mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -41,11 +41,29 @@ const ClaudeUsageWindowSchema = z.object({
|
||||
resets_at: z.string().nullish(),
|
||||
});
|
||||
|
||||
// Model- and surface-scoped weekly limits live in a `limits[]` array rather than a
|
||||
// top-level `seven_day_<model>` key. Entries are validated one at a time (see
|
||||
// scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down
|
||||
// the windows that already parsed from the top-level keys.
|
||||
const ClaudeScopeLabelSchema = z
|
||||
.object({ id: z.string().nullish(), display_name: z.string().nullish() })
|
||||
.nullish();
|
||||
|
||||
const ClaudeLimitSchema = z.object({
|
||||
kind: z.string(),
|
||||
percent: ApiNumberSchema.nullish(),
|
||||
resets_at: z.string().nullish(),
|
||||
scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(),
|
||||
});
|
||||
|
||||
const ClaudeUsageResponseSchema = z.object({
|
||||
five_hour: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_opus: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_omelette: ClaudeUsageWindowSchema.nullish(),
|
||||
// Deliberately permissive: an additive section must never regress the top-level
|
||||
// windows, so shape validation happens per entry rather than here.
|
||||
limits: z.array(z.unknown()).nullish(),
|
||||
extra_usage: z
|
||||
.object({
|
||||
is_enabled: z.boolean().optional(),
|
||||
@@ -61,6 +79,9 @@ const ClaudeTokenRefreshSchema = z.object({
|
||||
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
|
||||
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
|
||||
type ClaudeTokenRefresh = z.infer<typeof ClaudeTokenRefreshSchema>;
|
||||
type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
|
||||
|
||||
const SCOPED_WEEKLY_KIND = "weekly_scoped";
|
||||
|
||||
interface ClaudeCredentialRecord {
|
||||
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
||||
@@ -85,6 +106,197 @@ function buildClaudePlan(
|
||||
return tier ? `${label} ${tier}` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* A weekly limit scoped to one model or one surface, normalized away from whichever
|
||||
* shape of the response described it.
|
||||
*
|
||||
* The API describes the same limit two ways during the migration: a legacy top-level
|
||||
* `seven_day_<model>` key, and an entry in `limits[]`. Everything downstream works on
|
||||
* this one representation so the two shapes are reconciled exactly once, in
|
||||
* `reconcileScopedLimits`, rather than at each place a window is built.
|
||||
*/
|
||||
interface ScopedLimit {
|
||||
dimension: "model" | "surface";
|
||||
/** The API's own identifier. Null on every response observed so far. */
|
||||
id: string | null;
|
||||
/** Display name, or the id when the API sends no display name. */
|
||||
name: string;
|
||||
usedPct: number | null;
|
||||
resetsAt: string | null;
|
||||
}
|
||||
|
||||
// Windows that describe no particular model or surface.
|
||||
const UNSCOPED_WINDOWS: ReadonlyArray<{
|
||||
field: "five_hour" | "seven_day";
|
||||
id: string;
|
||||
label: string;
|
||||
}> = [
|
||||
{ field: "five_hour", id: "five_hour", label: "Session" },
|
||||
{ field: "seven_day", id: "weekly", label: "Weekly" },
|
||||
];
|
||||
|
||||
// Scoped windows from before `limits[]` existed. Declaring the dimension here is what
|
||||
// stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model*
|
||||
// window: these keys are model-scoped by definition.
|
||||
const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{
|
||||
field: "seven_day_opus" | "seven_day_omelette";
|
||||
name: string;
|
||||
}> = [
|
||||
{ field: "seven_day_opus", name: "Opus" },
|
||||
{ field: "seven_day_omelette", name: "Omelette" },
|
||||
];
|
||||
|
||||
/** Fold a name down to the characters an id is allowed to carry. */
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two descriptions denote the same limit. This is the single definition of
|
||||
* identity for scoped limits; nothing else may compare them, and in particular nothing
|
||||
* may compare display labels, which are presentation rather than identity.
|
||||
*
|
||||
* - Different dimensions are never the same limit, so a surface and a model sharing a
|
||||
* name stay apart.
|
||||
* - When both sides carry the API's own id, that id decides, so `fable-pro` and
|
||||
* `fable_pro` stay apart.
|
||||
* - Otherwise fall back to the normalized name, which is the only link available between
|
||||
* a legacy key (never has an id) and its `limits[]` counterpart.
|
||||
*/
|
||||
function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean {
|
||||
if (a.dimension !== b.dimension) return false;
|
||||
if (a.id && b.id) return a.id === b.id;
|
||||
return normalizeName(a.name) === normalizeName(b.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the legacy and `limits[]` descriptions into one limit per identity.
|
||||
*
|
||||
* A `limits[]` entry wins on identity because that is the representation the API is
|
||||
* migrating towards, so a limit keeps the same window id whichever shape carried it.
|
||||
* Its values are nullable though, so each field falls back to the legacy twin instead of
|
||||
* discarding a number the response did contain.
|
||||
*/
|
||||
function reconcileScopedLimits(
|
||||
legacy: ScopedLimit[],
|
||||
fromLimitsArray: ScopedLimit[],
|
||||
): ScopedLimit[] {
|
||||
const reconciled = [...legacy];
|
||||
for (const limit of fromLimitsArray) {
|
||||
const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit));
|
||||
if (index === -1) {
|
||||
reconciled.push(limit);
|
||||
continue;
|
||||
}
|
||||
const twin = reconciled[index];
|
||||
reconciled[index] = {
|
||||
...limit,
|
||||
usedPct: limit.usedPct ?? twin?.usedPct ?? null,
|
||||
resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null,
|
||||
};
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
function scopedLimitFromLegacy(
|
||||
spec: (typeof LEGACY_SCOPED_WINDOWS)[number],
|
||||
window: z.infer<typeof ClaudeUsageWindowSchema>,
|
||||
): ScopedLimit {
|
||||
return {
|
||||
dimension: "model",
|
||||
id: null,
|
||||
name: spec.name,
|
||||
usedPct: window.utilization,
|
||||
resetsAt: window.resets_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The scope of a `limits[]` entry, or null when it names nothing renderable. */
|
||||
function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null {
|
||||
for (const dimension of ["model", "surface"] as const) {
|
||||
const entry = limit.scope?.[dimension];
|
||||
const id = entry?.id?.trim() || null;
|
||||
const name = entry?.display_name?.trim() || id;
|
||||
if (name) {
|
||||
return {
|
||||
dimension,
|
||||
id,
|
||||
name,
|
||||
usedPct: limit.percent ?? null,
|
||||
resetsAt: limit.resets_at ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The client uses window ids as React keys, so they must be stable across refreshes and
|
||||
// unique within a response. An API-supplied id is already an identifier and is used
|
||||
// verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`);
|
||||
// only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and
|
||||
// `fable_pro` into one window.
|
||||
function scopedWindowId(limit: ScopedLimit): string {
|
||||
return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`;
|
||||
}
|
||||
|
||||
// Backstop for the one residual case identity cannot rule out: an entry whose verbatim id
|
||||
// equals another entry's normalized name. Suffix rather than drop, because a missing bar
|
||||
// is the bug this change exists to fix.
|
||||
function uniqueWindowId(candidate: string, taken: Set<string>): string {
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
for (let suffix = 2; ; suffix += 1) {
|
||||
const next = `${candidate}_${suffix}`;
|
||||
if (!taken.has(next)) return next;
|
||||
}
|
||||
}
|
||||
|
||||
function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] {
|
||||
const limits: ScopedLimit[] = [];
|
||||
for (const spec of LEGACY_SCOPED_WINDOWS) {
|
||||
const window = resp[spec.field];
|
||||
if (window) limits.push(scopedLimitFromLegacy(spec, window));
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] {
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
for (const spec of UNSCOPED_WINDOWS) {
|
||||
const window = resp[spec.field];
|
||||
if (!window) continue;
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
utilizationPct: window.utilization,
|
||||
resetsAt: window.resets_at ?? null,
|
||||
tone: toneFromUsedPct(window.utilization),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] {
|
||||
const taken = new Set<string>();
|
||||
return limits.map((limit) => {
|
||||
const id = uniqueWindowId(scopedWindowId(limit), taken);
|
||||
taken.add(id);
|
||||
// Emitted even at 0% and inactive: a zero bar answers "how much of this model have I
|
||||
// used", and the bar must not come and go between refreshes.
|
||||
return windowFromUsedPct({
|
||||
id,
|
||||
label: `Weekly \u00b7 ${limit.name}`,
|
||||
utilizationPct: limit.usedPct,
|
||||
resetsAt: limit.resetsAt,
|
||||
tone: toneFromUsedPct(limit.usedPct),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readClaudeKeychainCredentials(): Promise<unknown | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
@@ -104,12 +316,14 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "claude";
|
||||
readonly displayName = "Claude";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly claudeHome: string;
|
||||
private readonly readKeychainCredentials: () => Promise<unknown | null>;
|
||||
private readonly platform: typeof process.platform;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: ClaudeQuotaProviderOptions) {
|
||||
this.logger = options.logger.child({ module: "claude-quota-provider" });
|
||||
this.claudeHome =
|
||||
options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude");
|
||||
this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials;
|
||||
@@ -149,50 +363,17 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
if (resp.five_hour) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
utilizationPct: resp.five_hour.utilization,
|
||||
resetsAt: resp.five_hour.resets_at ?? null,
|
||||
tone: toneFromUsedPct(resp.five_hour.utilization),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: resp.seven_day.utilization,
|
||||
resetsAt: resp.seven_day.resets_at ?? null,
|
||||
tone: toneFromUsedPct(resp.seven_day.utilization),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_opus) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_opus",
|
||||
label: "Weekly · Opus",
|
||||
utilizationPct: resp.seven_day_opus.utilization,
|
||||
resetsAt: resp.seven_day_opus.resets_at ?? null,
|
||||
tone: toneFromUsedPct(resp.seven_day_opus.utilization),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_omelette) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_omelette",
|
||||
label: "Weekly · Omelette",
|
||||
utilizationPct: resp.seven_day_omelette.utilization,
|
||||
resetsAt: resp.seven_day_omelette.resets_at ?? null,
|
||||
tone: toneFromUsedPct(resp.seven_day_omelette.utilization),
|
||||
}),
|
||||
);
|
||||
const scoped = reconcileScopedLimits(
|
||||
legacyScopedLimits(resp),
|
||||
this.scopedLimitsFromResponse(resp.limits),
|
||||
);
|
||||
const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)];
|
||||
|
||||
if (windows.length === 0) {
|
||||
// The response parsed but described nothing. That silence is how the previous
|
||||
// shape change went unnoticed, so make it greppable. `warn` and not `debug`
|
||||
// because file logging defaults to `info`.
|
||||
this.logger.warn("Claude usage response parsed but produced no windows");
|
||||
}
|
||||
|
||||
const details: ProviderUsageDetail[] = [];
|
||||
@@ -217,6 +398,34 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped limits carried by `limits[]`.
|
||||
*
|
||||
* Entries are validated one at a time so a single malformed or newly-shaped entry
|
||||
* cannot fail the whole response and take the windows that already parsed with it.
|
||||
*/
|
||||
private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] {
|
||||
if (!limits) return [];
|
||||
|
||||
const parsed: ScopedLimit[] = [];
|
||||
for (const entry of limits) {
|
||||
const result = ClaudeLimitSchema.safeParse(entry);
|
||||
if (!result.success) {
|
||||
this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry");
|
||||
continue;
|
||||
}
|
||||
if (result.data.kind !== SCOPED_WEEKLY_KIND) continue;
|
||||
|
||||
const limit = scopedLimitFromEntry(result.data);
|
||||
if (!limit) {
|
||||
this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name");
|
||||
continue;
|
||||
}
|
||||
parsed.push(limit);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
const credPath = join(this.claudeHome, ".credentials.json");
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user