feat(composer): show provider account usage in the composer - #6080
feat(composer): show provider account usage in the composer#6080gfsaaser24 wants to merge 1 commit into
Conversation
|
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.
Three convention findings on the two newly introduced Effect services (ProviderUsageLimitsStore, ProviderUsageRefresher). They follow the older Services/ + Layers/ + Shape + ...Live shape rather than the canonical single-module Context.Service (inline interface) + make + layer layout used elsewhere in this app (e.g. apps/server/src/provider/providerMaintenanceRunner.ts, apps/server/src/provider/Layers/ProviderEventLoggers.ts, apps/server/src/workspace/WorkspacePaths.ts). Nothing else in the diff raised service-convention issues.
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 module exports only ProviderUsageLimitsStoreLive with construction inlined into Layer.effect. Convention is to export a real make (the Effect.gen body) plus export const layer = Layer.effect(ProviderUsageLimitsStore, make) from the service's canonical module, so consumers use ProviderUsageLimits.layer / ProviderUsageLimits.make. Folding this file into the tag module (see the comment on Services/ProviderUsageLimits.ts) and renaming to make/layer addresses both.
Posted via Macroscope — Effect Service Conventions
|
|
||
| const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); | ||
|
|
||
| export const ProviderUsageRefresherLive = Layer.effect( |
There was a problem hiding this comment.
Same as the store layer: prefer an exported make plus export const layer = Layer.effect(ProviderUsageRefresher, make) in the refresher's own canonical module (apps/server/src/provider/ProviderUsageRefresher.ts) instead of a ...Live layer whose tag lives in a separate Services/ file.
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.
Both new services keep a standalone Shape interface (ProviderUsageLimitsStoreShape here, ProviderUsageRefresherShape at line 101) and split the tag from its implementation across Services/ and Layers/. Convention for new services is one canonical module per service, with the interface declared inline in the Context.Service call and make/layer in the same file.
Suggest hoisting to apps/server/src/provider/ProviderUsageLimits.ts (store) and apps/server/src/provider/ProviderUsageRefresher.ts (refresher), inlining each interface into its Context.Service declaration, and dropping the two ...Shape types (referring to the inferred shape as ProviderUsageLimitsStore["Service"] where needed).
Posted via Macroscope — Effect Service Conventions
| onPress={() => { | ||
| // Restamp the relative labels and ask for a fresh | ||
| // reading; the server debounces the pull. | ||
| setUsageMenuNonce((nonce) => nonce + 1); |
There was a problem hiding this comment.
🟡 Medium threads/ThreadComposer.tsx:899
On iOS, the usage menu opens showing stale relative-time labels. The trigger's onPress calls setUsageMenuNonce to restamp labels like "resets in 2h 15m" and "as of 12m ago", but ControlPillMenu passes a no-op open on iOS and opens its native menu synchronously from the same tap event. The re-render triggered by the state update has not run yet when the menu presents, so it displays usageMenuActions computed from the previous render with outdated times. If usage data hasn't changed in hours, the labels stay frozen at whatever the clock read when the menu was last built. Consider computing time-sensitive values at open time (e.g., computing nowMs eagerly in the onPress handler) rather than relying on a state update to re-render before the native menu appears.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 899:
On iOS, the usage menu opens showing stale relative-time labels. The trigger's `onPress` calls `setUsageMenuNonce` to restamp labels like "resets in 2h 15m" and "as of 12m ago", but `ControlPillMenu` passes a no-op `open` on iOS and opens its native menu synchronously from the same tap event. The re-render triggered by the state update has not run yet when the menu presents, so it displays `usageMenuActions` computed from the previous render with outdated times. If usage data hasn't changed in hours, the labels stay frozen at whatever the clock read when the menu was last built. Consider computing time-sensitive values at open time (e.g., computing `nowMs` eagerly in the `onPress` handler) rather than relying on a state update to re-render before the native menu appears.
| // put this fiber behind the single `Effect.yieldNow` that its | ||
| // per-instance subscribers rely on to attach, delaying theirs by a | ||
| // tick and dropping the first snapshot published to them. | ||
| const usageChanges = yield* usageStore.subscribeChanges; |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderRegistry.ts:710
The usageChanges subscriber never republishes a provider when a usage reading arrives with identical percentages but a newer updatedAt, because the usage store intentionally suppresses change notifications for updatedAt-only updates. As a result, getProviders and WebSocket clients keep showing the stale usageLimits.updatedAt for that instance — so formatUsageUpdatedAtLabel renders an actively-refreshed unchanged quota as "as of … hours/days ago" until some unrelated percentage or snapshot change happens. Consider republishing the instance in the subscriber when updatedAt advances even if the bucket values are unchanged, or having the store emit a notification for updatedAt-only changes.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderRegistry.ts around line 710:
The `usageChanges` subscriber never republishes a provider when a usage reading arrives with identical percentages but a newer `updatedAt`, because the usage store intentionally suppresses change notifications for `updatedAt`-only updates. As a result, `getProviders` and WebSocket clients keep showing the stale `usageLimits.updatedAt` for that instance — so `formatUsageUpdatedAtLabel` renders an actively-refreshed unchanged quota as "as of … hours/days ago" until some unrelated percentage or snapshot change happens. Consider republishing the instance in the subscriber when `updatedAt` advances even if the bucket values are unchanged, or having the store emit a notification for `updatedAt`-only changes.
| if (!claimed) { | ||
| return; | ||
| } | ||
| // Snapshot the clear-generation before the pull. If the instance is |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderUsageRefresher.ts:116
When an instance is rebuilt between claimRefreshSlot and the usageStore.generation snapshot, the refresher writes the previous account's quota onto the rebuilt instance. accessToken and claudeSettings are resolved before the claim, so they belong to the old account; but generation is snapshotted after the claim, so it captures the post-clear generation. The guarded set therefore passes and overwrites the rebuilt instance's usage with stale data from the old account. Snapshot the generation before any credential work (before resolveClaudeConfigDirPath / readClaudeAccessToken) and use that earlier snapshot in the set call.
Also found in 1 other location(s)
apps/server/src/provider/Layers/ProviderUsageLimits.ts:112
clearresetslastRefreshAtRefseparately before bumping the generation inusageRef. A refresh that has already read the old account token can interleave after line 112: it claims the newly cleared refresh slot, thenclearbumps the generation, and the refresher snapshots that new generation before fetching with the old token. Its guardedsettherefore succeeds and restores the previous account's quota onto the rebuilt instance. The debounce state and generation must be cleared atomically, or the refresher must snapshot the generation before any account-specific/token work.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderUsageRefresher.ts around line 116:
When an instance is rebuilt between `claimRefreshSlot` and the `usageStore.generation` snapshot, the refresher writes the previous account's quota onto the rebuilt instance. `accessToken` and `claudeSettings` are resolved before the claim, so they belong to the old account; but `generation` is snapshotted after the claim, so it captures the post-clear generation. The guarded `set` therefore passes and overwrites the rebuilt instance's usage with stale data from the old account. Snapshot the generation before any credential work (before `resolveClaudeConfigDirPath` / `readClaudeAccessToken`) and use that earlier snapshot in the `set` call.
Also found in 1 other location(s):
- apps/server/src/provider/Layers/ProviderUsageLimits.ts:112 -- `clear` resets `lastRefreshAtRef` separately before bumping the generation in `usageRef`. A refresh that has already read the old account token can interleave after line 112: it claims the newly cleared refresh slot, then `clear` bumps the generation, and the refresher snapshots that new generation before fetching with the old token. Its guarded `set` therefore succeeds and restores the previous account's quota onto the rebuilt instance. The debounce state and generation must be cleared atomically, or the refresher must snapshot the generation before any account-specific/token work.
ApprovabilityVerdict: Needs human review 4 blocking correctness issues found. This PR introduces a new user-facing feature (provider usage meters) with significant new infrastructure including OAuth credential reading, multiple new services, and cross-cutting changes to provider snapshots. Multiple unresolved Medium-severity findings identify race conditions in the refresh/rebuild logic that warrant attention before merging. You can customize Macroscope's approvability policy. Learn more. |
eeba5d6 to
882ebfb
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 882ebfb. Configure here.
882ebfb to
9280026
Compare
|
Addressed the Macroscope findings in Fixed
Not changed, deliberately
|
A provider-agnostic usage-window contract, an in-memory store fed by the rate-limit events both adapters already emit plus a debounced OAuth pull for Claude, and meters in the web and mobile composers. Providers that report nothing render nothing. Hardening folded in from review: readings survive sparse per-bucket updates by merging on window id; rebuilt or removed instances drop their stored reading (generation-guarded against in-flight pulls restoring them); the refresh debounce survives a backwards wall clock and is not burned by a missing token; the usage fetch bounds the body read, not just the headers; percent and reset-time aliases fall through to the next readable value; Android's usage menu keeps its trigger interactive. Written by Claude in Claude Code.
9280026 to
cee4fe4
Compare
|
Follow-up in |
| } | ||
| // The OAuth endpoint reports every bucket, so this is authoritative: | ||
| // a window it omits really is gone. | ||
| yield* usageStore.set(instanceId, usage, "full", { ifGenerationIs: generation }); |
There was a problem hiding this comment.
🟡 Medium provider/ProviderUsageRefresher.ts:146
When an instance is rebuilt after usageStore.clear() frees its debounce state, a refresh that was already in flight can call claimRefreshSlot(instanceId) against the rebuilt instance. The eventual set is correctly rejected by the generation guard, but the claim is not released — so the rebuilt instance's own refresh is suppressed for the full 60-second debounce window and its meters stay blank or stale. The claim should be released when the guarded set is rejected, or made generation-aware so it does not suppress a rebuilt instance.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/ProviderUsageRefresher.ts around line 146:
When an instance is rebuilt after `usageStore.clear()` frees its debounce state, a refresh that was already in flight can call `claimRefreshSlot(instanceId)` against the rebuilt instance. The eventual `set` is correctly rejected by the generation guard, but the claim is not released — so the rebuilt instance's own refresh is suppressed for the full 60-second debounce window and its meters stay blank or stale. The claim should be released when the guarded `set` is rejected, or made generation-aware so it does not suppress a rebuilt instance.

Supersedes #5945 — same feature, re-cut as a single clean commit on current
mainwith every review-bot finding from that PR folded in (Android menu tap, provider-name mismatch, backwards-clock debounce, body-read timeout, stale usage across instance rebuilds with an in-flight-pull guard, percent/reset alias fallback).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
mainate5c82d7. 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
Touches provider registry, credential reads (Claude OAuth/keychain), and live snapshot publishing; failures are designed to degrade to blank meters, but instance-rebuild races and multi-account config paths need careful review.
Overview
Introduces account usage meters in the composer so users see subscription quota before a turn fails. The feature spans contracts, server plumbing, shared client helpers, and web/mobile UI.
Contracts & RPC — Adds
ProviderUsageLimits/ProviderUsageWindowonServerProviderand a read-scopedserver.refreshProviderUsageWebSocket RPC that schedules a pull and returns immediately; fresh numbers arrive on the provider snapshot stream.Server — A volatile
ProviderUsageLimitsStoremerges full vs partial readings, debounces pulls per instance (60s), clears usage when an instance is rebuilt, and pushes onlyusageLimitschanges to clients without persisting or re-upserting full snapshots. ProviderUsageIngestion consumes existingaccount.rate-limits.updatedturn events (Claude/Codex). ProviderUsageRefresher pulls Claude via stored OAuth credentials and the usage API (no message quota). Codex usage is read during status probe (account/rateLimits/read, bounded timeout) and normalized inusageLimits.ts. ProviderRegistry decorates snapshots from the store and strips usage from disk cache.Clients — Shared formatting/selection in
@t3tools/client-runtime/state/provider-usage. Web:UsageLimitsMetersring(s) beside send, compact mode shows worst window, refresh on focus/hover/open. Mobile: single gauge toolbar menu viaControlPillMenurender-function child (Android tap fix) andAppStatefocus refresh. ControlPillMenu acceptschildrenas(open) => ReactNodeso Android triggers can callopen()after their ownonPress.Docs: user guide and glossary entry for usage windows.
Reviewed by Cursor Bugbot for commit cee4fe4. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Show provider account usage meters in the composer and mobile thread view
ProviderUsageWindowandProviderUsageLimitsschemas toServerProvidersnapshots, populated from Claude OAuth usage endpoint and Codexaccount/rateLimits/readduring provider probes.ProviderUsageLimitsStore(in-memory, per-instance, debounced) andProviderUsageIngestionLiveto merge usage data from both probe snapshots and live turn events without extra network calls.ProviderUsageRefresherservice and a newserver.refreshProviderUsageWebSocket RPC so clients can request on-demand usage pulls; results appear on the provider snapshot stream.UsageLimitsMeters) and aControlPillMenuin the mobile composer; both refresh on mount, window/app focus, and menu open.formatUsagePercent,formatUsageResetLabel,formatUsageUpdatedAtLabel,pickWorstUsageWindow) handle countdown vs. absolute reset labels and staleness display.Macroscope summarized cee4fe4.