From e7fdc99afd705e97bbe6785078408a6d10b8985a Mon Sep 17 00:00:00 2001 From: LunarECL Date: Thu, 23 Jul 2026 19:19:40 +0900 Subject: [PATCH] feat(ai): report Anthropic extra usage --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth-storage.ts | 8 +- packages/ai/src/usage/claude.ts | 126 ++++- .../ai/test/auth-storage-usage-cache.test.ts | 20 +- packages/ai/test/claude-usage-headers.test.ts | 498 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/cli/usage-cli.ts | 11 +- .../modes/controllers/command-controller.ts | 34 +- .../slash-commands/helpers/usage-report.ts | 10 +- .../coding-agent/test/acp-builtins.test.ts | 34 ++ packages/coding-agent/test/usage-cli.test.ts | 17 + .../test/usage-report-column-align.test.ts | 57 ++ .../test/usage-report-tui-notes.test.ts | 55 ++ 13 files changed, 860 insertions(+), 12 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b90401767..df692f26d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ - Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels. - Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting. +- Added Anthropic extra-usage reporting across `omp usage`, interactive `/usage`, and ACP `/usage`: the OAuth usage endpoint's authoritative `spend` payload (or legacy `extra_usage` fallback when absent) is normalized into a `Claude Extra Usage` USD row; capped accounts show limit/remaining/fractions and status, while uncapped spend exposes only its absolute used amount—rendered as `$… used` in CLI/TUI and `123.45 usd used` in ACP—without a fabricated cap, percentage, or status. ([#5575](https://github.com/can1357/oh-my-pi/issues/5575)) ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index b4d8d01e7..5ae0793d5 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -635,10 +635,10 @@ const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial "google-antigravity": 2, zai: 2, // v2: cache identity gained an `org:` component so two subscriptions on one - // account email stop sharing a slot. The bump also retires pre-org entries — - // otherwise an org-less credential could replay another org's cached pool - // (incl. the 24h last-good fallback) via the old bare email/account key. - anthropic: 2, + // account email stop sharing a slot. v3 retires parsed reports created before + // Anthropic extra-usage rows existed; header ingestion can otherwise keep + // renewing those incomplete reports throughout the 24h last-good retention. + anthropic: 3, }; const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000; /** diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index fbcf5cd82..f43d1e326 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -63,6 +63,31 @@ interface ParsedUsageBucket { utilization?: number; resetsAt?: number; } + +interface ClaudeExtraUsage { + is_enabled?: boolean; + monthly_limit?: number | null; + used_credits?: number; + decimal_places?: number; + currency?: string; +} + +interface ClaudeMoneyAmount { + amount_minor?: number; + currency?: string; + exponent?: number; +} + +interface ClaudeSpend { + used?: ClaudeMoneyAmount | null; + limit?: ClaudeMoneyAmount | null; + enabled?: boolean; +} + +interface ParsedClaudeExtraUsage { + used: number; + limit?: number; +} type ClaudeUnifiedWindow = "5h" | "7d" | "7d_oi"; type ClaudeModelKind = "opus" | "sonnet" | "fable" | "mythos"; @@ -72,6 +97,8 @@ interface ClaudeUsageResponse { seven_day_opus?: ClaudeUsageBucket | null; seven_day_sonnet?: ClaudeUsageBucket | null; limits?: unknown; + extra_usage?: ClaudeExtraUsage | null; + spend?: ClaudeSpend | null; } interface ClaudeApiLimitModelScope { @@ -199,7 +226,8 @@ function hasUsageData(payload: ClaudeUsageResponse): boolean { parseBucket(payload.seven_day)?.utilization !== undefined || parseBucket(payload.seven_day_opus)?.utilization !== undefined || parseBucket(payload.seven_day_sonnet)?.utilization !== undefined || - parseApiLimitEntries(payload.limits).some(entry => entry.bucket.utilization !== undefined) + parseApiLimitEntries(payload.limits).some(entry => entry.bucket.utilization !== undefined) || + buildClaudeExtraUsageLimit(payload) !== null ); } @@ -364,6 +392,101 @@ function buildUsageStatus(usedFraction: number | undefined): UsageStatus | undef return "ok"; } +function parseDollarAmount( + amountMinor: unknown, + exponent: unknown, + currency: unknown, + currencyRequired: boolean, +): number | undefined { + if ( + typeof amountMinor !== "number" || + !Number.isSafeInteger(amountMinor) || + amountMinor < 0 || + typeof exponent !== "number" || + !Number.isSafeInteger(exponent) || + exponent < 0 + ) { + return undefined; + } + if (currency === undefined) { + if (currencyRequired) return undefined; + } else if (typeof currency !== "string" || currency.toUpperCase() !== "USD") { + return undefined; + } + const divisor = 10 ** exponent; + if (!Number.isFinite(divisor)) return undefined; + const dollars = amountMinor / divisor; + return Number.isFinite(dollars) ? dollars : undefined; +} + +function parseSpendExtraUsage(value: unknown): ParsedClaudeExtraUsage | null { + if (!isRecord(value) || value.enabled !== true || !Object.hasOwn(value, "limit") || !isRecord(value.used)) { + return null; + } + const used = parseDollarAmount(value.used.amount_minor, value.used.exponent, value.used.currency, true); + if (used === undefined) return null; + if (value.limit === null) return { used }; + if (!isRecord(value.limit)) return null; + const limit = parseDollarAmount(value.limit.amount_minor, value.limit.exponent, value.limit.currency, true); + // Reject non-positive caps rather than normalizing them into contradictory zero fractions. + return limit === undefined || limit <= 0 ? null : { used, limit }; +} + +function parseLegacyExtraUsage(value: unknown): ParsedClaudeExtraUsage | null { + if (!isRecord(value) || value.is_enabled !== true || !Object.hasOwn(value, "monthly_limit")) return null; + const decimalPlaces = value.decimal_places === undefined ? 2 : value.decimal_places; + const used = parseDollarAmount(value.used_credits, decimalPlaces, value.currency, false); + if (used === undefined) return null; + if (value.monthly_limit === null || value.monthly_limit === undefined) return { used }; + const limit = parseDollarAmount(value.monthly_limit, decimalPlaces, value.currency, false); + return limit === undefined || limit <= 0 ? null : { used, limit }; +} + +function buildExtraUsageAmount(used: number, limit: number | undefined): UsageAmount | undefined { + if (limit === undefined) return { used, unit: "usd" }; + const remaining = Math.max(0, limit - used); + const usedFraction = used / limit; + const remainingFraction = remaining / limit; + if (!Number.isFinite(remaining) || !Number.isFinite(usedFraction) || !Number.isFinite(remainingFraction)) { + return undefined; + } + return { + used, + unit: "usd", + limit, + remaining, + usedFraction, + remainingFraction, + }; +} + +function buildClaudeExtraUsageLimit(payload: ClaudeUsageResponse): UsageLimit | null { + const parsed = + payload.spend === null || payload.spend === undefined + ? parseLegacyExtraUsage(payload.extra_usage) + : parseSpendExtraUsage(payload.spend); + if (!parsed) return null; + + const amount = buildExtraUsageAmount(parsed.used, parsed.limit); + if (!amount) return null; + const status = + parsed.limit === undefined + ? undefined + : parsed.used >= parsed.limit + ? "exhausted" + : (buildUsageStatus(amount.usedFraction) ?? "ok"); + return { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { + provider: "anthropic", + windowId: "extra", + }, + amount, + ...(status !== undefined ? { status } : {}), + }; +} + function buildUsageLimit(args: { id: string; label: string; @@ -546,6 +669,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext tier: "sonnet", }), ...buildScopedWeeklyUsageLimits(apiLimitEntries), + buildClaudeExtraUsageLimit(payload), ].filter((limit): limit is UsageLimit => limit !== null); if (limits.length === 0) return null; diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index fad014b51..eb0f2f6a0 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -229,6 +229,22 @@ describe("AuthStorage usage cache: last-good failure fallback", () => { expect(calls).toBe(1); }); + it("cold-fetches instead of replaying a report stored under the previous Anthropic cache version", async () => { + const previousVersionKey = "usage_cache:report:2:anthropic:default:oauth|account:account-1|email:a@example.com"; + store.cache.set(previousVersionKey, { + value: JSON.stringify({ value: makeReport("a@example.com"), expiresAt: Date.now() + 60_000 }), + expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000), + }); + const base = makeReport("a@example.com"); + const freshReport = { ...base, metadata: { ...base.metadata, source: "fresh-v3-fetch" } }; + const fetchSpy = vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockResolvedValue(freshReport); + + const reports = anthropicReports(await storage.fetchUsageReports()); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(reports[0]?.metadata?.source).toBe("fresh-v3-fetch"); + }); + it("caches null on a cold failure for the backoff window, then retries after it expires", async () => { let calls = 0; vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => { @@ -596,7 +612,7 @@ describe("AuthStorage usage cache: terminal refresh failure", () => { if (row.credential.type !== "oauth") throw new Error("expected OAuth test credential"); row.credential.expires = Date.now() - 1000; const store = makeStore([row]); - const cacheKey = "usage_cache:report:2:anthropic:default:oauth|account:account-3|email:expired@example.com"; + const cacheKey = "usage_cache:report:3:anthropic:default:oauth|account:account-3|email:expired@example.com"; store.cache.set(cacheKey, { value: JSON.stringify({ value: makeReport("expired@example.com"), expiresAt: 1 }), expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000), @@ -654,7 +670,7 @@ describe("AuthStorage usage cache: terminal refresh failure", () => { }; const lastGood = makeReport("b@example.com"); - const cacheKey = "usage_cache:report:2:anthropic:default:oauth|account:account-2|email:b@example.com"; + const cacheKey = "usage_cache:report:3:anthropic:default:oauth|account:account-2|email:b@example.com"; cache.set(cacheKey, { value: JSON.stringify({ value: lastGood, expiresAt: 1 }), expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000), diff --git a/packages/ai/test/claude-usage-headers.test.ts b/packages/ai/test/claude-usage-headers.test.ts index 4d30e7db9..000b53598 100644 --- a/packages/ai/test/claude-usage-headers.test.ts +++ b/packages/ai/test/claude-usage-headers.test.ts @@ -28,6 +28,46 @@ function getHeaderCaseInsensitive( return undefined; } +async function fetchClaudeUsageReport(payload: Record): Promise { + const fetchMock = (async () => { + return new Response( + JSON.stringify({ + five_hour: { utilization: 42 }, + ...payload, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + return claudeUsageProvider.fetchUsage( + { + provider: "anthropic", + credential: { + type: "oauth", + accessToken: "oat-test-access-token", + accountId: "account_test", + email: "user@example.com", + expiresAt: Date.now() + 60_000, + }, + }, + { fetch: fetchMock }, + ); +} + +function money(amountMinor: number) { + return { amount_minor: amountMinor, currency: "USD", exponent: 2 }; +} + +function legacyExtraUsage(usedCredits: number, monthlyLimit: number | null) { + return { + is_enabled: true, + monthly_limit: monthlyLimit, + used_credits: usedCredits, + decimal_places: 2, + currency: "USD", + }; +} + describe("claude usage request headers", () => { it("sends aligned anthropic fingerprint and bearer auth headers", async () => { const now = Date.now(); @@ -307,6 +347,436 @@ describe("claude usage request headers", () => { expect(session?.amount.used).toBe(16); expect(weekly?.amount.used).toBe(18); }); + + it("accepts an extra-only payload on the first usage fetch", async () => { + const calls: string[] = []; + const fetchMock = (async (input: string | URL) => { + calls.push(String(input)); + return new Response( + JSON.stringify({ + spend: { + enabled: true, + used: money(1_234), + limit: null, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const report = await claudeUsageProvider.fetchUsage( + { + provider: "anthropic", + credential: { + type: "oauth", + accessToken: "oat-test-access-token", + accountId: "account_test", + email: "user@example.com", + expiresAt: Date.now() + 60_000, + }, + }, + { fetch: fetchMock }, + ); + + expect(calls.filter(url => url.endsWith("/usage"))).toEqual(["https://api.anthropic.com/api/oauth/usage"]); + expect(report?.limits.map(limit => limit.id)).toEqual(["anthropic:extra"]); + expect(report?.limits[0]?.amount).toEqual({ used: 12.34, unit: "usd" }); + }); + + it("accepts a legacy-only extra payload on the first usage fetch", async () => { + const calls: string[] = []; + const fetchMock = (async (input: string | URL) => { + calls.push(String(input)); + return new Response( + JSON.stringify({ + extra_usage: { + is_enabled: true, + used_credits: 1_234, + monthly_limit: 10_000, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const report = await claudeUsageProvider.fetchUsage( + { + provider: "anthropic", + credential: { + type: "oauth", + accessToken: "oat-test-access-token", + accountId: "account_test", + email: "user@example.com", + expiresAt: Date.now() + 60_000, + }, + }, + { fetch: fetchMock }, + ); + + expect(calls.filter(url => url.endsWith("/usage"))).toEqual(["https://api.anthropic.com/api/oauth/usage"]); + expect(report?.limits.map(limit => limit.id)).toEqual(["anthropic:extra"]); + expect(report?.limits[0]?.amount).toMatchObject({ used: 12.34, limit: 100, unit: "usd" }); + }); +}); + +describe("claude extra usage", () => { + function extraLimit(report: UsageReport | null): UsageLimit | undefined { + return report?.limits.find(limit => limit.id === "anthropic:extra"); + } + + it("normalizes capped zero spend without inventing a reset window", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(0), + limit: money(50_000), + }, + }); + + expect(extraLimit(report)).toEqual({ + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { + provider: "anthropic", + windowId: "extra", + }, + amount: { + used: 0, + limit: 500, + remaining: 500, + usedFraction: 0, + remainingFraction: 1, + unit: "usd", + }, + status: "ok", + }); + }); + + it("computes capped spend fractions and the warning threshold from dollar amounts", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(45_000), + limit: money(50_000), + percent: 0, + }, + }); + + const extra = extraLimit(report); + expect(extra?.amount).toEqual({ + used: 450, + limit: 500, + remaining: 50, + usedFraction: 0.9, + remainingFraction: 0.1, + unit: "usd", + }); + expect(extra?.status).toBe("warning"); + }); + + it("marks current spend at exactly the cap as exhausted", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(50_000), + limit: money(50_000), + }, + }); + + const extra = extraLimit(report); + expect(extra?.amount.usedFraction).toBe(1); + expect(extra?.amount.remainingFraction).toBe(0); + expect(extra?.status).toBe("exhausted"); + }); + + it("marks current spend above the cap as exhausted", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(62_500), + limit: money(50_000), + }, + }); + + const extra = extraLimit(report); + expect(extra?.amount).toEqual({ + used: 625, + limit: 500, + remaining: 0, + usedFraction: 1.25, + remainingFraction: 0, + unit: "usd", + }); + expect(extra?.status).toBe("exhausted"); + }); + + it("normalizes uncapped current spend without trusting the upstream percent", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(12_345), + limit: null, + percent: 0, + }, + }); + + const extra = extraLimit(report); + expect(extra?.amount).toEqual({ + used: 123.45, + unit: "usd", + }); + expect(extra).not.toHaveProperty("status"); + }); + + it("honors the current spend exponent and case-insensitive USD currency", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: { + amount_minor: 12_345, + currency: "usd", + exponent: 3, + }, + limit: null, + }, + }); + + expect(extraLimit(report)?.amount).toEqual({ + used: 12.345, + unit: "usd", + }); + }); + + it("uses valid current spend instead of legacy extra_usage", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: money(500), + limit: money(1_000), + }, + extra_usage: legacyExtraUsage(9_900, 10_000), + }); + + expect(extraLimit(report)?.amount).toEqual({ + used: 5, + limit: 10, + remaining: 5, + usedFraction: 0.5, + remainingFraction: 0.5, + unit: "usd", + }); + }); + + it("treats disabled current spend as authoritative over valid legacy data", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: false, + used: money(500), + limit: null, + }, + extra_usage: legacyExtraUsage(9_900, 10_000), + }); + + expect(extraLimit(report)).toBeUndefined(); + }); + + it.each([ + ["non-object", "unavailable"], + ["non-true gate", { enabled: "yes", used: money(500), limit: null }], + ["invalid used", { enabled: true, used: { amount_minor: "invalid", currency: "USD", exponent: 2 }, limit: null }], + ["fractional used", { enabled: true, used: { amount_minor: 1.5, currency: "USD", exponent: 2 }, limit: null }], + ["negative used", { enabled: true, used: { amount_minor: -1, currency: "USD", exponent: 2 }, limit: null }], + [ + "fractional exponent", + { enabled: true, used: { amount_minor: 500, currency: "USD", exponent: 1.5 }, limit: null }, + ], + ["negative exponent", { enabled: true, used: { amount_minor: 500, currency: "USD", exponent: -1 }, limit: null }], + ["non-USD used", { enabled: true, used: { amount_minor: 500, currency: "EUR", exponent: 2 }, limit: null }], + ["missing used exponent", { enabled: true, used: { amount_minor: 500, currency: "USD" }, limit: null }], + ["missing used currency", { enabled: true, used: { amount_minor: 500, exponent: 2 }, limit: null }], + ["missing limit key", { enabled: true, used: money(500) }], + [ + "invalid limit", + { enabled: true, used: money(500), limit: { amount_minor: "invalid", currency: "USD", exponent: 2 } }, + ], + ["zero limit", { enabled: true, used: money(500), limit: money(0) }], + ["negative limit", { enabled: true, used: money(500), limit: money(-100) }], + [ + "non-USD limit", + { enabled: true, used: money(500), limit: { amount_minor: 1_000, currency: "EUR", exponent: 2 } }, + ], + ])("does not fall back when authoritative current spend is malformed: %s", async (_name, spend) => { + const report = await fetchClaudeUsageReport({ + spend, + extra_usage: legacyExtraUsage(1_234, 10_000), + }); + + expect(extraLimit(report)).toBeUndefined(); + }); + + it.each([ + ["absent", {}], + ["null", { spend: null }], + ])("falls back to legacy extra_usage only when current spend is %s", async (_name, current) => { + const report = await fetchClaudeUsageReport({ + ...current, + extra_usage: legacyExtraUsage(1_234, 10_000), + }); + + const amount = extraLimit(report)?.amount; + expect(amount).toMatchObject({ + used: 12.34, + limit: 100, + remaining: 87.66, + unit: "usd", + }); + expect(amount?.usedFraction).toBeCloseTo(0.1234); + expect(amount?.remainingFraction).toBeCloseTo(0.8766); + }); + + it.each([ + [ + "unsafe used minor units", + { + enabled: true, + used: { amount_minor: Number.MAX_SAFE_INTEGER + 1, currency: "USD", exponent: 2 }, + limit: null, + }, + ], + [ + "unsafe used exponent", + { + enabled: true, + used: { amount_minor: 1, currency: "USD", exponent: Number.MAX_SAFE_INTEGER + 1 }, + limit: null, + }, + ], + [ + "non-finite used divisor", + { enabled: true, used: { amount_minor: 1, currency: "USD", exponent: 309 }, limit: null }, + ], + [ + "unsafe limit minor units", + { + enabled: true, + used: money(1), + limit: { amount_minor: Number.MAX_SAFE_INTEGER + 1, currency: "USD", exponent: 2 }, + }, + ], + [ + "unsafe limit exponent", + { + enabled: true, + used: money(1), + limit: { amount_minor: 1, currency: "USD", exponent: Number.MAX_SAFE_INTEGER + 1 }, + }, + ], + ])("rejects unsafe or non-finite current money: %s", async (_name, spend) => { + const report = await fetchClaudeUsageReport({ spend }); + expect(extraLimit(report)).toBeUndefined(); + }); + + it("rejects capped current spend whose finite dollar amounts produce an infinite fraction", async () => { + const report = await fetchClaudeUsageReport({ + spend: { + enabled: true, + used: { + amount_minor: Number.MAX_SAFE_INTEGER, + currency: "USD", + exponent: 0, + }, + limit: { + amount_minor: 1, + currency: "USD", + exponent: 308, + }, + }, + }); + + expect(extraLimit(report)).toBeUndefined(); + }); + + it("defaults the recorded legacy shape without currency or decimal_places to cents", async () => { + const report = await fetchClaudeUsageReport({ + extra_usage: { + is_enabled: true, + monthly_limit: 10_000, + used_credits: 1_234, + }, + }); + + const amount = extraLimit(report)?.amount; + expect(amount).toMatchObject({ + used: 12.34, + limit: 100, + remaining: 87.66, + unit: "usd", + }); + expect(amount?.usedFraction).toBeCloseTo(0.1234); + expect(amount?.remainingFraction).toBeCloseTo(0.8766); + }); + + it("honors non-default legacy decimal_places scaling", async () => { + const report = await fetchClaudeUsageReport({ + extra_usage: { + is_enabled: true, + monthly_limit: 50_000, + used_credits: 12_345, + decimal_places: 3, + currency: "usd", + }, + }); + + expect(extraLimit(report)?.amount).toEqual({ + used: 12.345, + limit: 50, + remaining: 37.655, + usedFraction: 0.2469, + remainingFraction: 0.7531, + unit: "usd", + }); + }); + + it("normalizes uncapped legacy spend without fabricating a cap or status", async () => { + const report = await fetchClaudeUsageReport({ + extra_usage: { + is_enabled: true, + monthly_limit: null, + used_credits: 1_234, + }, + }); + + const extra = extraLimit(report); + expect(extra?.amount).toEqual({ used: 12.34, unit: "usd" }); + expect(extra).not.toHaveProperty("status"); + }); + + it.each([ + ["disabled gate", { is_enabled: false, monthly_limit: 10_000, used_credits: 1_234 }], + ["non-true gate", { is_enabled: "yes", monthly_limit: 10_000, used_credits: 1_234 }], + ["missing monthly_limit", { is_enabled: true, used_credits: 1_234 }], + ["non-USD currency", { is_enabled: true, monthly_limit: 10_000, used_credits: 1_234, currency: "EUR" }], + ["invalid used", { is_enabled: true, monthly_limit: 10_000, used_credits: "invalid" }], + ["fractional used", { is_enabled: true, monthly_limit: 10_000, used_credits: 1.5 }], + ["unsafe used", { is_enabled: true, monthly_limit: 10_000, used_credits: Number.MAX_SAFE_INTEGER + 1 }], + [ + "fractional decimal_places", + { is_enabled: true, monthly_limit: 10_000, used_credits: 1_234, decimal_places: 1.5 }, + ], + [ + "unsafe decimal_places", + { is_enabled: true, monthly_limit: 10_000, used_credits: 1_234, decimal_places: Number.MAX_SAFE_INTEGER + 1 }, + ], + [ + "non-finite decimal divisor", + { is_enabled: true, monthly_limit: 10_000, used_credits: 1_234, decimal_places: 309 }, + ], + ["zero cap", { is_enabled: true, monthly_limit: 0, used_credits: 0 }], + ["negative cap", { is_enabled: true, monthly_limit: -100, used_credits: 0 }], + ])("rejects disabled or malformed legacy extra_usage: %s", async (_name, extra_usage) => { + const report = await fetchClaudeUsageReport({ extra_usage }); + expect(extraLimit(report)).toBeUndefined(); + }); }); describe("claude ranking strategy", () => { @@ -359,10 +829,38 @@ describe("claude ranking strategy", () => { usedFraction: args.fableWeeklyUsed, tier: "fable", }), + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { + used: 100, + unit: "usd", + limit: 100, + remaining: 0, + usedFraction: 1, + remainingFraction: 0, + }, + status: "exhausted", + }, ], }; } + it("keeps capped extra usage display-only for ranking and gating", () => { + const report = usageReportWithWeeklyCaps({ sharedWeeklyUsed: 0.18, fableWeeklyUsed: 0.64 }); + const windows = claudeRankingStrategy.findWindowLimits(report, { modelId: "claude-opus-4-8" }); + const scopeLimits = claudeRankingStrategy.scopeLimits; + if (!scopeLimits) throw new Error("expected claude scopeLimits"); + + expect(windows.primary?.id).toBe("anthropic:5h"); + expect(windows.secondary?.id).toBe("anthropic:7d"); + expect(scopeLimits(report, { modelId: "claude-opus-4-8" }).map(limit => limit.id)).toEqual([ + "anthropic:5h", + "anthropic:7d", + ]); + }); + it("scopes credential gating to shared umbrella limits plus the requested model tier", () => { const limits: UsageLimit[] = [ { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 102529b6a..465199a88 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -30,6 +30,7 @@ - Added opt-in RPC protocol v2 negotiation with bounded, lossless chunking for stdout objects up to 64 MiB, plus stable cursor-based message pages for histories that should not travel as one response. Legacy JSONL clients remain on protocol v1, while the bundled TypeScript and Python RPC clients negotiate, reassemble, and drain message pages automatically. - Fixed protocol v2 chunked framing materializing the whole base64 transport in memory: near-limit logical frames (~63 MiB) peaked around 686 MB RSS and over-ceiling frames allocated the full payload buffer before rejection. Chunk lines are now produced lazily from a single serialization, the 64 MiB ceiling is checked before any full-payload allocation, and RPC stdout writes honor backpressure line by line. - Fixed the bundled TypeScript and Python RPC clients throwing when a `get_messages_page` cursor went stale mid-walk (e.g. a background bash appending a message between pages): the high-level `getMessages()` drains now discard partial pages and fall back to the legacy snapshot on both `session_busy` and `stale_cursor`, driven by a new machine-readable `code` field on RPC error responses. Direct page calls remain strict. +- Fixed used-only absolute usage amounts across output surfaces: CLI now renders `$123.45 used`; the TUI shows a neutral, width-bounded amount instead of a pending/dotted/account-count placeholder; and ACP preserves `123.45 usd used` while suppressing duplicate window suffixes such as `— extra`. ([#5575](https://github.com/can1357/oh-my-pi/issues/5575)) ## [17.0.8] - 2026-07-22 diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 38302f536..91c456ba3 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -208,14 +208,23 @@ function describeAmount(limit: UsageLimit): string { const amount = limit.amount; const parts: string[] = []; const absoluteUnit = amount.unit !== "percent" && amount.unit !== "unknown"; + const fraction = resolveUsedFraction(limit); if (absoluteUnit && amount.used !== undefined && amount.limit !== undefined) { parts.push( `${formatUnitValue(amount.used, amount.unit)} / ${formatUnitValue(amount.limit, amount.unit)}${UNIT_SUFFIX[amount.unit]}`, ); } else if (absoluteUnit && amount.remaining !== undefined) { parts.push(`${formatUnitValue(amount.remaining, amount.unit)}${UNIT_SUFFIX[amount.unit]} left`); + } else if ( + absoluteUnit && + amount.used !== undefined && + Number.isFinite(amount.used) && + amount.limit === undefined && + amount.remaining === undefined && + fraction === undefined + ) { + parts.push(`${formatUnitValue(amount.used, amount.unit)}${UNIT_SUFFIX[amount.unit]} used`); } - const fraction = resolveUsedFraction(limit); if (fraction !== undefined) { parts.push(`${(fraction * 100).toFixed(1)}% used`); } else if (amount.remainingFraction !== undefined) { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 5db5d352b..e2f86c875 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1593,11 +1593,28 @@ function padColumn(text: string, width: number): string { return `${text}${padding(width - visible)}`; } -function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] { +type AggregateDisplayStatus = NonNullable | "neutral"; + +function isUsedOnlyAbsoluteAmount(limit: UsageLimit): boolean { + const amount = limit.amount; + return ( + amount.unit !== "percent" && + amount.unit !== "unknown" && + amount.used !== undefined && + Number.isFinite(amount.used) && + amount.limit === undefined && + amount.remaining === undefined && + resolveUsedFraction(limit) === undefined + ); +} + +function resolveAggregateStatus(limits: UsageLimit[]): AggregateDisplayStatus { const hasOk = limits.some(limit => limit.status === "ok"); const hasWarning = limits.some(limit => limit.status === "warning"); const hasExhausted = limits.some(limit => limit.status === "exhausted"); - if (!hasOk && !hasWarning && !hasExhausted) return "unknown"; + if (!hasOk && !hasWarning && !hasExhausted) { + return limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount) ? "neutral" : "unknown"; + } if (hasOk) { return hasWarning || hasExhausted ? "warning" : "ok"; } @@ -1625,6 +1642,8 @@ function formatAggregateAmount(limits: UsageLimit[]): string { return `${formatNumber(remainingPct)}% free`; } + if (limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount)) return ""; + // Count unique accounts from limit scopes — not limits.length. const uniqueAccountIds = new Set( limits.map(limit => limit.scope.accountId).filter((id): id is string => typeof id === "string" && id.length > 0), @@ -1707,7 +1726,8 @@ export function formatCompactQuota( return `Quota: ${lines.join(" │ ")}`; } -function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string { +function resolveStatusIcon(status: AggregateDisplayStatus, uiTheme: typeof theme): string { + if (status === "neutral") return uiTheme.fg("dim", uiTheme.status.info); if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error); if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning); if (status === "ok") return uiTheme.fg("success", uiTheme.status.success); @@ -1722,6 +1742,14 @@ function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning" } function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme, barWidth: number): string { + const usedAmount = limit.amount.used; + if (usedAmount !== undefined && isUsedOnlyAbsoluteAmount(limit)) { + const used = + limit.amount.unit === "usd" + ? `$${usedAmount.toFixed(2)}` + : `${formatNumber(usedAmount, 2)} ${limit.amount.unit}`; + return uiTheme.fg("dim", truncateJobLabel(`${used} used`, barWidth)); + } const fraction = resolveUsedFraction(limit); if (fraction === undefined) { return uiTheme.fg("dim", "·".repeat(barWidth)); diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 88f999b4c..43d9aaa67 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -12,6 +12,14 @@ function formatProviderName(provider: string): string { .join(" "); } +function formatWindowSuffix(label: string, windowLabel: string | undefined): string { + if (!windowLabel) return ""; + const normalizedLabel = label.toLowerCase(); + const normalizedWindow = windowLabel.toLowerCase(); + if (normalizedWindow === "quota window" || normalizedLabel.includes(normalizedWindow)) return ""; + return ` — ${windowLabel}`; +} + function formatUsageAmount(limit: UsageLimit): string { const amount = limit.amount; const used = amount.used ?? (amount.usedFraction !== undefined ? amount.usedFraction * 100 : undefined); @@ -118,7 +126,7 @@ function renderUsageReports( limit.scope.tier && !limit.label.toLowerCase().includes(limit.scope.tier.toLowerCase()) ? ` (${limit.scope.tier})` : ""; - lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`); + lines.push(`- ${limit.label}${tier}${formatWindowSuffix(limit.label, window)}`); lines.push( ` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}${inUse ? " ← in use by this session" : ""}`, ); diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index a65d4923d..1f5995fe4 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -274,6 +274,40 @@ describe("ACP builtin slash commands", () => { expect(output[0]).toContain("user@example.com: 0.24 unknown used (76.0% left)"); expect(output[0]).toContain("resets in"); }); + + it("suppresses redundant usage window suffixes while retaining legitimate ones", async () => { + const { output, runtime } = createRuntime(); + runtime.session.fetchUsageReports = async () => [ + { + provider: "anthropic", + fetchedAt: Date.now(), + limits: [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { used: 123.45, unit: "usd" }, + }, + { + id: "anthropic:daily", + label: "Daily quota", + scope: { provider: "anthropic", windowId: "24h" }, + window: { id: "24h", label: "24 hours" }, + amount: { used: 20, unit: "requests" }, + }, + ], + metadata: { email: "user@example.com" }, + }, + ]; + + const result = await executeAcpBuiltinSlashCommand("/usage", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Claude Extra Usage"); + expect(output[0]).not.toContain("Claude Extra Usage — extra"); + expect(output[0]).toContain("123.45 usd used"); + expect(output[0]).toContain("Daily quota — 24 hours"); + }); it("/usage show renders the same report as plain /usage", async () => { const now = 1_700_000_000_000; const nowSpy = spyOn(Date, "now").mockReturnValue(now); diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index b65adcc91..641d98172 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -208,6 +208,23 @@ describe("formatUsageBreakdown", () => { { provider: "cerebras", type: "api_key" }, ]; + it("renders used-only USD spend without fabricating quota data", () => { + const spendReport = makeReport("anthropic", "spend@example.test", [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { used: 123.45, unit: "usd" }, + }, + ]); + + const text = stripVTControlCharacters(formatUsageBreakdown([spendReport], [], Date.now())); + + expect(text).toContain("$123.45 used"); + expect(text).not.toContain("no data"); + expect(text).not.toContain("%"); + expect(text).not.toContain("resets"); + }); it("renders every account: reported ones with limits, credential-only ones as no-data rows", () => { const text = stripVTControlCharacters(formatUsageBreakdown(reports, accounts, Date.now())); expect(text).toContain("dummy.primary@example.test"); diff --git a/packages/coding-agent/test/usage-report-column-align.test.ts b/packages/coding-agent/test/usage-report-column-align.test.ts index 11eb14ef0..8d078632a 100644 --- a/packages/coding-agent/test/usage-report-column-align.test.ts +++ b/packages/coding-agent/test/usage-report-column-align.test.ts @@ -33,6 +33,34 @@ function acct(email: string, total: number, fiveH: number): UsageReport { } satisfies UsageReport; } +function spendAcct(email: string, used: number, limit?: number): UsageReport { + const amount = + limit === undefined + ? ({ used, unit: "usd" } as const) + : ({ + used, + unit: "usd", + limit, + remaining: Math.max(0, limit - used), + usedFraction: used / limit, + remainingFraction: Math.max(0, limit - used) / limit, + } as const); + return { + provider: "anthropic", + fetchedAt: Date.now(), + metadata: { email }, + limits: [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount, + ...(limit === undefined ? {} : { status: "ok" as const }), + }, + ], + }; +} + describe("renderUsageReports multi-account column alignment (#6067)", () => { it("keeps account columns in the same order across every window row", () => { // Account A: weekly exhausted, 5h free. Account B: weekly light, 5h exhausted. @@ -53,4 +81,33 @@ describe("renderUsageReports multi-account column alignment (#6067)", () => { const fiveHOrder = columnOrder("5h limit"); expect(fiveHOrder).toEqual(totalOrder); }); + + it("keeps all-used-only amount cells within four-column narrow widths", () => { + const reports = [spendAcct("first@example.test", 123.45), spendAcct("second@example.test", 67.89)]; + const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 11)); + const lines = text.split("\n"); + const headerIdx = lines.findIndex(line => line.includes("Claude Extra Usage")); + expect(headerIdx).toBeGreaterThanOrEqual(0); + const amountRow = lines[headerIdx + 2]!; + const cells = amountRow.trim().split(/\s+/); + + expect(cells).toHaveLength(2); + expect(cells.every(cell => Bun.stringWidth(cell) <= 4)).toBe(true); + expect(Bun.stringWidth(amountRow)).toBeLessThanOrEqual(11); + }); + + it("keeps mixed capped and used-only amount cells aligned at narrow widths", () => { + const reports = [spendAcct("capped@example.test", 50, 100), spendAcct("uncapped@example.test", 123.45)]; + const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 20)); + const lines = text.split("\n"); + const headerIdx = lines.findIndex(line => line.includes("Claude Extra Usage")); + expect(headerIdx).toBeGreaterThanOrEqual(0); + const labelRow = lines[headerIdx + 1]!; + const amountRow = lines[headerIdx + 2]!; + const summaryStart = amountRow.lastIndexOf(" 2 accts"); + + expect(summaryStart).toBeGreaterThanOrEqual(0); + expect(Bun.stringWidth(labelRow)).toBe(11); + expect(Bun.stringWidth(amountRow.slice(2, summaryStart))).toBe(9); + }); }); diff --git a/packages/coding-agent/test/usage-report-tui-notes.test.ts b/packages/coding-agent/test/usage-report-tui-notes.test.ts index 4b57346d2..52c385c17 100644 --- a/packages/coding-agent/test/usage-report-tui-notes.test.ts +++ b/packages/coding-agent/test/usage-report-tui-notes.test.ts @@ -107,6 +107,61 @@ describe("renderUsageReports (#3268 TUI aggregate)", () => { expect(text).toContain("rae@example.com (Team Org)"); }); + + it("renders used-only absolute amounts with neutral status and no account summary", () => { + const reports: UsageReport[] = [ + report("anthropic", "spend@example.test", [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { used: 123.45, unit: "usd" }, + }, + ]), + ]; + + const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 120)); + + expect(text).toContain(theme.status.info); + expect(text).not.toContain(theme.status.pending); + expect(text).toContain("$123.45 used"); + expect(text).not.toContain("1 accts"); + }); + + it("preserves capped aggregate status when a group mixes capped and used-only amounts", () => { + const reports: UsageReport[] = [ + report("anthropic", "capped@example.test", [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { + used: 50, + limit: 100, + remaining: 50, + usedFraction: 0.5, + remainingFraction: 0.5, + unit: "usd", + }, + status: "ok", + }, + ]), + report("anthropic", "spend@example.test", [ + { + id: "anthropic:extra", + label: "Claude Extra Usage", + scope: { provider: "anthropic", windowId: "extra" }, + amount: { used: 123.45, unit: "usd" }, + }, + ]), + ]; + + const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 160)); + + expect(text).toContain(theme.status.success); + expect(text).toContain("$123.45 used"); + expect(text).toContain("2 accts"); + }); }); describe("renderUsageReports session marker (#5691 org-qualified identity)", () => {