From c9b03eb309510bed509229241a8c66501def5c33 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 6 Nov 2026 12:48:19 -0800 Subject: [PATCH] fix(usage): report enterprise spending limits --- docs/providers.md | 3 + .../claude-usage-source/server/usage.test.ts | 55 +++++++++++++++++++ plugins/claude-usage-source/server/usage.ts | 32 +++++++++-- .../codex-usage-source/server/usage.test.ts | 32 +++++++++++ plugins/codex-usage-source/server/usage.ts | 31 +++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 60fb75a785..191ab8aeb3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -195,6 +195,9 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag contract, account and window identity, provider-derived period names, login fallback, and read-only credential rules. Usage adapters own the interpretation of provider fields; the app renders their names and resolves pins without provider-specific duration guesses. +Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency +units and displayed only for USD; the balance contract does not represent other currencies. +Codex individual spending limits provide percentages, not dollar amounts. --- diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index 29e78a54e9..f8490b20ed 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -162,6 +162,61 @@ describe("claude usage source", () => { expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("reports USD extra usage monthly limit and used credits in dollars", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse({ + ...makeClaudeResponse(), + extra_usage: { + is_enabled: true, + monthly_limit: 5000, + used_credits: 1250, + currency: "USD", + }, + }), + ], + ]), + ); + + const claude = findProvider(await service().listUsage(), "claude"); + + expect(claude.balances).toEqual([ + expect.objectContaining({ + id: "extra_usage_monthly", + used: 12.5, + remaining: 37.5, + limit: 50, + unit: "usd", + }), + ]); + expect(claude.details).toContainEqual( + expect.objectContaining({ id: "extra_usage", value: "Enabled" }), + ); + }); + + it("does not report extra usage balances without explicit USD currency", async () => { + writeClaudeCredentials(claudeHome, "at_valid"); + fetchApi = mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse({ + ...makeClaudeResponse(), + extra_usage: { monthly_limit: 5000, used_credits: 1250, currency: "EUR" }, + }), + ], + ]), + ); + + const claude = findProvider(await service().listUsage(), "claude"); + + expect(claude.balances).toEqual([]); + }); it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { writeClaudeCredentials(claudeHome, "at_valid"); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 9efade2062..5056416466 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -68,6 +68,9 @@ const ClaudeUsageResponseSchema = z.object({ extra_usage: z .object({ is_enabled: z.boolean().optional(), + monthly_limit: ApiNumberSchema.nullish(), + used_credits: ApiNumberSchema.nullish(), + currency: z.string().nullish(), }) .nullish(), }); @@ -491,6 +494,26 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec : null; } +function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) { + if (extraUsage?.currency?.toUpperCase() !== "USD") return []; + const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100; + const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100; + if (used == null && limit == null) return []; + return [ + { + id: "extra_usage_monthly", + label: "Extra usage", + used, + remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined, + limit, + unit: "usd" as const, + tone: toneFromUsedPct( + used != null && limit != null && limit > 0 ? (used / limit) * 100 : null, + ), + }, + ]; +} + export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, @@ -594,20 +617,21 @@ export async function fetchUsage( } const details: UsageDetail[] = []; - const extraUsageEnabled = resp.extra_usage?.is_enabled; - if (extraUsageEnabled !== undefined) { + const extraUsage = resp.extra_usage; + if (extraUsage?.is_enabled !== undefined) { details.push({ id: "extra_usage", label: "Extra usage", - value: extraUsageEnabled ? "Enabled" : "Disabled", + value: extraUsage.is_enabled ? "Enabled" : "Disabled", }); } + const balances = extraUsageBalances(extraUsage); return { status: "available", planLabel: plan ?? undefined, windows, - balances: [], + balances, details, }; } diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 4cdefa2bcd..18247c9f71 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -117,6 +117,38 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () ]); }); +test("reports enterprise individual spend limit percentage and reset", async () => { + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ + plan_type: "enterprise", + spend_control: { + individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 }, + }, + }), + ); + + expect(report).toMatchObject({ + planLabel: "enterprise", + windows: [ + expect.objectContaining({ + id: "individual_limit", + label: "Individual spend limit", + summary: true, + usedPct: 5, + remainingPct: 95, + resetsAt: new Date(1795996800 * 1000).toISOString(), + }), + ], + }); +}); + +test("omits individual spend limit without a percentage", async () => { + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }), + ); + expect(report.windows).toEqual([]); +}); + test("summarizes the session and weekly windows by default, not code review", async () => { const report = await fetchUsage( authInput(fixtureHome), diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 0b211726c6..135948f4a0 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -34,6 +34,15 @@ const rateLimitSchema = z.object({ primary_window: windowSchema.nullish(), secondary_window: windowSchema.nullish(), }); +const spendControlSchema = z.object({ + individual_limit: z + .object({ + used_percent: number.nullish(), + remaining_percent: number.nullish(), + reset_at: number.nullish(), + }) + .nullish(), +}); const responseSchema = z.object({ plan_type: z.string().optional(), email: z.string().optional(), @@ -48,6 +57,7 @@ const responseSchema = z.object({ ) .nullish(), code_review_rate_limit: rateLimitSchema.nullish(), + spend_control: spendControlSchema.nullish(), credits: z.object({ balance: number.optional() }).nullish(), }); @@ -227,6 +237,27 @@ export async function fetchUsage( }, }); }), + ...(usage.spend_control?.individual_limit + ? (() => { + const limit = usage.spend_control.individual_limit; + const usedPct = + limit.used_percent ?? + (limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined); + if (usedPct === undefined && limit.remaining_percent == null) return []; + return [ + { + id: "individual_limit", + label: "Individual spend limit", + summary: true, + usedPct, + remainingPct: limit.remaining_percent, + resetsAt: + limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null, + tone: toneFromUsedPct(usedPct), + }, + ]; + })() + : []), ...usageWindows({ rateLimit: usage.code_review_rate_limit, scope: { id: "code_review", label: "Code review" },