From 570e6ab2929efd1d8f62f00e160836d95fddc16a Mon Sep 17 00:00:00 2001 From: John McLaren Date: Tue, 25 Aug 2026 21:31:30 +0000 Subject: [PATCH 1/2] feat(providers): show subscription usage for Claude and Codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving two or three subscriptions all day, there was no way to see which one still had room without leaving the app. The choice of provider was made blind, and the first sign that a window was exhausted was a refused turn mid-task. Both providers already reported this and T3 Code already dropped it: the Claude and Codex adapters translate the CLIs' native rate-limit messages into `account.rate-limits.updated`, and `ProviderRuntimeIngestion` has no case for it. Rather than reconstruct the window set from that stream — each Claude event names only the window that just moved — the status probes now read the providers' structured snapshot APIs, which return every window at once. Both probes already spawn the CLI and ask it an account question, so the allowance is known before the first turn and the picker is useful cold. `ServerProvider` gains an optional `subscriptionUsage`, riding the existing `providerStatuses` push, so there is no new channel and no contract bump. It is stripped from the on-disk status cache alongside `updateState`, and aged out client-side, so a restart or a tab left open overnight shows nothing rather than a stale allowance. Rendered in two places: the bottom of the model picker, for the provider selected in the rail, and a Subscription section in the context bubble's popover, for the provider the thread is running on. Values cross the wire as percent used and always render as "N% left". Notes: - `Effect.timeoutOption` cannot bound the Claude request. It does not interrupt a `tryPromise` whose promise never settles, and a CLI that does not implement the control request never answers it, hanging the probe past its own ceiling. The deadline lives inside the promise instead, as an `AbortSignal.timeout` raced against the call. Codex needs none of this — its client resolves through an interruptible `Deferred.await`. - The request is skipped unless the account reports a subscription, so API-key, Bedrock, Vertex and logged-out instances never pay the deadline. - The SDK method is experimental and documented to be renamed, and its per-model buckets ship ahead of the npm types, so the call site takes `unknown` and the normalizer validates. No lockfile bump was needed: those buckets come over the wire from the user's own installed CLI. - Cursor, Grok and OpenCode report nothing and show nothing, matching the usage page's existing Claude/Codex scope. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AEwwDVNes37aYjwtUgNhEi --- FORK.md | 84 +++++- .../Layers/ClaudeCapabilitiesProbe.test.ts | 29 +- .../src/provider/Layers/ClaudeProvider.ts | 141 ++++++--- .../src/provider/Layers/CodexProvider.ts | 29 +- apps/server/src/provider/providerSnapshot.ts | 3 + .../src/provider/providerStatusCache.ts | 9 +- .../providerSubscriptionUsage.test.ts | 219 ++++++++++++++ .../src/provider/providerSubscriptionUsage.ts | 279 ++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 18 ++ .../components/chat/ContextWindowMeter.tsx | 28 +- .../components/chat/ModelPickerContent.tsx | 35 +++ .../chat/SubscriptionUsage.logic.test.ts | 97 ++++++ .../chat/SubscriptionUsage.logic.ts | 120 ++++++++ .../chat/SubscriptionUsageMeters.tsx | 65 ++++ docs/user/composer.md | 17 ++ packages/contracts/src/server.ts | 52 ++++ 16 files changed, 1186 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/provider/providerSubscriptionUsage.test.ts create mode 100644 apps/server/src/provider/providerSubscriptionUsage.ts create mode 100644 apps/web/src/components/chat/SubscriptionUsage.logic.test.ts create mode 100644 apps/web/src/components/chat/SubscriptionUsage.logic.ts create mode 100644 apps/web/src/components/chat/SubscriptionUsageMeters.tsx diff --git a/FORK.md b/FORK.md index e8ef332ea9e8..5baee2e26fab 100644 --- a/FORK.md +++ b/FORK.md @@ -11,8 +11,9 @@ GitHub-hosted runners instead of upstream's Blacksmith ones, nothing needing cre fork lacks, and unsigned desktop artifacts published as pruned development-build prereleases), plus fork identity (this file, the `README.md` banner, the `AGENTS.md` policy sections, no update checking, a sidebar link to this repo) and a handful of source changes: multi-instance -provider support (15, 16, 19), a configurable worktree branch prefix (17), and five web UX -changes (5, 6, 7, 18, 21). Everything else — `native/`, `scripts/`, `infra/`, `pnpm-lock.yaml`, +provider support (15, 16, 19), a configurable worktree branch prefix (17), provider +subscription usage in the picker and context bubble (22), and five web UX changes +(5, 6, 7, 18, 21). Everything else — `native/`, `scripts/`, `infra/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `apps/server/src/persistence/` — is byte-identical to upstream. This file is the authoritative list of what sets this fork apart, and it is written to be used @@ -518,6 +519,85 @@ untouched lines; that is not an intentional edit. button next to the scope menu and no menu item. - **Browser-only:** the menu item placement and the widened trigger row. +### 22. Subscription usage for Claude and Codex, in the picker and the context bubble + +- **Intent.** A user driving two or three subscriptions all day has no way to see which one has + room left without leaving the app, so the choice of provider is made blind and the first + signal that a window is exhausted is a refused turn mid-task. Both priority providers already + report this and T3 Code already received it and dropped it on the floor: `ClaudeAdapter` and + `CodexAdapter` translate the CLIs' native rate-limit messages into + `account.rate-limits.updated`, `providerRuntime.ts` declares the event, and + `ProviderRuntimeIngestion` has no case for it. The allowance is now read where the provider + is chosen (the model picker) and where the current turn's cost is already shown (the context + bubble under the composer). +- **Files:** `packages/contracts/src/server.ts`, + `apps/server/src/provider/providerSubscriptionUsage.ts` (new), + `apps/server/src/provider/{providerSnapshot,providerStatusCache}.ts`, + `apps/server/src/provider/Layers/{ClaudeProvider,CodexProvider}.ts`, + `apps/web/src/components/chat/{SubscriptionUsage.logic.ts,SubscriptionUsageMeters.tsx,ContextWindowMeter.tsx,ModelPickerContent.tsx,ChatComposer.tsx}`, + plus `providerSubscriptionUsage.test.ts`, `SubscriptionUsage.logic.test.ts` and the + `get_usage` round-trip added to `ClaudeCapabilitiesProbe.test.ts` +- **Re-apply notes.** `ServerProvider` gains `subscriptionUsage` as an `optionalKey`, following + `versionAdvisory`/`updateState`; it rides the existing `providerStatuses` push, so no new + channel and no contract version bump. Decisions worth keeping: + 1. **Read the snapshot, do not accumulate the stream.** Claude's `rate_limit_event` carries + one window per event (`rateLimitType` is a single value), so reconstructing the full set + from the stream means holding state and still showing nothing until a turn has run. The + SDK's structured `/usage` control request returns every window at once, including the + per-model weekly buckets. Codex has the same shape available as `account/rateLimits/read`. + 2. **Collected in the status probe, not during a turn.** Both probes already spawn the CLI and + already ask it an account question (`account/read`; the SDK initialization handshake), so + the allowance is known before the first turn and the picker is useful cold. No new poll + loop was added — a probe spawns a process, and the existing refresh cadence is the budget. + 3. **`Effect.timeoutOption` does not bound the Claude request.** It cannot interrupt a + `tryPromise` whose promise never settles, and a CLI that does not implement the control + request simply never answers it — the probe hangs past its own 25s ceiling. The deadline + therefore lives _inside_ the promise, as an `AbortSignal.timeout` raced against the call + and folded together with the fiber's signal. Codex needs none of this: its client resolves + through `Deferred.await`, which is interruptible, so the ordinary Effect timeout works. + `ClaudeCapabilitiesProbe.test.ts` is the regression guard — its fake CLI answered only + `initialize`, and the un-deadlined version hung it for the full 120s test timeout. + 4. **The request is gated on `subscriptionType`.** Only a claude.ai plan has windows to + report, so API-key, Bedrock, Vertex and logged-out instances skip the call entirely rather + than paying the deadline every refresh to learn what the account payload already said. + 5. **The SDK is read structurally, not by its types.** The method is named + `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` and documented to be renamed; + `model_scoped` (the per-model buckets, e.g. Fable) ships server-side ahead of the npm + types and is absent from the pinned `0.3.170` typings. The call site takes `unknown` and + the normalizer validates, so both a rename and a field arriving early are non-events — + and **no lockfile bump was needed**, since the per-model buckets come over the wire from + the user's own installed CLI, not from the npm package. + 6. **Never cached to disk.** `writeProviderStatusCache` already strips `updateState`; + `subscriptionUsage` is stripped the same way. A percentage rehydrated from a previous run + is worse than no percentage. The client independently ages a snapshot out after an hour, + so a tab left open overnight shows nothing rather than yesterday's allowance. + 7. **Stored as used, rendered as left.** Both providers report consumption + (Codex `usedPercent`, Claude `utilization`), so that is what crosses the wire; the UI + always says "N% left" because that is the question being asked. The bar still fills with + consumption, matching the context meter directly above it. + 8. **Labels are strings, not an enum.** The set is open — Claude's per-model bucket names come + from the server, and Codex names its windows only by `windowDurationMins` (300 → "5 hour", + 10080 → "Weekly"). `windows` is a `ForwardCompatibleArray` so an older client drops a + bucket it cannot render instead of failing the whole config decode. + 9. **No self-ticking clock.** The reset countdown re-reads `Date.now()` only when a fresh + snapshot arrives, never on a timer — a continuously repainting meter in the composer is + exactly the GPU cost this app avoids. + 10. **Cursor, Grok and OpenCode report nothing**, so they show nothing. This matches the + existing usage page, which is already scoped to `UsageProviderKind = ["claude", "codex"]`. + + **Mobile carries the data but not the UI.** `ServerProvider` reaches mobile unchanged, so + `serverConfig.providers` already has the field; mobile has no context bubble and its own + provider sheets, so rendering it there is separate work. + +- **Drop it when:** upstream's `ServerProvider` carries a subscription/rate-limit field, or + `ProviderRuntimeIngestion` grows a case for `account.rate-limits.updated`. Check with + `grep -c subscriptionUsage packages/contracts/src/server.ts` and + `grep -n 'account.rate-limits.updated' apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` + against clean upstream. If upstream ships its own version, check decision 3 against it first — + bounding the Claude request with `Effect.timeoutOption` alone is the natural thing to write + and it hangs. +- **Browser-only:** the picker footer and the Subscription section of the context bubble. + ## 4. Superseded changes Changes the fork used to carry that upstream has since implemented. **Do not re-introduce them.** diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 2a8f6ac9f192..3840433420dc 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -89,7 +89,26 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + ' subscription_type: "max",', + " rate_limits_available: true,", + " rate_limits: {", + " five_hour: { utilization: 25, resets_at: null },", + ' model_scoped: [{ display_name: "Fable", utilization: 60, resets_at: null }],', + " },", + " },", + " },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -128,6 +147,14 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { tokenSource: "oauth", apiKeySource: undefined, apiProvider: undefined, + subscriptionUsageResponse: { + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 25, resets_at: null }, + model_scoped: [{ display_name: "Fable", utilization: 60, resets_at: null }], + }, + }, slashCommands: [ { name: "review", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 76bf58723666..97eb097b0bb0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -40,6 +40,7 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { normalizeClaudeSubscriptionUsage } from "../providerSubscriptionUsage.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment, resolveClaudeConfigDirectory } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; @@ -697,6 +698,13 @@ type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + /** + * Raw structured `/usage` response, normalized by the caller so the timestamp + * comes from the Effect clock rather than this promise. Absent when the CLI is + * too old to answer, the session has no plan limits (API key, Bedrock, + * Vertex), or the request timed out. + */ + readonly subscriptionUsageResponse?: unknown; }; function parseClaudeInitializationCommands( @@ -771,6 +779,61 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } +/** How long to wait for the structured `/usage` answer before giving up on it. */ +const CLAUDE_USAGE_REQUEST_TIMEOUT_MS = 3_000; + +type ClaudeUsageCapableQuery = { + readonly usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET?: () => Promise; +}; + +/** + * The SDK's structured `/usage` request, which is where every plan window comes + * from in one answer — the streaming `rate_limit_event` only ever names the one + * window that just moved. + * + * Called through a structural cast rather than the SDK's own type on purpose. + * The method is flagged experimental and is documented to be renamed when it + * stabilizes, and the fields it returns (`model_scoped`, notably, which carries + * the per-model weekly buckets) are added server-side ahead of the npm types. + * Reading it as `unknown` and validating in the normalizer keeps this working + * across SDK versions in both directions, and keeps a rename to a compile-time + * non-event: the method goes missing, the probe returns undefined, and the UI + * shows nothing. + */ +async function readClaudeUsageSnapshot(query: unknown, fiberSignal: AbortSignal): Promise { + const request = (query as ClaudeUsageCapableQuery | null) + ?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET; + if (typeof request !== "function") { + return undefined; + } + + // A CLI that does not implement this control request never answers it, and a + // promise that never settles cannot be interrupted out of — an enclosing + // `Effect.timeoutOption` hangs with it rather than cutting it loose. So the + // deadline has to live inside the promise. `AbortSignal.timeout` supplies it + // without a bare timer, and the fiber's own signal is folded in so an + // interrupt upstream releases this too. + const deadline = AbortSignal.any([ + fiberSignal, + AbortSignal.timeout(CLAUDE_USAGE_REQUEST_TIMEOUT_MS), + ]); + + try { + return await Promise.race([ + request.call(query), + new Promise((resolve) => { + if (deadline.aborted) { + resolve(undefined); + return; + } + deadline.addEventListener("abort", () => resolve(undefined), { once: true }); + }), + ]); + } catch { + return undefined; + } +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. @@ -796,40 +859,47 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); - return yield* Effect.tryPromise(async () => { - const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield - prompt: (async function* (): AsyncGenerator { - await waitForAbortSignal(abort.signal); - })(), - options: buildClaudeCapabilitiesProbeQueryOptions({ - executablePath, - abortController: abort, - environment: claudeEnvironment, - cwd, - }), - }); - const init = await q.initializationResult(); - const account = init.account as - | { - readonly email?: string; - readonly subscriptionType?: string; - readonly tokenSource?: string; - readonly apiKeySource?: string; - readonly apiProvider?: string; - } - | undefined; - return { - email: account?.email, - subscriptionType: account?.subscriptionType, - tokenSource: account?.tokenSource, - apiKeySource: account?.apiKeySource, - apiProvider: account?.apiProvider, - slashCommands: parseClaudeInitializationCommands(init.commands), - } satisfies ClaudeCapabilitiesProbe; + const q = claudeQuery({ + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, + abortController: abort, + environment: claudeEnvironment, + cwd, + }), }); + const init = yield* Effect.tryPromise(() => q.initializationResult()); + const account = init.account as + | { + readonly email?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + readonly apiKeySource?: string; + readonly apiProvider?: string; + } + | undefined; + // Only a claude.ai plan has windows to report. Asking anyway would cost an + // API-key, Bedrock or logged-out instance the full request deadline on + // every refresh to learn what `subscriptionType` already said. + const usageResponse = account?.subscriptionType + ? yield* Effect.tryPromise((signal) => readClaudeUsageSnapshot(q, signal)).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined; + return { + email: account?.email, + subscriptionType: account?.subscriptionType, + tokenSource: account?.tokenSource, + apiKeySource: account?.apiKeySource, + apiProvider: account?.apiProvider, + slashCommands: parseClaudeInitializationCommands(init.commands), + subscriptionUsageResponse: usageResponse, + } satisfies ClaudeCapabilitiesProbe; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -1034,6 +1104,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const subscriptionUsage = normalizeClaudeSubscriptionUsage({ + response: capabilities.subscriptionUsageResponse, + collectedAt: checkedAt, + }); const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, @@ -1047,6 +1121,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( slashCommands: dedupedSlashCommands, skills, configDirectory, + ...(subscriptionUsage ? { subscriptionUsage } : {}), probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..2459cd5f33c2 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import type { CodexSettings, + ProviderSubscriptionUsage, ServerProvider, ServerProviderState, ModelCapabilities, @@ -32,12 +33,18 @@ import { buildServerProvider, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { normalizeCodexSubscriptionUsage } from "../providerSubscriptionUsage.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; +/** The probe already holds a spawned app-server; one slow account read must not hold it open. */ +const CODEX_RATE_LIMITS_READ_TIMEOUT = Duration.seconds(3); + +const nowIsoString = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -48,6 +55,8 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; + /** Absent when the CLI predates `account/rateLimits/read`, or the read failed. */ + readonly subscriptionUsage?: ProviderSubscriptionUsage | undefined; } const REASONING_EFFORT_LABELS: Readonly> = { @@ -398,19 +407,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun version, models: appendCustomCodexModels([], input.customModels ?? []), skills: [], + subscriptionUsage: undefined, } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + // `account/rateLimits/read` is newer than the rest of the probe and is not + // worth failing a provider over: an older CLI answers with a method error and + // the instance still reports ready, just without a usage snapshot. + const rateLimitsRead = client.request("account/rateLimits/read", undefined).pipe( + Effect.timeoutOption(CODEX_RATE_LIMITS_READ_TIMEOUT), + Effect.map(Option.getOrUndefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ); + + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + rateLimitsRead, ], { concurrency: "unbounded" }, ); + const collectedAt = yield* nowIsoString; + return { account: accountResponse, version, @@ -418,6 +440,10 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun appendCustomCodexModels(models, input.customModels ?? []), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + subscriptionUsage: normalizeCodexSubscriptionUsage({ + snapshot: rateLimits, + collectedAt, + }), } satisfies CodexAppServerProviderSnapshot; }); @@ -614,6 +640,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu input: { hint: "Describe the issue (optional)" }, }, ], + ...(snapshot.subscriptionUsage ? { subscriptionUsage: snapshot.subscriptionUsage } : {}), probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index d2544ee4c1e2..cbfec895b50f 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ModelCapabilities, + ProviderSubscriptionUsage, ServerProvider, ServerProviderAuth, ServerProviderConfigDirectory, @@ -223,6 +224,7 @@ export function buildServerProvider(input: { slashCommands?: ReadonlyArray; skills?: ReadonlyArray; configDirectory?: ServerProviderConfigDirectory | undefined; + subscriptionUsage?: ProviderSubscriptionUsage | undefined; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -253,6 +255,7 @@ export function buildServerProvider(input: { slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], ...(versionAdvisory ? { versionAdvisory } : {}), + ...(input.subscriptionUsage ? { subscriptionUsage: input.subscriptionUsage } : {}), }; } diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 81bdfb22d698..29c515013244 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -151,7 +151,14 @@ export const writeProviderStatusCache = (input: { readonly filePath: string; readonly provider: ServerProvider; }) => { - const { updateState: _updateState, ...cacheableProvider } = input.provider; + // `updateState` is in-flight machinery, and `subscriptionUsage` is a live + // percentage — rehydrating either from a previous run would show the user a + // number that was true minutes or days ago as if it were current. + const { + updateState: _updateState, + subscriptionUsage: _subscriptionUsage, + ...cacheableProvider + } = input.provider; return writeFileStringAtomically({ filePath: input.filePath, contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, diff --git a/apps/server/src/provider/providerSubscriptionUsage.test.ts b/apps/server/src/provider/providerSubscriptionUsage.test.ts new file mode 100644 index 000000000000..8f5129cefe59 --- /dev/null +++ b/apps/server/src/provider/providerSubscriptionUsage.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + formatWindowDurationLabel, + normalizeClaudeSubscriptionUsage, + normalizeCodexSubscriptionUsage, +} from "./providerSubscriptionUsage.ts"; + +const COLLECTED_AT = "2026-08-25T12:00:00.000Z"; + +describe("formatWindowDurationLabel", () => { + it("names the durations the two providers actually use", () => { + expect(formatWindowDurationLabel(300)).toBe("5 hour"); + expect(formatWindowDurationLabel(10_080)).toBe("Weekly"); + expect(formatWindowDurationLabel(1_440)).toBe("Daily"); + expect(formatWindowDurationLabel(20_160)).toBe("2 week"); + expect(formatWindowDurationLabel(90)).toBe("90 min"); + }); + + it("has no label for a nonsense duration", () => { + expect(formatWindowDurationLabel(0)).toBeUndefined(); + expect(formatWindowDurationLabel(-5)).toBeUndefined(); + expect(formatWindowDurationLabel(Number.NaN)).toBeUndefined(); + expect(formatWindowDurationLabel(undefined)).toBeUndefined(); + }); +}); + +describe("normalizeCodexSubscriptionUsage", () => { + it("labels anonymous windows by their duration and converts epoch seconds", () => { + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { + planType: "pro", + primary: { usedPercent: 42, resetsAt: 1_790_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 7, resetsAt: 1_790_500_000, windowDurationMins: 10_080 }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.planLabel).toBe("pro"); + expect(usage?.collectedAt).toBe(COLLECTED_AT); + expect(usage?.windows).toEqual([ + { id: "primary", label: "5 hour", usedPercent: 42, resetsAt: "2026-09-21T14:13:20.000Z" }, + { id: "secondary", label: "Weekly", usedPercent: 7, resetsAt: "2026-09-27T09:06:40.000Z" }, + ]); + }); + + it("falls back to positional labels when the duration is missing", () => { + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { primary: { usedPercent: 10 } }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows).toEqual([{ id: "primary", label: "Primary", usedPercent: 10 }]); + }); + + it("treats Codex's own 'unknown' plan placeholder as no plan", () => { + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { planType: "unknown", primary: { usedPercent: 10, windowDurationMins: 300 } }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.planLabel).toBeUndefined(); + }); + + it("keeps the percentage when the reset time is unusable", () => { + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { + primary: { usedPercent: 55, resetsAt: "not-a-date", windowDurationMins: 300 }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows).toEqual([{ id: "primary", label: "5 hour", usedPercent: 55 }]); + }); + + it("clamps out-of-range percentages rather than rendering them", () => { + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { + primary: { usedPercent: 140, windowDurationMins: 300 }, + secondary: { usedPercent: -3, windowDurationMins: 10_080 }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows.map((window) => window.usedPercent)).toEqual([100, 0]); + }); + + it("reports nothing when no window carries a usable percentage", () => { + expect( + normalizeCodexSubscriptionUsage({ snapshot: undefined, collectedAt: COLLECTED_AT }), + ).toBeUndefined(); + expect( + normalizeCodexSubscriptionUsage({ snapshot: {}, collectedAt: COLLECTED_AT }), + ).toBeUndefined(); + expect( + normalizeCodexSubscriptionUsage({ + snapshot: { primary: { usedPercent: "42" } }, + collectedAt: COLLECTED_AT, + }), + ).toBeUndefined(); + }); +}); + +describe("normalizeClaudeSubscriptionUsage", () => { + it("reads every fixed window plus the server-labelled per-model buckets", () => { + const usage = normalizeClaudeSubscriptionUsage({ + response: { + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 31, resets_at: "2026-08-25T17:00:00.000Z" }, + seven_day: { utilization: 64, resets_at: "2026-08-29T00:00:00.000Z" }, + seven_day_opus: { utilization: 12, resets_at: null }, + model_scoped: [{ display_name: "Fable", utilization: 8, resets_at: null }], + }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.planLabel).toBe("max"); + expect(usage?.windows).toEqual([ + { id: "five_hour", label: "5 hour", usedPercent: 31, resetsAt: "2026-08-25T17:00:00.000Z" }, + { id: "seven_day", label: "Weekly", usedPercent: 64, resetsAt: "2026-08-29T00:00:00.000Z" }, + { id: "seven_day_opus", label: "Weekly (Opus)", usedPercent: 12 }, + { id: "model_scoped:0:Fable", label: "Weekly (Fable)", usedPercent: 8 }, + ]); + }); + + it("omits third-party OAuth app usage, which is not the user's own spend", () => { + const usage = normalizeClaudeSubscriptionUsage({ + response: { + rate_limits: { + five_hour: { utilization: 5 }, + seven_day_oauth_apps: { utilization: 99 }, + }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows.map((window) => window.id)).toEqual(["five_hour"]); + }); + + it("reports overage only while it is actually enabled", () => { + const enabled = normalizeClaudeSubscriptionUsage({ + response: { + rate_limits: { + five_hour: { utilization: 5 }, + extra_usage: { is_enabled: true, utilization: 20 }, + }, + }, + collectedAt: COLLECTED_AT, + }); + expect(enabled?.windows.at(-1)).toEqual({ + id: "extra_usage", + label: "Extra usage", + usedPercent: 20, + }); + + const disabled = normalizeClaudeSubscriptionUsage({ + response: { + rate_limits: { + five_hour: { utilization: 5 }, + extra_usage: { is_enabled: false, utilization: 20 }, + }, + }, + collectedAt: COLLECTED_AT, + }); + expect(disabled?.windows.map((window) => window.id)).toEqual(["five_hour"]); + }); + + it("reports nothing for a session that has no plan limits", () => { + expect( + normalizeClaudeSubscriptionUsage({ + response: { rate_limits_available: false, rate_limits: null }, + collectedAt: COLLECTED_AT, + }), + ).toBeUndefined(); + }); + + it("survives an SDK that predates the per-model buckets", () => { + const usage = normalizeClaudeSubscriptionUsage({ + response: { rate_limits: { five_hour: { utilization: 3 } } }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows).toEqual([{ id: "five_hour", label: "5 hour", usedPercent: 3 }]); + }); + + it("skips per-model buckets that carry no usable label", () => { + const usage = normalizeClaudeSubscriptionUsage({ + response: { + rate_limits: { + model_scoped: [ + { display_name: " ", utilization: 8 }, + { utilization: 9 }, + { display_name: "Fable", utilization: 10 }, + ], + }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.windows).toEqual([ + { id: "model_scoped:2:Fable", label: "Weekly (Fable)", usedPercent: 10 }, + ]); + }); + + it("reports nothing when the response is not a usage payload at all", () => { + expect( + normalizeClaudeSubscriptionUsage({ response: undefined, collectedAt: COLLECTED_AT }), + ).toBeUndefined(); + expect( + normalizeClaudeSubscriptionUsage({ response: "nope", collectedAt: COLLECTED_AT }), + ).toBeUndefined(); + expect( + normalizeClaudeSubscriptionUsage({ response: {}, collectedAt: COLLECTED_AT }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerSubscriptionUsage.ts b/apps/server/src/provider/providerSubscriptionUsage.ts new file mode 100644 index 000000000000..b19827bf57fe --- /dev/null +++ b/apps/server/src/provider/providerSubscriptionUsage.ts @@ -0,0 +1,279 @@ +/** + * Normalizes each provider's native rate-limit snapshot into the shared + * `ProviderSubscriptionUsage` contract. + * + * Providers describe the same idea in different shapes. Codex reports two + * anonymous windows (`primary`/`secondary`) identified only by duration in + * minutes; Claude reports named windows (`five_hour`, `seven_day`, per-model + * weekly buckets) whose labels partly come from the server. Both express + * consumption as "percent of the window used", so that is the one number the + * contract carries and every client renders. + * + * Everything here is pure and defensive: these inputs cross a CLI boundary and + * one is an explicitly experimental API, so each field is validated rather than + * trusted, and a shape we do not recognise yields no window instead of a wrong + * one. + * + * @module providerSubscriptionUsage + */ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import type { + ProviderSubscriptionUsage, + ProviderSubscriptionUsageWindow, +} from "@t3tools/contracts"; + +/** + * Epoch values above this are already milliseconds. `1e11` seconds is the year + * 5138; `1e11` milliseconds is 1973, so no real reset time is ambiguous. + */ +const SECONDS_EPOCH_CEILING = 1e11; + +/** Percentages outside 0-100 are provider bugs; clamp rather than render them. */ +function normalizePercent(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(0, Math.min(100, value)); +} + +function normalizeLabel(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Codex reports `resetsAt` as epoch seconds, Claude as an ISO string. Both + * arrive here as `unknown` and leave as ISO, or absent when unusable — a + * malformed reset time must not sink the window's percentage. + */ +function normalizeResetsAt(value: unknown): string | undefined { + if (typeof value === "string") { + return Option.match(DateTime.make(value), { + onNone: () => undefined, + onSome: DateTime.formatIso, + }); + } + if (typeof value === "number" && Number.isFinite(value)) { + // Codex sends seconds. Anything already in milliseconds would land tens of + // thousands of years out, so scale by magnitude rather than trusting each + // provider to keep its unit stable. + const millis = value > SECONDS_EPOCH_CEILING ? value : value * 1000; + return Option.match(DateTime.make(millis), { + onNone: () => undefined, + onSome: DateTime.formatIso, + }); + } + return undefined; +} + +/** + * Turns a window duration into the label a user recognises. Codex only tells us + * the length, so "5 hour" and "Weekly" are derived rather than reported. + */ +export function formatWindowDurationLabel(minutes: number | undefined): string | undefined { + if (minutes === undefined || !Number.isFinite(minutes) || minutes <= 0) { + return undefined; + } + if (minutes % (60 * 24 * 7) === 0) { + const weeks = minutes / (60 * 24 * 7); + return weeks === 1 ? "Weekly" : `${weeks} week`; + } + if (minutes % (60 * 24) === 0) { + const days = minutes / (60 * 24); + return days === 1 ? "Daily" : `${days} day`; + } + if (minutes % 60 === 0) { + return `${minutes / 60} hour`; + } + return `${minutes} min`; +} + +function makeWindow(input: { + readonly id: string; + readonly label: string; + readonly usedPercent: number | undefined; + readonly resetsAt: unknown; +}): ProviderSubscriptionUsageWindow | undefined { + if (input.usedPercent === undefined) { + return undefined; + } + const resetsAt = normalizeResetsAt(input.resetsAt); + return { + id: input.id, + label: input.label, + usedPercent: input.usedPercent, + ...(resetsAt ? { resetsAt } : {}), + }; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Codex `account/rateLimits/read` (and the identically-shaped + * `account/rateLimits/updated` notification) into the shared contract. + * + * `planType` is surfaced as the plan label; `credits` is deliberately dropped + * here because a credit balance is not a window and has no percentage to draw. + */ +export function normalizeCodexSubscriptionUsage(input: { + readonly snapshot: unknown; + readonly collectedAt: string; +}): ProviderSubscriptionUsage | undefined { + const snapshot = asRecord(input.snapshot); + if (!snapshot) { + return undefined; + } + + const windows: Array = []; + for (const [key, fallbackLabel] of [ + ["primary", "Primary"], + ["secondary", "Secondary"], + ] as const) { + const raw = asRecord(snapshot[key]); + if (!raw) { + continue; + } + const durationMinutes = + typeof raw.windowDurationMins === "number" ? raw.windowDurationMins : undefined; + const window = makeWindow({ + id: key, + label: formatWindowDurationLabel(durationMinutes) ?? fallbackLabel, + usedPercent: normalizePercent(raw.usedPercent), + resetsAt: raw.resetsAt, + }); + if (window) { + windows.push(window); + } + } + + if (windows.length === 0) { + return undefined; + } + + // "unknown" is Codex's own placeholder, not a plan worth showing. + const planType = normalizeLabel(snapshot.planType); + const planLabel = planType && planType !== "unknown" ? planType : undefined; + + return { + ...(planLabel ? { planLabel } : {}), + windows, + collectedAt: input.collectedAt, + }; +} + +/** + * Fixed Claude windows, in the order they should read. `seven_day_oauth_apps` + * is omitted: it measures third-party OAuth app usage, which is not what a user + * driving Claude through T3 Code is spending. + */ +const CLAUDE_WINDOW_LABELS: ReadonlyArray = [ + ["five_hour", "5 hour"], + ["seven_day", "Weekly"], + ["seven_day_opus", "Weekly (Opus)"], + ["seven_day_sonnet", "Weekly (Sonnet)"], +]; + +/** + * Claude's experimental structured `/usage` response into the shared contract. + * + * `model_scoped` is additive on the Anthropic side — it carries per-model weekly + * buckets with server-supplied labels (Fable, for instance) and is simply absent + * on SDK versions that predate it, which is why it is read positionally rather + * than by a known key set. + */ +export function normalizeClaudeSubscriptionUsage(input: { + readonly response: unknown; + readonly collectedAt: string; +}): ProviderSubscriptionUsage | undefined { + const response = asRecord(input.response); + if (!response) { + return undefined; + } + // `rate_limits_available` is false for API-key, Bedrock and Vertex sessions. + // Those have no subscription to report and must show nothing at all. + if (response.rate_limits_available === false) { + return undefined; + } + const rateLimits = asRecord(response.rate_limits); + if (!rateLimits) { + return undefined; + } + + const windows: Array = []; + for (const [key, label] of CLAUDE_WINDOW_LABELS) { + const raw = asRecord(rateLimits[key]); + if (!raw) { + continue; + } + const window = makeWindow({ + id: key, + label, + usedPercent: normalizePercent(raw.utilization), + resetsAt: raw.resets_at, + }); + if (window) { + windows.push(window); + } + } + + const modelScoped = rateLimits.model_scoped; + if (Array.isArray(modelScoped)) { + for (const [index, entry] of modelScoped.entries()) { + const raw = asRecord(entry); + if (!raw) { + continue; + } + const displayName = normalizeLabel(raw.display_name); + if (!displayName) { + continue; + } + const window = makeWindow({ + // Labels are server-supplied and could collide; the index keeps the id + // unique without inventing a name the provider did not give us. + id: `model_scoped:${index}:${displayName}`, + label: `Weekly (${displayName})`, + usedPercent: normalizePercent(raw.utilization), + resetsAt: raw.resets_at, + }); + if (window) { + windows.push(window); + } + } + } + + // Overage spend is a real limit users hit, and the only one expressed as a + // budget rather than a window. It is reported only while actually enabled. + const extraUsage = asRecord(rateLimits.extra_usage); + if (extraUsage?.is_enabled === true) { + const window = makeWindow({ + id: "extra_usage", + label: "Extra usage", + usedPercent: normalizePercent(extraUsage.utilization), + resetsAt: undefined, + }); + if (window) { + windows.push(window); + } + } + + if (windows.length === 0) { + return undefined; + } + + const planLabel = normalizeLabel(response.subscription_type); + + return { + ...(planLabel ? { planLabel } : {}), + windows, + collectedAt: input.collectedAt, + }; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c94b91eff339..4cda97d19616 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,6 +5,7 @@ import type { PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, + ProviderSubscriptionUsage, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, @@ -123,6 +124,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; +import { usableSubscriptionUsage } from "./SubscriptionUsage.logic"; import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; @@ -446,6 +448,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; + activeSubscriptionUsage: ProviderSubscriptionUsage | undefined; + activeSubscriptionUsageNowMs: number; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -475,6 +479,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( @@ -1019,6 +1025,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + // Re-read the clock only when a new snapshot lands. The reset countdown is + // therefore accurate as of the last provider refresh and never repaints on + // its own — a self-ticking meter in the composer is a GPU cost for no gain. + const activeSubscriptionUsage = useMemo(() => { + const snapshot = selectedProviderEntry?.snapshot.subscriptionUsage; + return { + usage: usableSubscriptionUsage(snapshot, Date.now()), + nowMs: Date.now(), + }; + }, [selectedProviderEntry?.snapshot.subscriptionUsage]); // ------------------------------------------------------------------ // Composer-local state @@ -3575,6 +3591,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact={isComposerPrimaryActionsCompact} activeContextWindow={activeContextWindow} activeThreadModelDisplayName={activeThreadModelDisplayName} + activeSubscriptionUsage={activeSubscriptionUsage.usage} + activeSubscriptionUsageNowMs={activeSubscriptionUsage.nowMs} pendingAction={pendingPrimaryAction} isRunning={phase === "running"} showPlanFollowUpPrompt={ diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 6943684b1f58..ed56a1020ae0 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,7 +1,10 @@ +import type { ProviderSubscriptionUsage } from "@t3tools/contracts"; + import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; +import { SubscriptionUsageMeters } from "./SubscriptionUsageMeters"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -16,8 +19,15 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; modelDisplayName?: string | null; + /** + * Subscription allowance for the instance this thread is running on, already + * aged out by the caller. Absent for providers with no plan limits to report. + */ + subscriptionUsage?: ProviderSubscriptionUsage | undefined; + /** Clock for the reset countdowns, read once by the caller per popover open. */ + subscriptionUsageNowMs?: number; }) { - const { usage, modelDisplayName } = props; + const { usage, modelDisplayName, subscriptionUsage } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -131,6 +141,22 @@ export function ContextWindowMeter(props: { {formatContextWindowCompactionMessage(modelDisplayName)} ) : null} + {subscriptionUsage ? ( +
+
+
Subscription
+ {subscriptionUsage.planLabel ? ( +
+ {subscriptionUsage.planLabel} +
+ ) : null} +
+ +
+ ) : null} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8729b1bf8f00..6c8f8dd67141 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -8,6 +8,8 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; +import { SubscriptionUsageMeters } from "./SubscriptionUsageMeters"; +import { usableSubscriptionUsage } from "./SubscriptionUsage.logic"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { modelPickerLegacySectionKey, @@ -596,6 +598,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [filteredItemKeys, updateModelListScrollFades]); + // Keyed on the snapshot so the countdown re-reads the clock only when a fresh + // one arrives, never on every open or keystroke in the search box. + const selectedInstanceSubscriptionUsage = useMemo(() => { + const snapshot = + selectedInstanceId === "favorites" + ? undefined + : props.instanceEntries.find((entry) => entry.instanceId === selectedInstanceId)?.snapshot + .subscriptionUsage; + return { usage: usableSubscriptionUsage(snapshot, Date.now()), nowMs: Date.now() }; + }, [props.instanceEntries, selectedInstanceId]); + return (
No models found + {/* + Subscription allowance for the instance the rail has selected, so + the answer to "which provider do I still have room on" is visible + at the moment of choosing. Absent for API-key sessions and for + providers that report no plan limits. + */} + {selectedInstanceSubscriptionUsage.usage ? ( +
+
+
Subscription
+ {selectedInstanceSubscriptionUsage.usage.planLabel ? ( +
+ {selectedInstanceSubscriptionUsage.usage.planLabel} +
+ ) : null} +
+ +
+ ) : null}
diff --git a/apps/web/src/components/chat/SubscriptionUsage.logic.test.ts b/apps/web/src/components/chat/SubscriptionUsage.logic.test.ts new file mode 100644 index 000000000000..5324294f9f6f --- /dev/null +++ b/apps/web/src/components/chat/SubscriptionUsage.logic.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProviderSubscriptionUsage } from "@t3tools/contracts"; + +import { + formatRemainingPercent, + formatResetCountdown, + SUBSCRIPTION_USAGE_STALE_AFTER_MS, + tightestWindow, + usableSubscriptionUsage, +} from "./SubscriptionUsage.logic"; + +const NOW_MS = Date.parse("2026-08-25T12:00:00.000Z"); + +function makeUsage(overrides?: Partial): ProviderSubscriptionUsage { + return { + windows: [{ id: "five_hour", label: "5 hour", usedPercent: 40 }], + collectedAt: "2026-08-25T11:59:00.000Z", + ...overrides, + }; +} + +describe("formatRemainingPercent", () => { + it("reports what is left, not what is spent", () => { + expect(formatRemainingPercent({ id: "a", label: "a", usedPercent: 40 })).toBe("60% left"); + expect(formatRemainingPercent({ id: "a", label: "a", usedPercent: 0 })).toBe("100% left"); + expect(formatRemainingPercent({ id: "a", label: "a", usedPercent: 100 })).toBe("0% left"); + }); + + it("keeps a decimal only in the last percent, where it changes the decision", () => { + expect(formatRemainingPercent({ id: "a", label: "a", usedPercent: 99.7 })).toBe("0.3% left"); + expect(formatRemainingPercent({ id: "a", label: "a", usedPercent: 55.4 })).toBe("45% left"); + }); +}); + +describe("tightestWindow", () => { + it("picks the window the user will hit first", () => { + const usage = makeUsage({ + windows: [ + { id: "five_hour", label: "5 hour", usedPercent: 20 }, + { id: "seven_day", label: "Weekly", usedPercent: 88 }, + { id: "model", label: "Weekly (Fable)", usedPercent: 55 }, + ], + }); + + expect(tightestWindow(usage)?.id).toBe("seven_day"); + }); + + it("has no answer without windows", () => { + expect(tightestWindow(undefined)).toBeUndefined(); + expect(tightestWindow(makeUsage({ windows: [] }))).toBeUndefined(); + }); +}); + +describe("formatResetCountdown", () => { + it("scales the unit to the distance", () => { + expect(formatResetCountdown("2026-08-25T12:45:00.000Z", NOW_MS)).toBe("resets in 45m"); + expect(formatResetCountdown("2026-08-25T15:00:00.000Z", NOW_MS)).toBe("resets in 3h"); + expect(formatResetCountdown("2026-08-25T15:20:00.000Z", NOW_MS)).toBe("resets in 3h 20m"); + expect(formatResetCountdown("2026-08-28T12:00:00.000Z", NOW_MS)).toBe("resets in 3d"); + expect(formatResetCountdown("2026-08-28T18:00:00.000Z", NOW_MS)).toBe("resets in 3d 6h"); + }); + + it("says nothing rather than counting backwards past a rollover", () => { + expect(formatResetCountdown("2026-08-25T11:00:00.000Z", NOW_MS)).toBeUndefined(); + expect(formatResetCountdown("2026-08-25T12:00:00.000Z", NOW_MS)).toBeUndefined(); + }); + + it("says nothing for a missing or unparseable reset time", () => { + expect(formatResetCountdown(undefined, NOW_MS)).toBeUndefined(); + expect(formatResetCountdown("later", NOW_MS)).toBeUndefined(); + }); +}); + +describe("usableSubscriptionUsage", () => { + it("passes a fresh snapshot through", () => { + const usage = makeUsage(); + expect(usableSubscriptionUsage(usage, NOW_MS)).toBe(usage); + }); + + it("drops a snapshot old enough to be misleading", () => { + const stale = makeUsage({ + collectedAt: new Date(NOW_MS - SUBSCRIPTION_USAGE_STALE_AFTER_MS - 1).toISOString(), + }); + expect(usableSubscriptionUsage(stale, NOW_MS)).toBeUndefined(); + }); + + it("keeps a snapshot from a clock skewed into the future", () => { + const skewed = makeUsage({ collectedAt: new Date(NOW_MS + 60_000).toISOString() }); + expect(usableSubscriptionUsage(skewed, NOW_MS)).toBe(skewed); + }); + + it("drops empty and malformed snapshots", () => { + expect(usableSubscriptionUsage(undefined, NOW_MS)).toBeUndefined(); + expect(usableSubscriptionUsage(makeUsage({ windows: [] }), NOW_MS)).toBeUndefined(); + expect(usableSubscriptionUsage(makeUsage({ collectedAt: "nope" }), NOW_MS)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/SubscriptionUsage.logic.ts b/apps/web/src/components/chat/SubscriptionUsage.logic.ts new file mode 100644 index 000000000000..bc1bccbbcdd4 --- /dev/null +++ b/apps/web/src/components/chat/SubscriptionUsage.logic.ts @@ -0,0 +1,120 @@ +/** + * Presentation rules for provider subscription usage. + * + * The wire carries "percent of the window used" because that is what both + * providers report. Users ask the opposite question — how much is left — so + * every number rendered here is remaining, and the word "left" is always + * attached to it. The bar still fills with consumption, matching the context + * window meter directly above it in the composer. + * + * @module SubscriptionUsage.logic + */ +import type { + ProviderSubscriptionUsage, + ProviderSubscriptionUsageWindow, +} from "@t3tools/contracts"; + +/** A snapshot older than this is not worth showing as a live number. */ +export const SUBSCRIPTION_USAGE_STALE_AFTER_MS = 60 * 60 * 1000; + +export function remainingPercent(window: ProviderSubscriptionUsageWindow): number { + return Math.max(0, Math.min(100, 100 - window.usedPercent)); +} + +/** Whole numbers except near exhaustion, where the last percent is the useful one. */ +export function formatRemainingPercent(window: ProviderSubscriptionUsageWindow): string { + const remaining = remainingPercent(window); + if (remaining > 0 && remaining < 1) { + return `${remaining.toFixed(1).replace(/\.0$/, "")}% left`; + } + return `${Math.round(remaining)}% left`; +} + +/** + * The window a user is most likely to hit next — the most consumed one. This is + * what the picker trigger summarises when there is only room for one number. + */ +export function tightestWindow( + usage: ProviderSubscriptionUsage | undefined, +): ProviderSubscriptionUsageWindow | undefined { + if (!usage || usage.windows.length === 0) { + return undefined; + } + return usage.windows.reduce((tightest, window) => + window.usedPercent > tightest.usedPercent ? window : tightest, + ); +} + +/** + * Formats the reset time as a short countdown. Returns undefined once the + * window has rolled over, because a negative countdown is worse than silence — + * the next probe will carry the fresh window. + */ +export function formatResetCountdown( + resetsAt: string | undefined, + nowMs: number, +): string | undefined { + if (!resetsAt) { + return undefined; + } + const resetMs = Date.parse(resetsAt); + if (Number.isNaN(resetMs)) { + return undefined; + } + const remainingMs = resetMs - nowMs; + if (remainingMs <= 0) { + return undefined; + } + + const totalMinutes = Math.ceil(remainingMs / 60_000); + if (totalMinutes < 60) { + return `resets in ${totalMinutes}m`; + } + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) { + const minutes = totalMinutes % 60; + return minutes === 0 ? `resets in ${totalHours}h` : `resets in ${totalHours}h ${minutes}m`; + } + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return hours === 0 ? `resets in ${days}d` : `resets in ${days}d ${hours}h`; +} + +/** + * Usage worth rendering, or undefined. + * + * A snapshot is dropped once it ages out: the server only refreshes it when a + * provider status refresh runs, so a tab left open overnight would otherwise + * keep showing yesterday's allowance as though it were current. + */ +export function usableSubscriptionUsage( + usage: ProviderSubscriptionUsage | undefined, + nowMs: number, +): ProviderSubscriptionUsage | undefined { + if (!usage || usage.windows.length === 0) { + return undefined; + } + const collectedMs = Date.parse(usage.collectedAt); + if (Number.isNaN(collectedMs)) { + return undefined; + } + // A clock skewed into the future still describes a snapshot we just took. + if (nowMs - collectedMs > SUBSCRIPTION_USAGE_STALE_AFTER_MS) { + return undefined; + } + return usage; +} + +/** + * Bar colour thresholds, matching the context window meter's convention so the + * two meters in the same popover read the same way. + */ +export function usageBarColor(window: ProviderSubscriptionUsageWindow): string { + if (window.usedPercent >= 90) { + return "var(--color-error)"; + } + if (window.usedPercent >= 75) { + return "var(--color-warning)"; + } + return "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; +} diff --git a/apps/web/src/components/chat/SubscriptionUsageMeters.tsx b/apps/web/src/components/chat/SubscriptionUsageMeters.tsx new file mode 100644 index 000000000000..293e9815ba96 --- /dev/null +++ b/apps/web/src/components/chat/SubscriptionUsageMeters.tsx @@ -0,0 +1,65 @@ +import type { ProviderSubscriptionUsage } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import { + formatRemainingPercent, + formatResetCountdown, + remainingPercent, + usageBarColor, +} from "./SubscriptionUsage.logic"; + +/** + * One row per subscription window: what it is, how much is left, and when it + * rolls over. Shared by the composer's context bubble and the model picker so + * the two never drift apart. + * + * `nowMs` is passed in rather than read here so the component stays pure and + * the countdown does not re-render on its own — these numbers move on the + * order of minutes, and a self-ticking meter in the composer is exactly the + * kind of continuous repaint this app avoids. + */ +export function SubscriptionUsageMeters(props: { + usage: ProviderSubscriptionUsage; + nowMs: number; + className?: string; +}) { + const { usage, nowMs } = props; + + return ( +
+ {usage.windows.map((window) => { + const remaining = remainingPercent(window); + const countdown = formatResetCountdown(window.resetsAt, nowMs); + return ( +
+
+ {window.label} + + {formatRemainingPercent(window)} + +
+
+
+
+ {countdown ? ( +
{countdown}
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/docs/user/composer.md b/docs/user/composer.md index b7ef57a56015..61bacfe8b506 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -22,3 +22,20 @@ On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from start it in the background. T3 Code opens another new thread and shows an **Open** action for the thread that started. The new thread keeps the selected workspace mode and base branch. If **New worktree** is selected, each background thread creates its own worktree. + +## Subscription usage + +When a provider runs on a subscription rather than an API key, T3 Code shows how much of each +plan window is still available. Claude reports a 5-hour window, a weekly window, and separate +weekly windows for individual models; Codex reports its own two windows. Each row shows the +share left and, where the provider reports one, how long until that window resets. + +The same rows appear in two places: at the bottom of the model picker, for whichever provider is +selected in the rail, so you can see which account still has room before you pick one; and under +**Subscription** in the popover of the round meter beside the send button, for the provider the +current thread is running on. + +Providers that do not report plan limits show nothing. That includes sessions authenticated with +an API key, Amazon Bedrock, and Google Vertex, none of which have subscription windows to +report. Usage refreshes when T3 Code checks provider status, and disappears rather than going +stale if a figure gets too old to trust. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5926c3ac25bf..701c15840b27 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -177,6 +177,54 @@ export const ServerProviderUpdateState = Schema.Struct({ }); export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; +/** + * One subscription rate-limit window reported by a provider. + * + * Normalized across providers so the UI never branches on driver kind: both + * Codex's `usedPercent` and Claude's `utilization` are "percent of the window + * consumed", so `usedPercent` is the single number every client renders. The + * window's identity is carried as a human label rather than an enum because + * the set is open — Claude adds per-model weekly buckets whose names come from + * the server (`model_scoped[].display_name`), and Codex only describes its + * windows by duration. + */ +export const ProviderSubscriptionUsageWindow = Schema.Struct({ + /** + * Stable key for this window within a provider, used for React keys and to + * dedupe successive snapshots. Derived, not displayed. + */ + id: TrimmedNonEmptyString, + /** Display label, e.g. "5 hour", "Weekly", "Weekly (Fable)". */ + label: TrimmedNonEmptyString, + /** Percent of the window consumed, 0-100. */ + usedPercent: Schema.Number, + /** When the window rolls over, when the provider reports it. */ + resetsAt: Schema.optionalKey(IsoDateTime), +}); +export type ProviderSubscriptionUsageWindow = typeof ProviderSubscriptionUsageWindow.Type; + +/** + * Subscription usage for one provider instance. + * + * Absent when the provider has no subscription limits to report (API-key, + * Bedrock and Vertex sessions), when the CLI is too old to answer, or when the + * probe that collects it timed out. Consumers must treat absence as "unknown" + * and render nothing rather than implying a full allowance. + * + * Deliberately excluded from the on-disk provider status cache: a percentage + * rehydrated from a previous run is worse than no percentage at all. + */ +export const ProviderSubscriptionUsage = Schema.Struct({ + /** Plan name the provider reports, when it exposes one (e.g. "max", "pro"). */ + planLabel: Schema.optionalKey(TrimmedNonEmptyString), + // Window kinds grow over time; an older client must not fail the whole + // config decode over a bucket it does not know how to render. + windows: ForwardCompatibleArray(ProviderSubscriptionUsageWindow), + /** When this snapshot was collected, so clients can age it out. */ + collectedAt: IsoDateTime, +}); +export type ProviderSubscriptionUsage = typeof ProviderSubscriptionUsage.Type; + export const ServerProvider = Schema.Struct({ // Routing key for the configured instance this snapshot represents. This // is the only stable identity consumers may use for provider routing. @@ -216,6 +264,10 @@ export const ServerProvider = Schema.Struct({ skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), + // Live subscription rate-limit snapshot. Optional and never cached to + // disk (see providerStatusCache) so a restart shows nothing rather than + // a stale allowance. + subscriptionUsage: Schema.optionalKey(ProviderSubscriptionUsage), }); export type ServerProvider = typeof ServerProvider.Type; From 6ef5d57a1723b466b328f229c1d5bf9046e9e99e Mon Sep 17 00:00:00 2001 From: John McLaren Date: Tue, 25 Aug 2026 21:47:18 +0000 Subject: [PATCH 2/2] fix(providers): unwrap the Codex rate-limit envelope and age usage on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review, all real. Codex subscription usage never appeared. `account/rateLimits/read` returns the windows nested under a `rateLimits` key, and the normalizer read `primary`/`secondary`/`planType` straight off the value it was handed, so every window came back empty and the whole snapshot resolved to undefined. The unit tests passed because they fed the normalizer an already-unwrapped snapshot, exercising it correctly but never the wiring. It now unwraps `rateLimits` when present and still accepts a bare snapshot, which also covers the `account/rateLimits/updated` notification — that carries the same envelope. The one-hour age-out never fired. Staleness was decided in a composer memo keyed on the snapshot, so the timestamp was captured once and recomputed only when a new snapshot arrived — which is exactly what stops happening when provider refreshes stop. An expired allowance could sit in the popover indefinitely. The meter now ages the raw snapshot itself and reads the clock when its popover opens; the picker reads it once per open, its popup being unmounted while closed. Still no timer, so nothing repaints on its own. The deadline path lost its coverage. Teaching the probe fixture to answer `get_usage` proved the happy path but left the timeout untested — removing it would have kept the suite green. Adds a case whose CLI reports a subscription and then ignores `get_usage`, asserting the probe still resolves with auth intact and no usage snapshot. Verified both new tests fail against the un-fixed code: the envelope case yields zero windows, and the deadline case hangs to the test timeout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AEwwDVNes37aYjwtUgNhEi --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 60 +++++++++++++++++++ .../providerSubscriptionUsage.test.ts | 22 +++++++ .../src/provider/providerSubscriptionUsage.ts | 9 ++- apps/web/src/components/chat/ChatComposer.tsx | 21 ++----- .../components/chat/ContextWindowMeter.tsx | 25 ++++---- .../components/chat/ModelPickerContent.tsx | 19 +++--- 6 files changed, 120 insertions(+), 36 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 3840433420dc..381626f6927d 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -188,4 +188,64 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { assert.equal(flagSettings.disableAllHooks, true); }).pipe(Effect.scoped), ); + + it.effect( + "completes without a usage snapshot when a subscribed CLI ignores get_usage", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-no-usage-" }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const workspaceCwd = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + + // An older CLI: it reports a subscription, so the probe does ask for + // usage, but it never answers that request. Without the deadline inside + // `readClaudeUsageSnapshot` the promise never settles and the probe + // hangs — `Effect.timeoutOption` cannot interrupt it. + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { createInterface } from "node:readline";', + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [],", + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { ...process.env }, + workspaceCwd, + ); + + // The probe still resolves, with auth intact and no usage. + assert.equal(capabilities?.subscriptionType, "pro"); + assert.equal(capabilities?.subscriptionUsageResponse, undefined); + }).pipe(Effect.scoped), + { timeout: 30_000 }, + ); }); diff --git a/apps/server/src/provider/providerSubscriptionUsage.test.ts b/apps/server/src/provider/providerSubscriptionUsage.test.ts index 8f5129cefe59..9ba6068723b7 100644 --- a/apps/server/src/provider/providerSubscriptionUsage.test.ts +++ b/apps/server/src/provider/providerSubscriptionUsage.test.ts @@ -44,6 +44,28 @@ describe("normalizeCodexSubscriptionUsage", () => { ]); }); + it("unwraps the `rateLimits` envelope the read response and notification both use", () => { + // `V2GetAccountRateLimitsResponse` nests the windows one level down. Handing + // the whole response over used to yield zero windows, so Codex usage never + // appeared at all. + const usage = normalizeCodexSubscriptionUsage({ + snapshot: { + rateLimits: { + planType: "plus", + primary: { usedPercent: 12, windowDurationMins: 300 }, + secondary: { usedPercent: 34, windowDurationMins: 10_080 }, + }, + }, + collectedAt: COLLECTED_AT, + }); + + expect(usage?.planLabel).toBe("plus"); + expect(usage?.windows).toEqual([ + { id: "primary", label: "5 hour", usedPercent: 12 }, + { id: "secondary", label: "Weekly", usedPercent: 34 }, + ]); + }); + it("falls back to positional labels when the duration is missing", () => { const usage = normalizeCodexSubscriptionUsage({ snapshot: { primary: { usedPercent: 10 } }, diff --git a/apps/server/src/provider/providerSubscriptionUsage.ts b/apps/server/src/provider/providerSubscriptionUsage.ts index b19827bf57fe..1f927587b2dc 100644 --- a/apps/server/src/provider/providerSubscriptionUsage.ts +++ b/apps/server/src/provider/providerSubscriptionUsage.ts @@ -121,6 +121,10 @@ function asRecord(value: unknown): Record | undefined { * Codex `account/rateLimits/read` (and the identically-shaped * `account/rateLimits/updated` notification) into the shared contract. * + * Both carry the windows one level down, under `rateLimits`, so the whole + * response can be handed here directly; a bare snapshot is still accepted so a + * caller that has already unwrapped is not forced to re-wrap. + * * `planType` is surfaced as the plan label; `credits` is deliberately dropped * here because a credit balance is not a window and has no percentage to draw. */ @@ -128,10 +132,11 @@ export function normalizeCodexSubscriptionUsage(input: { readonly snapshot: unknown; readonly collectedAt: string; }): ProviderSubscriptionUsage | undefined { - const snapshot = asRecord(input.snapshot); - if (!snapshot) { + const payload = asRecord(input.snapshot); + if (!payload) { return undefined; } + const snapshot = asRecord(payload.rateLimits) ?? payload; const windows: Array = []; for (const [key, fallbackLabel] of [ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4cda97d19616..51317e59da59 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -124,7 +124,6 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { usableSubscriptionUsage } from "./SubscriptionUsage.logic"; import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; @@ -449,7 +448,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact: boolean; activeContextWindow: ReturnType; activeSubscriptionUsage: ProviderSubscriptionUsage | undefined; - activeSubscriptionUsageNowMs: number; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -480,7 +478,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( usage={props.activeContextWindow} modelDisplayName={props.activeThreadModelDisplayName} subscriptionUsage={props.activeSubscriptionUsage} - subscriptionUsageNowMs={props.activeSubscriptionUsageNowMs} /> ) : null} {props.isPreparingWorktree ? ( @@ -1025,16 +1022,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); - // Re-read the clock only when a new snapshot lands. The reset countdown is - // therefore accurate as of the last provider refresh and never repaints on - // its own — a self-ticking meter in the composer is a GPU cost for no gain. - const activeSubscriptionUsage = useMemo(() => { - const snapshot = selectedProviderEntry?.snapshot.subscriptionUsage; - return { - usage: usableSubscriptionUsage(snapshot, Date.now()), - nowMs: Date.now(), - }; - }, [selectedProviderEntry?.snapshot.subscriptionUsage]); + // Handed over raw. The meter ages it and reads the clock when its popover + // opens: deciding staleness here would memoise it against a snapshot that + // stops changing exactly when provider refreshes stop, so an expired + // allowance would never age out. + const activeSubscriptionUsage = selectedProviderEntry?.snapshot.subscriptionUsage; // ------------------------------------------------------------------ // Composer-local state @@ -3591,8 +3583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact={isComposerPrimaryActionsCompact} activeContextWindow={activeContextWindow} activeThreadModelDisplayName={activeThreadModelDisplayName} - activeSubscriptionUsage={activeSubscriptionUsage.usage} - activeSubscriptionUsageNowMs={activeSubscriptionUsage.nowMs} + activeSubscriptionUsage={activeSubscriptionUsage} pendingAction={pendingPrimaryAction} isRunning={phase === "running"} showPlanFollowUpPrompt={ diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index ed56a1020ae0..41ce6364d605 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import type { ProviderSubscriptionUsage } from "@t3tools/contracts"; import { Button } from "../ui/button"; @@ -5,6 +7,7 @@ import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/con import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; import { SubscriptionUsageMeters } from "./SubscriptionUsageMeters"; +import { usableSubscriptionUsage } from "./SubscriptionUsage.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -20,14 +23,19 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; modelDisplayName?: string | null; /** - * Subscription allowance for the instance this thread is running on, already - * aged out by the caller. Absent for providers with no plan limits to report. + * Raw subscription allowance for the instance this thread is running on. + * Absent for providers with no plan limits to report. */ subscriptionUsage?: ProviderSubscriptionUsage | undefined; - /** Clock for the reset countdowns, read once by the caller per popover open. */ - subscriptionUsageNowMs?: number; }) { - const { usage, modelDisplayName, subscriptionUsage } = props; + const { usage, modelDisplayName } = props; + // The clock is read when the popover opens, never on a timer. Ageing the + // snapshot in the composer instead would freeze the decision: it would be + // memoised against a snapshot that stops changing exactly when provider + // refreshes stop, so an expired allowance would sit here indefinitely. + const [openedAtMs, setOpenedAtMs] = useState(null); + const subscriptionUsage = + openedAtMs === null ? undefined : usableSubscriptionUsage(props.subscriptionUsage, openedAtMs); const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -41,7 +49,7 @@ export function ContextWindowMeter(props: { : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; return ( - + setOpenedAtMs(open ? Date.now() : null)}> ) : null}
- + ) : null} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 6c8f8dd67141..2841e77a9030 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -598,16 +598,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [filteredItemKeys, updateModelListScrollFades]); - // Keyed on the snapshot so the countdown re-reads the clock only when a fresh - // one arrives, never on every open or keystroke in the search box. + // The picker's popup unmounts on close, so this is read once per open — fresh + // enough to age a snapshot out, and never a repainting timer. + const [openedAtMs] = useState(() => Date.now()); const selectedInstanceSubscriptionUsage = useMemo(() => { const snapshot = selectedInstanceId === "favorites" ? undefined : props.instanceEntries.find((entry) => entry.instanceId === selectedInstanceId)?.snapshot .subscriptionUsage; - return { usage: usableSubscriptionUsage(snapshot, Date.now()), nowMs: Date.now() }; - }, [props.instanceEntries, selectedInstanceId]); + return usableSubscriptionUsage(snapshot, openedAtMs); + }, [openedAtMs, props.instanceEntries, selectedInstanceId]); return ( @@ -813,19 +814,19 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { at the moment of choosing. Absent for API-key sessions and for providers that report no plan limits. */} - {selectedInstanceSubscriptionUsage.usage ? ( + {selectedInstanceSubscriptionUsage ? (
Subscription
- {selectedInstanceSubscriptionUsage.usage.planLabel ? ( + {selectedInstanceSubscriptionUsage.planLabel ? (
- {selectedInstanceSubscriptionUsage.usage.planLabel} + {selectedInstanceSubscriptionUsage.planLabel}
) : null}
) : null}