Skip to content

feat(providers): show subscription usage for Claude and Codex - #20

Merged
jmclaren7 merged 2 commits into
mainfrom
claude/provider-subscription-usage-k472cl
Aug 26, 2026
Merged

feat(providers): show subscription usage for Claude and Codex#20
jmclaren7 merged 2 commits into
mainfrom
claude/provider-subscription-usage-k472cl

Conversation

@jmclaren7

@jmclaren7 jmclaren7 commented Aug 25, 2026

Copy link
Copy Markdown
Member

What Changed

ServerProvider gains an optional subscriptionUsage: 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:

  • Model picker — a footer for whichever provider is selected in the rail, so the allowance is visible at the moment of choosing.
  • Context bubble — a Subscription section 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.md and as fork entry 22 in FORK.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, 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, 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:

  • It rides the existing providerStatuses push, so there is no new channel and no contract version bump. ForwardCompatibleArray means an older client drops a window kind it cannot render rather than failing the whole config decode.
  • 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 — the probe hangs past its own 25s ceiling. The deadline lives inside the promise instead, 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 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 to learn what the account payload already said.
  • 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 is documented to be renamed, and model_scoped (the per-model buckets) ships server-side ahead of the npm typings. The call site takes unknown and 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.
  • Never cached to disk. writeProviderStatusCache already strips updateState; subscriptionUsage is 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.
  • Stored as used, rendered as left. Both providers report consumption, 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 above it. The reset countdown reads the clock when a surface opens, never on a timer.

Mobile receives the field for free via ServerProvider but 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

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes — not captured; see above
  • I included a video for animation/interaction changes — n/a, no motion added

Verification

Re-verified on the rebased head 6ef5d57a:

  • tsgo --noEmit clean on packages/contracts, apps/server and apps/web.
  • apps/server/src/provider/ — 580 passed, 6 skipped. apps/web/src/components/ — 1397 passed.
  • providerSubscriptionUsage.test.ts (16 cases) — both normalizers: the rateLimits envelope, 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 answers get_usage (proving the control request round-trips through the real SDK), plus one that reports a subscription and then ignores get_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.

main had landed its own FORK.md entry 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

Copilot AI balanced review requested due to automatic review settings August 25, 2026 21:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/server/src/provider/Layers/CodexProvider.ts
Comment thread apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Comment thread apps/web/src/components/chat/ChatComposer.tsx Outdated
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.4 KiB 13.4 KiB −2 B (−0.0%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB −5 B (−0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.5 KiB 6.5 KiB +3 B (+0.0%) 7.8 KiB
Codex Live turn WebSocket decoded 55.0 KiB 55.0 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 16 16 0 (0.0%) 21
Claude Total thread wire 13.5 KiB 13.4 KiB −8 B (−0.1%) 15.1 KiB
Claude Thread snapshot wire 6.9 KiB 6.9 KiB 0 B (0.0%) 7.3 KiB
Claude Live turn WebSocket wire 6.6 KiB 6.6 KiB −8 B (−0.1%) 7.8 KiB
Claude Live turn WebSocket decoded 55.8 KiB 55.8 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 16 16 0 (0.0%) 21

Baseline: 5b0711f · PR result: 7463d8c · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@jmclaren7

Copy link
Copy Markdown
Member Author

Check failed on 7463d8ca for a runner infrastructure reason, not this diff.

The job never got as far as running anything. It failed while downloading the voidzero-dev/setup-vp@v1 action, with DNS resolution failing against GitHub's own internal host:

Failed to download action 'https://internal-api.service.iad.github.net/repos/voidzero-dev/setup-vp/tarball/250f29ce...'
Error: Name or service not known (internal-api.service.iad.github.net:443)
...
Failed to download archive after 3 attempts.

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: Rust passed, and Test, Test Server 1/2/3 and Release Smoke all pulled the same action successfully and are still running.

Re-run attempted and currently blocked, not skipped: rerun-failed-jobs returns 403 This workflow is already running while the other five jobs are in flight. I'll re-run Check once the run completes, and I'm keeping a check-in scheduled until this PR is green. If the re-run fails on anything other than the action download, that failure is real and I'll treat it as mine.

For the record, the same checks pass locally on this exact commit: tsgo --noEmit clean on packages/contracts, apps/server and apps/web, vp lint exit 0, and 580 passing in apps/server/src/provider/ plus 1397 in apps/web/src/components/.


Generated by Claude Code

jmclaren7 and others added 2 commits August 26, 2026 16:40
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
@jmclaren7
jmclaren7 force-pushed the claude/provider-subscription-usage-k472cl branch from 7463d8c to 6ef5d57 Compare August 26, 2026 16:43
@jmclaren7
jmclaren7 merged commit 2f016c6 into main Aug 26, 2026
@jmclaren7
jmclaren7 deleted the claude/provider-subscription-usage-k472cl branch August 26, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants