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..381626f6927d 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", @@ -161,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/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..9ba6068723b7 --- /dev/null +++ b/apps/server/src/provider/providerSubscriptionUsage.test.ts @@ -0,0 +1,241 @@ +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("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 } }, + 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..1f927587b2dc --- /dev/null +++ b/apps/server/src/provider/providerSubscriptionUsage.ts @@ -0,0 +1,284 @@ +/** + * 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. + * + * 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. + */ +export function normalizeCodexSubscriptionUsage(input: { + readonly snapshot: unknown; + readonly collectedAt: string; +}): ProviderSubscriptionUsage | undefined { + const payload = asRecord(input.snapshot); + if (!payload) { + return undefined; + } + const snapshot = asRecord(payload.rateLimits) ?? payload; + + 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..51317e59da59 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, @@ -446,6 +447,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; + activeSubscriptionUsage: ProviderSubscriptionUsage | undefined; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -475,6 +477,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( @@ -1019,6 +1022,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + // 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 @@ -3575,6 +3583,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact={isComposerPrimaryActionsCompact} activeContextWindow={activeContextWindow} activeThreadModelDisplayName={activeThreadModelDisplayName} + 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 6943684b1f58..41ce6364d605 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,7 +1,13 @@ +import { useState } from "react"; + +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"; +import { usableSubscriptionUsage } from "./SubscriptionUsage.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -16,8 +22,20 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; modelDisplayName?: string | null; + /** + * Raw subscription allowance for the instance this thread is running on. + * Absent for providers with no plan limits to report. + */ + subscriptionUsage?: ProviderSubscriptionUsage | undefined; }) { 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; @@ -31,7 +49,7 @@ export function ContextWindowMeter(props: { : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; return ( - + setOpenedAtMs(open ? Date.now() : null)}> ) : 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..2841e77a9030 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,18 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [filteredItemKeys, updateModelListScrollFades]); + // 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 usableSubscriptionUsage(snapshot, openedAtMs); + }, [openedAtMs, 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 ? ( +
+
+
Subscription
+ {selectedInstanceSubscriptionUsage.planLabel ? ( +
+ {selectedInstanceSubscriptionUsage.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;