Compare commits

...
Author SHA1 Message Date
f56a1add91 fix(usage): complete enterprise spending percentages
Derive missing remaining percentages so CLI spending output matches the Usage card. Preserve explicitly reported percentages and cover missing currency and absent rolling windows.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: JasprW <wang@jaspr.me>
2026-11-07 00:21:38 +00:00
Matt Cowger c9b03eb309 fix(usage): report enterprise spending limits 2026-11-06 12:48:19 -08:00
5 changed files with 170 additions and 4 deletions
+3
View File
@@ -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.
---
@@ -162,6 +162,64 @@ describe("claude usage source", () => {
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...(hasWindows ? 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.each(["EUR", null, undefined])(
"does not report extra usage balances for currency %j",
async (currency) => {
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 },
}),
],
]),
);
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");
+28 -4
View File
@@ -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,
};
}
@@ -117,6 +117,56 @@ 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.each([
[{ used_percent: 5 }, 5, 95],
[{ remaining_percent: 95 }, 5, 95],
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
[{ used_percent: 110 }, 110, 0],
])(
"completes individual spending percentages for %j",
async (individual_limit, usedPct, remainingPct) => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit } }),
);
expect(report).toMatchObject({
status: "available",
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
});
},
);
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),
@@ -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) return [];
return [
{
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct,
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
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" },