feat(composer): show provider account usage in the input area - #5945
feat(composer): show provider account usage in the input area#5945gfsaaser24 wants to merge 3 commits into
Conversation
You find out you are out of quota when a turn stops halfway through.
Both the Claude and Codex adapters already emit
`account.rate-limits.updated` at the end of every turn and nothing
consumed it, so the numbers were sitting there unused.
Adds a provider-agnostic `ProviderUsageLimits` contract
(`{ id, label, usedPercent, resetsAt }`) and two feeds into an in-memory
store: the free turn events, and a debounced on-demand pull for Claude,
whose OAuth usage endpoint reports utilization without spending message
quota. Web draws a ring per window beside the send button, collapsing to
the worst one when the composer is narrow; mobile gets a single toolbar
button that lists the windows on tap. Providers that report no usage —
Cursor, Grok, OpenCode — render nothing rather than an empty circle.
Readings are never persisted, and the store only announces a change when
a number actually moves, so a provider re-reporting identical usage does
not push the whole provider array to every client once per turn.
Written by Claude Opus 5 in Claude Code.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Effect service conventions review of the new provider-usage services. Four findings, all in the new service/layer modules: standalone *Shape interfaces instead of inline Context.Service interfaces, and new services split across Services/ + Layers/ with *Live layer names rather than one canonical module exporting make/layer.
Posted via Macroscope — Effect Service Conventions
|
|
||
| const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); | ||
|
|
||
| export const ProviderUsageRefresherLive = Layer.effect( |
There was a problem hiding this comment.
Same layout issue, plus the tag for this service lives in a differently named module (Services/ProviderUsageLimits.ts). Consider a single canonical apps/server/src/provider/ProviderUsageRefresher.ts holding the Context.Service tag, an exported make, and export const layer = Layer.effect(ProviderUsageRefresher, make) rather than ProviderUsageRefresherLive.
Posted via Macroscope — Effect Service Conventions
| ProviderUsageLimitsStoreShape | ||
| >()("t3/provider/Services/ProviderUsageLimits/ProviderUsageLimitsStore") {} | ||
|
|
||
| export interface ProviderUsageRefresherShape { |
There was a problem hiding this comment.
Same here: ProviderUsageRefresherShape should be inlined into the Context.Service declaration below, with consumers using ProviderUsageRefresher["Service"] (as server.test.ts already does for the mock) rather than the exported shape type.
Posted via Macroscope — Effect Service Conventions
| */ | ||
| export const USAGE_REFRESH_DEBOUNCE_MS = 60_000; | ||
|
|
||
| export const ProviderUsageLimitsStoreLive = Layer.effect( |
There was a problem hiding this comment.
This new service is split across Services/ProviderUsageLimits.ts (tag) and Layers/ProviderUsageLimits.ts (construction + layer). For newly added services the tag, construction, and layer belong in one canonical module — e.g. apps/server/src/provider/ProviderUsageLimits.ts — exporting a real make plus export const layer = Layer.effect(ProviderUsageLimitsStore, make) instead of ProviderUsageLimitsStoreLive.
Posted via Macroscope — Effect Service Conventions
| import type * as Scope from "effect/Scope"; | ||
| import type * as Stream from "effect/Stream"; | ||
|
|
||
| export interface ProviderUsageLimitsStoreShape { |
There was a problem hiding this comment.
New service definitions should declare their interface inline in the Context.Service call rather than keeping a standalone *Shape type. Consider moving these members into Context.Service<ProviderUsageLimitsStore, { ... }>() and referring to the inferred shape as ProviderUsageLimitsStore["Service"] at the implementation site (satisfies in Layers/ProviderUsageLimits.ts) instead of importing ProviderUsageLimitsStoreShape.
Posted via Macroscope — Effect Service Conventions
| // capability, and skill — to every connected client, once per | ||
| // turn, for no visible change. | ||
| return [ | ||
| existing === undefined || !Equal.equals(existing.windows, merged.windows), |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderUsageLimits.ts:65
Equal.equals(existing.windows, merged.windows) compares ordinary JavaScript arrays by reference, not structurally. applyUsageReading returns a new array on every call, so even an identical re-report of usage is treated as changed and PubSub.publish fires every turn. The change suppression the comment describes is ineffective, so unchanged usage readings trigger the registry to rebuild and broadcast the full provider snapshot to every client. Use an explicit structural comparison for the windows arrays (or use data types implementing Equal).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderUsageLimits.ts around line 65:
`Equal.equals(existing.windows, merged.windows)` compares ordinary JavaScript arrays by reference, not structurally. `applyUsageReading` returns a new array on every call, so even an identical re-report of usage is treated as changed and `PubSub.publish` fires every turn. The change suppression the comment describes is ineffective, so unchanged usage readings trigger the registry to rebuild and broadcast the full provider snapshot to every client. Use an explicit structural comparison for the windows arrays (or use data types implementing `Equal`).
ApprovabilityVerdict: Needs human review 2 blocking correctness issues found. This PR introduces a substantial new feature (provider usage meters) with new UI components, server services, RPC endpoints, and OAuth credential reading. The scope includes ~3000 lines of new code and multiple unresolved Medium-severity findings about correctness issues. New feature introduction of this complexity warrants human review. You can customize Macroscope's approvability policy. Learn more. |
Four real ones from the bot pass: - The Android usage menu never ran its own onPress. `AndroidAnchoredMenu` wraps a plain child in a `pointerEvents="none"` view so the anchor owns the tap, so opening the menu never restamped the relative labels or asked for a fresh reading. `ControlPillMenu` now also accepts the render-function child form, which keeps the trigger interactive there. - The web popover titled one provider's quota with another's name. The meters read the selected instance (the choice for the next turn) while the title read the thread's persisted provider, so picking a new model swapped the numbers but not the label. - `claimRefreshSlot` wedged when the wall clock moved backwards: a negative elapsed read as "still inside the debounce window" and blocked refreshes until the clock caught up. - `fetchClaudeUsage` only bounded `client.execute`, so a 2xx response with a trickled body parked the refresh fiber past the timeout. Written by Claude Opus 5 in Claude Code.
|
Pushed FixedAndroid usage menu never ran its own Web popover attributed one provider's quota to another. The meters read
Not fixed, because the bots are wrong
Effect service conventions ×4 — the suggestion is to collapse the tag, construction, and layer into one canonical module and inline the "Refresh debounce burned without a token" — the sequence is real but the current ordering is the safer one. Claiming the slot before reading credentials is what stops ambient UI triggers (window focus, thread switch, meter hover) from driving an unbounded number of macOS Tests, typecheck, and lint are clean on the new commit. The two pre-existing failures I mentioned in the description still reproduce on unmodified |
|
revisions inbound. leave open please. |
Remaining findings from the bot pass: - A rebuilt or removed instance kept its stored usage reading. Rebuilds can point at a different account, and `applyProviderUsageLimits` decorated the new configuration's snapshots with the old numbers until a fresh reading happened to land. The store gains `clear`, and `syncLiveSources` forgets every instance it does not carry over unchanged. The usage-change subscriber now strips the previous decoration before re-upserting — left on, it re-seeded whatever the store held last, which resurrected cleared readings the moment the clear announced itself. - A present-but-malformed percent alias (`used_percentage: "abc"`) masked a readable later one; each alias now runs through the parser in turn. - The usage-meter doc described the hover value as the exact percentage; it is the displayed (rounded) one. Written by Claude Fable 5 in Claude Code.
| resetsAt: readIsoTimestamp( | ||
| value.resets_at ?? value.resetsAt ?? value.reset_at ?? value.resetAt, | ||
| ), |
There was a problem hiding this comment.
🟡 Medium provider/usageLimits.ts:106
readClaudeBucket parses the reset-time aliases with ??, so a present-but-malformed resets_at value masks a valid resetsAt fallback. For input like { resets_at: "invalid", resetsAt: "2026-08-12T00:00:00Z" }, readIsoTimestamp receives "invalid", returns null, and the valid resetsAt timestamp is silently dropped — producing a missing reset label even though a usable value was present. The percentage aliases just above correctly avoid this by running each candidate through readPercent. Consider applying the same reduce-through-parser pattern to the reset aliases.
| resetsAt: readIsoTimestamp( | |
| value.resets_at ?? value.resetsAt ?? value.reset_at ?? value.resetAt, | |
| ), | |
| resetsAt: [value.resets_at, value.resetsAt, value.reset_at, value.resetAt].reduce<string | null>( | |
| (found, candidate) => found ?? readIsoTimestamp(candidate), | |
| null, | |
| ), |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/usageLimits.ts around lines 106-108:
`readClaudeBucket` parses the reset-time aliases with `??`, so a present-but-malformed `resets_at` value masks a valid `resetsAt` fallback. For input like `{ resets_at: "invalid", resetsAt: "2026-08-12T00:00:00Z" }`, `readIsoTimestamp` receives `"invalid"`, returns `null`, and the valid `resetsAt` timestamp is silently dropped — producing a missing reset label even though a usable value was present. The percentage aliases just above correctly avoid this by running each candidate through `readPercent`. Consider applying the same reduce-through-parser pattern to the reset aliases.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 75a8a88. Configure here.
| if (hadReading) { | ||
| yield* PubSub.publish(changes, instanceId); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Clear loses to in-flight sets
Medium Severity
clear drops a rebuilt instance’s reading, but it does not invalidate in-flight writers. A Claude refresh can resolve settings and claim a slot, then finish its OAuth pull after rebuild and call set with the previous account’s numbers. The same hole exists for a snapshot syncProvider that still carries baked-in usageLimits. Meters can show the old account again after a config change that was supposed to wipe them.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 75a8a88. Configure here.
|
Superseded by #6080 — the same feature re-cut as one clean commit on current |


What Changed
Adds account usage meters to the composer.
Contract — a provider-agnostic
ProviderUsageLimitsinpackages/contracts, shaped{ id, label, usedPercent, resetsAt }, so clients map over a list instead of branching per provider.Server — an in-memory
ProviderUsageLimitsStorefed by two sources:ProviderUsageIngestionconsumes theaccount.rate-limits.updatedruntime events the Claude and Codex adapters already emit and nothing currently reads. Zero extra network calls.ProviderUsageRefresherdoes a debounced (60s/instance) pull for Claude only, since Claude reports usage only on request. It reads the token theclaudeCLI already stored and calls the OAuth usage endpoint, which reports utilization without spending message quota. Codex needs no pull — it volunteers numbers over the app-server connection.Provider-shaped payloads are flattened in
usageLimits.ts. Every normalizer is total: anything unparseable yields no windows rather than an error.Web — a ring per window beside the send button, collapsing to the worst window when the composer is narrow. Hover gives exact percentages and reset times.
Mobile — one toolbar button instead of three 44pt touch targets, listing the windows on tap.
Providers that do not report usage (Cursor, Grok, OpenCode) render nothing at all, so switching threads between providers costs no layout shift.
Why
Right now you find out you are out of quota when a turn stops halfway through. The data to prevent that is already flowing — both adapters emit rate-limit events at the end of every turn and nothing consumes them.
Design decisions worth flagging, since they are the ones you would push back on:
updatedAtis stamped server-side on every reading, so publishing on the stamp would wake the registry and push the entire provider array — every model, capability, and skill — to every connected client once per turn, for no visible change.UI Changes
Hovering a meter in the web composer:
Before is the same composer with no circles and no popover — the meters are purely additive, and providers that report no usage still render exactly that.
No motion or transitions were added, so there is no video.
Testing
Branched off
mainat3d74474f. Focused tests for the normalizers, the store's merge/debounce/announce behavior, the ingestion seam, the token reader, and the web components. Typecheck and lint clean across server, web, mobile, contracts, and client-runtime.Two pre-existing failures in
ProviderRegistry.test.tsandserver.test.tsreproduce identically on unmodifiedmainon my machine — they look like Windows-only arg-quoting artifacts in the spawn mocks (Unexpected args: ^"--version^"), unrelated to this change.Checklist
Note
Medium Risk
Large cross-cutting feature touching provider registry, OAuth credential reads, and macOS keychain subprocesses; failures are designed to degrade to empty meters rather than block turns.
Overview
Adds account quota usage to the composer on web and mobile, driven by a new optional
usageLimitsfield onServerProviderand aserverRefreshProviderUsageRPC that schedules a background pull and returns immediately.On the server, a volatile in-memory store merges full vs partial readings from turn-time
account.rate-limits.updatedevents (Claude/Codex) and from Claude-only on-demand pulls (OAuth token from the CLI credentials path or macOS keychain, debounced per instance). Codex also seeds usage during status probe viaaccount/rateLimits/readwith a tight timeout. The provider registry decorates snapshots from the store, republishes on usage-only changes without disk writes, strips usage from status cache persistence, and clears stored usage when an instance is rebuilt or removed.Web shows ring meters beside send (compact = worst window); mobile uses one gauge menu in the toolbar.
ControlPillMenugains a render-prop child so Android triggers stay tappable. Shared formatting/selection lives in@t3tools/client-runtime/state/provider-usage.Reviewed by Cursor Bugbot for commit 75a8a88. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add provider account usage meters to the composer input area
ProviderUsageLimitsStorethat tracks usage per provider instance, populated via runtime turn events (ProviderUsageIngestionLive) and on-demand HTTP fetches (ProviderUsageRefresher) for Claude and Codex.serverRefreshProviderUsageWebSocket RPC that schedules a background usage refresh without blocking the caller; client-side calls are deduplicated with single-flight keying.ServerProvidersnapshots in the registry and propagated to clients without disk persistence or waiting for full status probes.Macroscope summarized 75a8a88.