feat(providers): show subscription usage for Claude and Codex - #20
Conversation
There was a problem hiding this comment.
Pull request overview
Adds normalized Claude and Codex subscription-usage reporting to provider status snapshots and displays it in web composer surfaces.
Changes:
- Collects and normalizes provider rate-limit windows.
- Renders remaining allowance in the model picker and context popover.
- Adds tests and user/fork documentation.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages/contracts/src/server.ts |
Defines subscription-usage contracts. |
apps/server/src/provider/providerSubscriptionUsage.ts |
Normalizes Claude and Codex responses. |
apps/server/src/provider/providerSubscriptionUsage.test.ts |
Tests normalization behavior. |
apps/server/src/provider/providerStatusCache.ts |
Excludes live usage from persistence. |
apps/server/src/provider/providerSnapshot.ts |
Adds usage to provider snapshots. |
apps/server/src/provider/Layers/CodexProvider.ts |
Requests Codex rate limits. |
apps/server/src/provider/Layers/ClaudeProvider.ts |
Requests Claude usage with a deadline. |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts |
Extends the Claude probe fixture. |
apps/web/src/components/chat/SubscriptionUsage.logic.ts |
Implements usage formatting and aging. |
apps/web/src/components/chat/SubscriptionUsage.logic.test.ts |
Tests presentation logic. |
apps/web/src/components/chat/SubscriptionUsageMeters.tsx |
Renders usage meters. |
apps/web/src/components/chat/ModelPickerContent.tsx |
Adds model-picker usage details. |
apps/web/src/components/chat/ContextWindowMeter.tsx |
Adds usage to the context popover. |
apps/web/src/components/chat/ChatComposer.tsx |
Connects active-provider usage to the meter. |
docs/user/composer.md |
Documents subscription usage. |
FORK.md |
Records the fork-specific feature. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
|
The job never got as far as running anything. It failed while downloading the No checkout, no install, no test body — three retries over ~46s, all DNS. That it is transient rather than systemic is visible in the same run: Re-run attempted and currently blocked, not skipped: For the record, the same checks pass locally on this exact commit: Generated by Claude Code |
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEwwDVNes37aYjwtUgNhEi
… open 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEwwDVNes37aYjwtUgNhEi
7463d8c to
6ef5d57
Compare
What Changed
ServerProvidergains an optionalsubscriptionUsage: a normalized list of plan windows, each carrying a label, percent used, and a reset time. The Claude and Codex status probes fill it in, and two surfaces render it:Subscriptionsection in the popover of the round meter beside the send button, for the provider the current thread is running on.Claude contributes its 5-hour window, its weekly window, the per-model weekly buckets (Fable, Opus, Sonnet) and overage when enabled. Codex contributes its two windows plus plan type. Cursor, Grok and OpenCode report nothing and show nothing, matching the usage page's existing Claude/Codex scope.
Documented for users in
docs/user/composer.mdand as fork entry 22 inFORK.md.Why
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 adapters translate the CLIs' native rate-limit messages into
account.rate-limits.updated, andProviderRuntimeIngestionhas no case for it. Rather than reconstruct the window set from that stream (each Claude event names only the window that just moved, so it needs accumulated state and stays empty until a turn runs), the status probes 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.Design decisions worth reviewing:
providerStatusespush, so there is no new channel and no contract version bump.ForwardCompatibleArraymeans an older client drops a window kind it cannot render rather than failing the whole config decode.Effect.timeoutOptioncannot bound the Claude request. It does not interrupt atryPromisewhose promise never settles, and a CLI that does not implement the control request never answers it — the probe hangs past its own 25s ceiling. The deadline lives inside the promise instead, as anAbortSignal.timeoutraced against the call and folded together with the fiber's signal. Codex needs none of this: its client resolves through an interruptibleDeferred.await.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YETand is documented to be renamed, andmodel_scoped(the per-model buckets) ships server-side ahead of the npm typings. The call site takesunknownand the normalizer validates, so a rename degrades to "the UI shows nothing" rather than a build break. No lockfile bump was needed — those buckets arrive over the wire from the user's own installed CLI, not from the npm package.writeProviderStatusCachealready stripsupdateState;subscriptionUsageis stripped the same way, and the client ages a snapshot out after an hour when its popover opens. A restart or a tab left open overnight shows nothing rather than a stale allowance.Mobile receives the field for free via
ServerProviderbut has no context bubble and its own provider sheets, so rendering it there is deliberately left as separate work.UI Changes
No screenshots — I did not launch a browser or the app, per the repo's rule about not verifying with browsers or computer use without explicit agreement. Both surfaces are additive and render nothing at all when a provider reports no usage, so the no-subscription path is visually unchanged. Happy to capture before/after if you want it before merging.
Checklist
Verification
Re-verified on the rebased head
6ef5d57a:tsgo --noEmitclean onpackages/contracts,apps/serverandapps/web.apps/server/src/provider/— 580 passed, 6 skipped.apps/web/src/components/— 1397 passed.providerSubscriptionUsage.test.ts(16 cases) — both normalizers: therateLimitsenvelope, window labelling from duration, epoch-seconds and ISO reset times, percent clamping, the Fable per-model bucket, overage only while enabled, and every "report nothing" path.SubscriptionUsage.logic.test.ts(11 cases) — remaining-percent formatting, countdown unit scaling, post-rollover silence, staleness ageing.ClaudeCapabilitiesProbe.test.ts— a fake CLI that answersget_usage(proving the control request round-trips through the real SDK), plus one that reports a subscription and then ignoresget_usage(the older-CLI case the request deadline exists for). Both review-fix regressions were confirmed to fail against the un-fixed code before passing with it.History
Rebased onto
main(5b0711f9) for linear history — the branch previously carried a merge commit. The resulting tree is byte-identical to the pre-rebase head that CI passed on; only the shape of the history changed.mainhad landed its ownFORK.mdentry 21 (the sidebar new-project menu item) while this was in flight. That entry stays 21 and this one is renumbered to 22; the section-1 summary line carries both.🤖 Generated with Claude Code
https://claude.ai/code/session_01AEwwDVNes37aYjwtUgNhEi