fix(server): show Claude model-scoped weekly usage limits (#2303)

The Claude usage card showed only Session and Weekly. Anthropic moved
model-scoped weekly limits out of the top-level `seven_day_<model>` keys
into a `limits[]` array, and `seven_day_opus` / `seven_day_omelette` now
return null, so the scoped bar silently disappeared. Nothing errored, so
the card looked healthy while under-reporting.

Scoped limits are now read from `limits[]`, restoring a `Weekly · Fable`
bar. Session and all-models weekly keep coming from the top-level keys,
which is also where the Claude CLI reads them from.

A response mid-migration can describe one limit twice, as a legacy key
and as a `limits[]` entry, so both shapes normalize into a single
`ScopedLimit` and one predicate decides whether two are the same limit:
same dimension, same id when both carry one, else same normalized name.
That is the only comparison in the file. Comparing display labels would
conflate a surface with a model of the same name, and collapse ids that
differ only by punctuation.

The `limits[]` entry supplies identity, since that is the shape the API
is migrating towards. Its `percent` and `resets_at` are nullable, so
each field falls back to the legacy twin rather than discarding a value
the response did carry.

Legacy scoped windows adopt the same id scheme, so `weekly_opus` becomes
`weekly_model_opus`. Window ids are internal React keys, not
user-facing, and unifying them is what lets a limit keep one id
whichever shape of the response carried it.

Two supporting changes:

- `limits[]` entries are validated one at a time. The response goes
  through a single parse, so one malformed entry would otherwise throw
  and take the windows that already parsed with it.
- The provider stores the logger it was already handed and warns when a
  response parses but yields no windows, when a scope resolves to no
  name, and when an entry is unparseable. This failure was silent at
  every level, which is why it went unnoticed. Warn rather than debug,
  because file logging defaults to info.

Scoped windows render even at 0% and inactive, so a bar does not appear
and disappear between refreshes.

Closes #2302

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-07-24 21:16:05 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 19565f6605
commit 99440201bd
2 changed files with 604 additions and 45 deletions
@@ -425,7 +425,7 @@ describe("real provider usage fetchers", () => {
windows: expect.arrayContaining([
expect.objectContaining({ id: "five_hour", usedPct: 11 }),
expect.objectContaining({ id: "weekly", usedPct: 1 }),
expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }),
expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }),
]),
});
expect(fetchApi).toHaveBeenCalledWith(
@@ -1025,3 +1025,353 @@ describe("usage bars escalate as they fill", () => {
);
});
});
// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the
// top-level `seven_day_*` keys, which now return null on most accounts.
describe("ClaudeQuotaProvider scoped weekly limits", () => {
let claudeHome: string;
beforeEach(() => {
claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-"));
});
afterEach(() => {
rmSync(claudeHome, { recursive: true, force: true });
});
function fableLimit(overrides: Record<string, unknown> = {}) {
return {
kind: "weekly_scoped",
group: "weekly",
percent: 0,
severity: "normal",
resets_at: "2026-06-04T00:00:00Z",
is_active: false,
scope: { model: { id: null, display_name: "Fable" }, surface: null },
...overrides,
};
}
function claudeProvider(body: unknown) {
writeClaudeCredentials(claudeHome, "at_valid");
const logger = createLogger() as unknown as { warn: ReturnType<typeof vi.fn> };
const provider = new ClaudeQuotaProvider({
logger: logger as never,
claudeHome,
claudeKeychainReader: async () => null,
fetch: mockFetch(
new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]),
),
});
return { provider, logger };
}
it("renders a scoped weekly limit as its own window", async () => {
const { provider } = claudeProvider({
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [fableLimit()],
});
const usage = await provider.fetchUsage();
expect(usage.windows).toContainEqual(
expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }),
);
});
it("renders a scoped window that is at zero and inactive", async () => {
const { provider } = claudeProvider({
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [fableLimit({ percent: 0, is_active: false })],
});
const usage = await provider.fetchUsage();
expect(usage.windows).toContainEqual(
expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }),
);
});
it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => {
const { provider } = claudeProvider({
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [
{ kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null },
{ kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null },
fableLimit(),
],
});
const usage = await provider.fetchUsage();
expect(usage.windows.map((window) => window.id)).toEqual([
"five_hour",
"weekly",
"weekly_model_fable",
]);
});
it("labels a surface-scoped limit from its surface name", async () => {
const { provider } = claudeProvider({
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [
fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }),
],
});
const usage = await provider.fetchUsage();
expect(usage.windows).toContainEqual(
expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }),
);
});
it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => {
const { provider, logger } = claudeProvider({
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })],
});
const usage = await provider.fetchUsage();
expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]);
expect(logger.warn).toHaveBeenCalled();
});
// Regression: an additive section must never take down data that already parsed.
it("keeps the top-level windows when a limits entry is malformed", async () => {
const { provider, logger } = claudeProvider({
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
limits: [{ percent: "not-a-kind" }, fableLimit()],
});
const usage = await provider.fetchUsage();
expect(usage.status).toBe("available");
expect(usage.windows.map((window) => window.id)).toEqual([
"five_hour",
"weekly",
"weekly_model_fable",
]);
expect(logger.warn).toHaveBeenCalled();
});
it("warns when a successful response describes no windows at all", async () => {
const { provider, logger } = claudeProvider({ limits: [] });
const usage = await provider.fetchUsage();
expect(usage.windows).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
"Claude usage response parsed but produced no windows",
);
});
});
/**
* The reconciliation matrix.
*
* Four review rounds on PR #2303 each found a different hole in how the two
* representations of one scoped limit get combined, because each fix was tested against
* the case that was reported rather than the space of cases. This walks the space:
* every combination of which representation carries the limit, whether the `limits[]`
* entry supplies values, and whether the two descriptions denote the same limit at all.
*/
describe("ClaudeQuotaProvider scoped limit reconciliation", () => {
let claudeHome: string;
beforeEach(() => {
claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-"));
});
afterEach(() => {
rmSync(claudeHome, { recursive: true, force: true });
});
const RESETS = "2026-06-04T00:00:00Z";
function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) {
return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope };
}
const model = (name: string | null, id: string | null = null) => ({
model: { id, display_name: name },
surface: null,
});
const surface = (name: string | null, id: string | null = null) => ({
model: null,
surface: { id, display_name: name },
});
async function windowsFor(body: Record<string, unknown>) {
writeClaudeCredentials(claudeHome, "at_valid");
const usage = await new ClaudeQuotaProvider({
logger: createLogger(),
claudeHome,
claudeKeychainReader: async () => null,
fetch: mockFetch(
new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]),
),
}).fetchUsage();
return usage.windows;
}
it("which representation carries the limit: legacy only", async () => {
const windows = await windowsFor({
seven_day_omelette: { utilization: 12, resets_at: RESETS },
});
expect(windows).toEqual([
expect.objectContaining({
id: "weekly_model_omelette",
label: "Weekly · Omelette",
usedPct: 12,
}),
]);
});
it("which representation carries the limit: limits[] only", async () => {
const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] });
expect(windows).toEqual([
expect.objectContaining({
id: "weekly_model_fable",
label: "Weekly · Fable",
usedPct: 2,
}),
]);
});
it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => {
const windows = await windowsFor({
seven_day_omelette: { utilization: 12, resets_at: RESETS },
limits: [scoped(model("Omelette"), 30)],
});
expect(windows).toEqual([
expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }),
]);
});
it("which representation carries the limit: both, different limits — two bars", async () => {
const windows = await windowsFor({
seven_day_opus: { utilization: 8, resets_at: RESETS },
limits: [scoped(model("Fable"), 2)],
});
expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]);
});
it("which representation carries the limit: neither", async () => {
const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } });
expect(windows.map((w) => w.id)).toEqual(["weekly"]);
});
const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } };
it("value fallback when the scoped entry is sparse: scoped values win when present", async () => {
const windows = await windowsFor({
...legacy,
limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")],
});
expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" });
});
it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => {
const windows = await windowsFor({
...legacy,
limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")],
});
expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" });
});
it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => {
const windows = await windowsFor({
...legacy,
limits: [scoped(model("Omelette"), 30, null)],
});
expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS });
});
it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => {
const windows = await windowsFor({
...legacy,
limits: [scoped(model("Omelette"), null, null)],
});
expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS });
});
it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => {
const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] });
expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null });
});
it("identity: a surface never matches a legacy model window of the same name", async () => {
const windows = await windowsFor({
seven_day_omelette: { utilization: 12, resets_at: RESETS },
limits: [scoped(surface("Omelette"), 30)],
});
expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]);
expect(windows[0]).toMatchObject({ usedPct: 12 });
expect(windows[1]).toMatchObject({ usedPct: 30 });
});
it("identity: a model and a surface of the same name stay apart", async () => {
const windows = await windowsFor({
limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)],
});
expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]);
});
it("identity: ids decide when both sides have one", async () => {
const windows = await windowsFor({
limits: [
scoped(model("Fable-Pro", "fable-pro"), 4),
scoped(model("Fable_Pro", "fable_pro"), 9),
],
});
expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]);
});
it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => {
const windows = await windowsFor({
limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)],
});
expect(windows).toEqual([
expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }),
]);
});
it("identity: a renamed scope keeps its id when the API supplies one", async () => {
const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] });
const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] });
expect(before[0]?.id).toBe("weekly_model_fable");
expect(after[0]?.id).toBe("weekly_model_fable");
expect(after[0]?.label).toBe("Weekly · Fable 5");
});
it("identity: a limit keeps one id whichever representation carries it", async () => {
const viaLegacy = await windowsFor({
seven_day_omelette: { utilization: 12, resets_at: RESETS },
});
const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] });
expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id);
});
it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => {
const windows = await windowsFor({
five_hour: { utilization: 6, resets_at: RESETS },
seven_day: { utilization: 23, resets_at: RESETS },
seven_day_opus: { utilization: 8, resets_at: RESETS },
limits: [
{ kind: "session", percent: 6, resets_at: RESETS, scope: null },
{ kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null },
scoped(model("Fable"), 2),
],
});
expect(windows.map((w) => w.id)).toEqual([
"five_hour",
"weekly",
"weekly_model_opus",
"weekly_model_fable",
]);
});
});