From 13ebd0181429e280d22aa2d5e0ed350d96427904 Mon Sep 17 00:00:00 2001 From: maco Date: Wed, 23 Sep 2026 06:46:34 +0000 Subject: [PATCH] fix(server): show usage limits for every Claude instance A second Claude account could be marked unsupported after get_usage and then left off the Limits tab, even after a turn reported real windows. Subscription logins with a missing rate-limit body are a failed probe now, and a later turn can fill those windows in without the next probe wiping them. --- .../src/provider/Layers/ClaudeProvider.ts | 45 ++++++-- .../provider/Layers/ProviderRegistry.test.ts | 32 ++++++ .../provider/Layers/claudeUsageLimits.test.ts | 107 +++++++++++++++++- .../src/provider/Layers/claudeUsageLimits.ts | 94 ++++++++++++++- .../src/provider/providerUsageLimits.test.ts | 58 +++++++++- .../src/provider/providerUsageLimits.ts | 44 +++++-- packages/shared/src/usageLimits.test.ts | 75 ++++++++++++ packages/shared/src/usageLimits.ts | 7 +- 8 files changed, 432 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 62d7444c6968..423f4003f9bf 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -35,10 +35,9 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; -import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { type ClaudeScopedLimitNames, - claudeUsageResponseToLimits, + claudeProbeUsageLimits, recordClaudeUsageResponse, } from "./claudeUsageLimits.ts"; import { @@ -237,8 +236,9 @@ type ClaudeCapabilitiesProbe = { readonly slashCommands: ReadonlyArray; /** * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). + * `undefined` when the request itself failed. A present body with + * `rate_limits_available: false` is an account that cannot report, except + * a subscription login, which is classified as a failed read instead. */ readonly usage?: Pick; }; @@ -372,6 +372,17 @@ const probeClaudeCapabilities = ( rate_limits: usageResult.success.rate_limits, } : undefined; + // The raw body is not useful later; the flag and whether windows + // were present are enough to tell a locked instance from a failed read. + yield* Effect.logInfo( + "Claude get_usage probe finished.", + usage + ? { + rateLimitsAvailable: usage.rate_limits_available, + rateLimitsPresent: usage.rate_limits != null, + } + : { failed: true }, + ); const account = init.account as | { readonly email?: string; @@ -416,6 +427,12 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); +/** + * Build the Claude provider snapshot from the CLI version probe and the + * capabilities probe. Usage limits come from `claudeProbeUsageLimits`, so a + * subscription instance with an empty `get_usage` body is a failed read + * rather than an account with no quota. + */ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, resolveCapabilities?: ( @@ -560,14 +577,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); - const usageLimits = !capabilities.usage - ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) - : scopedLimitNames - ? yield* recordClaudeUsageResponse(scopedLimitNames, { - response: capabilities.usage, - checkedAt, - }) - : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + const usageProbe = { + usage: capabilities.usage, + account: { + subscriptionType: capabilities.subscriptionType, + tokenSource: capabilities.tokenSource, + apiProvider: capabilities.apiProvider, + }, + checkedAt, + }; + const usageLimits = scopedLimitNames + ? yield* recordClaudeUsageResponse(scopedLimitNames, usageProbe) + : claudeProbeUsageLimits(usageProbe).limits; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 354484e6d395..5ed6c1597124 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -144,6 +144,10 @@ type TestClaudeCapabilities = { readonly tokenSource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly usage?: { + readonly rate_limits_available: boolean; + readonly rate_limits: null; + }; }; function claudeCapabilities(overrides: Partial = {}) { @@ -2921,6 +2925,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect( + "does not mark a second subscription Claude instance unsupported when usage is empty", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + email: "other@example.com", + subscriptionType: "max", + tokenSource: "oauth", + usage: { rate_limits_available: false, rate_limits: null }, + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "other@example.com"); + assert.strictEqual(status.usageLimits?.unavailable?.reason, "probeFailed"); + assert.deepStrictEqual(status.usageLimits?.windows, []); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.test.ts b/apps/server/src/provider/Layers/claudeUsageLimits.test.ts index a3d326a7dcb2..dd9136eef683 100644 --- a/apps/server/src/provider/Layers/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/Layers/claudeUsageLimits.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "../providerUsageLimits.ts"; +import { + claudeAccountReportsSubscriptionUsage, + claudeProbeUsageLimits, + claudeRateLimitEventToUpdate, + claudeUsageResponseToLimits, +} from "./claudeUsageLimits.ts"; const checkedAt = "2026-07-18T10:00:00.000Z"; const noNames = { overageIncluded: undefined } as const; @@ -95,6 +101,15 @@ describe("claudeUsageResponseToLimits", () => { ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); }); + it("treats a null rate-limit body as a failed probe", () => { + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: null }, + }).limits, + ).toEqual({ checkedAt, windows: [], unavailable: { reason: "probeFailed" } }); + }); + it("skips a window the endpoint reports without a utilization", () => { expect( claudeUsageResponseToLimits({ @@ -119,6 +134,96 @@ describe("claudeUsageResponseToLimits", () => { }); }); +const noAccount = { + subscriptionType: undefined, + tokenSource: undefined, + apiProvider: undefined, +} as const; + +describe("claudeProbeUsageLimits", () => { + it("keeps API key and Bedrock logins unsupported", () => { + const unavailable = { rate_limits_available: false, rate_limits: null } as const; + expect( + claudeProbeUsageLimits({ + checkedAt, + usage: unavailable, + account: { ...noAccount, tokenSource: "ANTHROPIC_AUTH_TOKEN" }, + }).limits.unavailable?.reason, + ).toBe("unsupported"); + expect( + claudeProbeUsageLimits({ + checkedAt, + usage: unavailable, + account: { ...noAccount, apiProvider: "bedrock" }, + }).limits.unavailable?.reason, + ).toBe("unsupported"); + expect(claudeAccountReportsSubscriptionUsage({ ...noAccount, apiProvider: "vertex" })).toBe( + false, + ); + }); + + it("does not lock a subscription instance that returned no windows", () => { + const account = { subscriptionType: "max", tokenSource: "oauth", apiProvider: undefined }; + for (const usage of [ + { rate_limits_available: false, rate_limits: null }, + { rate_limits_available: true, rate_limits: null }, + ] as const) { + expect(claudeProbeUsageLimits({ checkedAt, usage, account }).limits.unavailable?.reason).toBe( + "probeFailed", + ); + } + expect( + claudeProbeUsageLimits({ + checkedAt, + usage: undefined, + account, + }).limits.unavailable?.reason, + ).toBe("probeFailed"); + }); + + it("publishes a second instance's turn windows and keeps them across the next bad probe", () => { + const account = { subscriptionType: "max", tokenSource: "oauth", apiProvider: undefined }; + const probed = claudeProbeUsageLimits({ + checkedAt, + account, + usage: { rate_limits_available: false, rate_limits: null }, + }); + const update = claudeRateLimitEventToUpdate( + { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1.09, + resetsAt: 1_789_938_600, + }, + probed.names, + ); + const recovered = applyUsageLimitsUpdate({ + previous: probed.limits, + checkedAt: "2026-09-20T18:58:27.751Z", + update: update!, + }); + expect(recovered?.windows).toEqual([ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 100, + windowDurationMins: 300, + resetsAt: "2026-09-20T21:10:00.000Z", + }, + ]); + expect(recovered?.unavailable?.reason).toBe("probeFailed"); + const again = claudeProbeUsageLimits({ + checkedAt: "2026-09-20T19:00:00.000Z", + account, + usage: { rate_limits_available: true, rate_limits: null }, + }); + expect(resolveUsageLimitsAfterProbe({ published: recovered, probed: again.limits })).toBe( + recovered, + ); + }); +}); + describe("claudeRateLimitEventToUpdate", () => { it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { expect( diff --git a/apps/server/src/provider/Layers/claudeUsageLimits.ts b/apps/server/src/provider/Layers/claudeUsageLimits.ts index b67645102e5a..b1f8c267075b 100644 --- a/apps/server/src/provider/Layers/claudeUsageLimits.ts +++ b/apps/server/src/provider/Layers/claudeUsageLimits.ts @@ -150,21 +150,60 @@ export function claudeRateLimitEventToUpdate( return undefined; } +const NON_SUBSCRIPTION_API_PROVIDERS = new Set(["bedrock", "vertex"]); + +/** + * Whether this Claude login can have subscription windows. + * + * API-key, Bedrock, and Vertex accounts cannot. A named subscription, or any + * other token source, can — a `get_usage` flag that says otherwise is a + * failed read, not proof the account has no quota. + */ +export function claudeAccountReportsSubscriptionUsage(account: { + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; +}): boolean { + const apiProvider = account.apiProvider?.trim().toLowerCase(); + if (apiProvider !== undefined && NON_SUBSCRIPTION_API_PROVIDERS.has(apiProvider)) return false; + const tokenSource = account.tokenSource?.toLowerCase().replace(/[\s_-]+/g, ""); + if ( + tokenSource === "apikey" || + tokenSource === "anthropicapikey" || + tokenSource === "anthropicauthtoken" + ) { + return false; + } + if (account.subscriptionType?.trim()) return true; + return Boolean(account.tokenSource?.trim()); +} + /** * Percentages on the `get_usage` response are already 0–100. Also yields the * scoped-bucket names the response carried, for the event mapper to reuse. + * + * `rate_limits_available: false` is an account the SDK says cannot report + * subscription windows. `rate_limits_available: true` with no `rate_limits` + * is a fetch that failed this time — Claude keeps the flag and nulls the + * body — so that is `probeFailed`, not a permanent unsupported lock. */ export function claudeUsageResponseToLimits(input: { readonly response: Pick; readonly checkedAt: string; }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { const { response, checkedAt } = input; - if (!response.rate_limits_available || !response.rate_limits) { + if (!response.rate_limits_available) { return { limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), names: { overageIncluded: undefined }, }; } + if (!response.rate_limits) { + return { + limits: makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }), + names: { overageIncluded: undefined }, + }; + } const windows: ServerProviderUsageWindow[] = []; for (const id of Object.keys(WINDOWS)) { const window = response.rate_limits[id as "five_hour" | "seven_day"]; @@ -189,11 +228,56 @@ export function claudeUsageResponseToLimits(input: { }; } -/** Probe-side helper: map the response and remember the scoped names for events. */ +/** + * Limits to publish from one Claude capabilities probe. + * + * No usage payload means the request failed. `unsupported` stands only when + * the account cannot have a subscription. A subscription login that comes + * back `unsupported`, including a second instance whose `get_usage` omits + * windows, is `probeFailed` so a later turn can fill the bars in. + */ +export function claudeProbeUsageLimits(input: { + readonly usage: + | Pick + | undefined; + readonly account: { + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; + }; + readonly checkedAt: string; +}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { + if (!input.usage) { + return { + limits: makeUnavailableUsageLimits({ checkedAt: input.checkedAt, reason: "probeFailed" }), + names: { overageIncluded: undefined }, + }; + } + const mapped = claudeUsageResponseToLimits({ + response: input.usage, + checkedAt: input.checkedAt, + }); + if ( + mapped.limits.unavailable?.reason === "unsupported" && + claudeAccountReportsSubscriptionUsage(input.account) + ) { + return { + limits: makeUnavailableUsageLimits({ checkedAt: input.checkedAt, reason: "probeFailed" }), + names: mapped.names, + }; + } + return mapped; +} + +/** + * Probe-side helper: map the response and remember the scoped names for events. + * A failed request leaves the names from the last successful body in place. + */ export const recordClaudeUsageResponse = ( namesRef: Ref.Ref, - input: Parameters[0], + input: Parameters[0], ): Effect.Effect => { - const { limits, names } = claudeUsageResponseToLimits(input); - return Ref.set(namesRef, names).pipe(Effect.as(limits)); + const probed = claudeProbeUsageLimits(input); + if (!input.usage) return Effect.succeed(probed.limits); + return Ref.set(namesRef, probed.names).pipe(Effect.as(probed.limits)); }; diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index 6e288ddd3a36..865233572ac0 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -52,14 +52,48 @@ describe("applyUsageLimitsUpdate", () => { }); }); - it("leaves an unsupported account and an empty update alone", () => { + it("leaves an empty update alone and recovers windows from a mistaken unsupported lock", () => { const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; - expect( - applyUsageLimitsUpdate({ previous: unsupported, checkedAt, update: { windows: [session] } }), - ).toBe(unsupported); expect( applyUsageLimitsUpdate({ previous: published, checkedAt, update: { windows: [] } }), ).toBe(published); + const next = applyUsageLimitsUpdate({ + previous: unsupported, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [session] }, + }); + expect(next).toEqual({ + checkedAt: "2026-09-03T12:00:05.000Z", + windows: [session], + unavailable: { reason: "probeFailed" }, + }); + }); + + it("keeps a failed probe marked when a sparse update lands on it", () => { + const failed = { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" as const, message: "usage timed out" }, + }; + const next = applyUsageLimitsUpdate({ + previous: failed, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [weekly] }, + }); + expect(next).toEqual({ + checkedAt: "2026-09-03T12:00:05.000Z", + windows: [weekly], + unavailable: failed.unavailable, + }); + expect( + applyUsageLimitsUpdate({ previous: next, checkedAt, update: { windows: [weekly] } }), + ).toBe(next); + }); + + it("marks a sparse update with no previous snapshot as probeFailed", () => { + expect( + applyUsageLimitsUpdate({ previous: undefined, checkedAt, update: { windows: [weekly] } }), + ).toEqual({ checkedAt, windows: [weekly], unavailable: { reason: "probeFailed" } }); }); it("preserves reset credits when a streamed window update changes usage", () => { @@ -86,4 +120,20 @@ describe("resolveUsageLimitsAfterProbe", () => { expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); }); + + it("keeps turn-reported windows through a later failed or unsupported probe", () => { + const failed = { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } }; + const partial = applyUsageLimitsUpdate({ + previous: failed, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [weekly] }, + }); + expect(resolveUsageLimitsAfterProbe({ published: partial, probed: failed })).toBe(partial); + expect( + resolveUsageLimitsAfterProbe({ + published: partial, + probed: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }), + ).toBe(partial); + }); }); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index ea8d0d1d029f..43d2626d7fd1 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -47,6 +47,20 @@ export function makeUnavailableUsageLimits(input: { }; } +/** + * A sparse update is not a full read. Keep `probeFailed` when there was no + * snapshot, or the previous one was a failed probe or a mistaken + * `unsupported`, so the windows do not become the last good full read. A + * snapshot that already has no `unavailable` stays unmarked. + */ +function incompleteAfterSparseUpdate( + previous: ServerProviderUsageLimits | undefined, +): ServerProviderUsageLimits["unavailable"] | undefined { + if (previous !== undefined && previous.unavailable === undefined) return undefined; + if (previous?.unavailable?.reason === "probeFailed") return previous.unavailable; + return { reason: "probeFailed" }; +} + /** * Fold a sparse runtime update into the limits a provider currently * publishes. Windows upsert by `id`; a window the update omits keeps its @@ -54,8 +68,11 @@ export function makeUnavailableUsageLimits(input: { * `windowDurationMins` keeps whatever the last probe resolved for it. An * update with no windows leaves `previous` untouched. * - * An `unsupported` snapshot stays unsupported: an account that cannot have - * subscription windows will not start reporting them mid-turn. + * An `unsupported` snapshot is not a permanent lock. A turn that reports a + * real window clears it: a second Claude account can be mislabeled at boot + * and still emit `rate_limit_event` utilization. The result stays + * `probeFailed` until a full probe succeeds, so one window does not look + * like the complete set. */ export function applyUsageLimitsUpdate(input: { readonly previous: ServerProviderUsageLimits | undefined; @@ -63,7 +80,7 @@ export function applyUsageLimitsUpdate(input: { readonly checkedAt: string; }): ServerProviderUsageLimits | undefined { const { previous, update } = input; - if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { + if (update.windows.length === 0) { return previous; } const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); @@ -88,12 +105,14 @@ export function applyUsageLimitsUpdate(input: { changed = true; } } - if (!changed && previous !== undefined && previous.unavailable === undefined) { + if (!changed && previous !== undefined) { return previous; } + const unavailable = incompleteAfterSparseUpdate(previous); return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), + ...(unavailable !== undefined ? { unavailable } : {}), }; } @@ -111,8 +130,10 @@ function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageW /** * Choose what to publish after a status probe finishes. A probe that failed * this time must not wipe bars a previous probe or a turn already - * established, so the last good snapshot stays; `unsupported` is - * authoritative and replaces them. + * established, so any snapshot that has windows stays. `unsupported` + * replaces a clean snapshot (an account that truly cannot report). It does + * not replace windows that only exist because a turn recovered a mistaken + * `unsupported` lock — those stay marked `probeFailed`. * * A successful probe replaces the published windows outright, including any * runtime update that landed while it was running. That is a deliberate @@ -127,7 +148,16 @@ export function resolveUsageLimitsAfterProbe(input: { readonly probed: ServerProviderUsageLimits | undefined; }): ServerProviderUsageLimits | undefined { const { published, probed } = input; - if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { + if (!probed?.unavailable || !published || published.windows.length === 0) { + return probed; + } + if (probed.unavailable.reason === "probeFailed") { + return published; + } + if ( + probed.unavailable.reason === "unsupported" && + published.unavailable?.reason === "probeFailed" + ) { return published; } return probed; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0ef53b1e7f55..e9e3cbfb5b70 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -81,6 +81,13 @@ describe("limitsNotice", () => { it("explains empty bars and passes provider messages through", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; expect(limitsNotice({ checkedAt, windows: [window] })).toBeNull(); + expect( + limitsNotice({ + checkedAt, + windows: [window], + unavailable: { reason: "probeFailed" }, + }), + ).toBeNull(); expect(limitsNotice({ checkedAt, windows: [] })).toBe("No limits reported."); expect(limitsNotice({ checkedAt, windows: [], unavailable: { reason: "unsupported" } })).toBe( "This account has no subscription limits.", @@ -740,6 +747,74 @@ describe("collectLimitNotices", () => { }); expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); }); + + it("draws a second Claude instance once a turn has reported its windows", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const first = provider({ + instanceId: ProviderInstanceId.make("claude-a"), + driver: claude, + displayName: "Claude", + auth: { status: "authenticated", email: "one@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const second = provider({ + instanceId: ProviderInstanceId.make("claude-b"), + driver: claude, + displayName: "Other Claude", + auth: { status: "authenticated", email: "two@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 80 }], + unavailable: { reason: "probeFailed" }, + }, + }); + const hidden = provider({ + instanceId: ProviderInstanceId.make("claude-api"), + driver: claude, + displayName: "API", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { providers: [first, second, hidden] }, + }, + ], + ]); + expect(collectLimitAccounts(input).map((account) => account.email)).toEqual([ + "one@example.com", + "two@example.com", + ]); + expect(collectLimitNotices(input)).toEqual([]); + }); + + it("names a second Claude instance whose probe failed and has no windows yet", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { + providers: [ + provider({ + instanceId: ProviderInstanceId.make("claude-b"), + driver: claude, + displayName: "Other Claude", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }), + ], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)).toEqual([]); + expect(collectLimitNotices(input)).toEqual(["Other Claude: Could not read limits."]); + }); }); describe("/usage-limits", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 17ba1feab5d6..9098cc595dbb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -391,8 +391,13 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); } -/** The one-line status under a provider heading when there are no bars to draw. */ +/** + * The one-line status under a provider heading when there are no bars to draw. + * Windows already in hand are drawn even when the last full probe failed, so + * a second Claude account is not omitted after a turn reports its quota. + */ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { + if (limits.windows.length > 0) return null; if (limits.unavailable?.reason === "unsupported") { return limits.unavailable.message ?? "This account has no subscription limits."; }