Skip to content

Composer usage meter, Claude plan tier, and Retry-After handling - #41

Open
daveycodez wants to merge 1 commit into
V1ki:mainfrom
daveycodez:feat/composer-usage-meter
Open

Composer usage meter, Claude plan tier, and Retry-After handling#41
daveycodez wants to merge 1 commit into
V1ki:mainfrom
daveycodez:feat/composer-usage-meter

Conversation

@daveycodez

Copy link
Copy Markdown

Puts the Claude subscription reading where it is actually spent — the composer — and closes two gaps that kept it approximate.

Happy to split any part of this out if you'd rather take it in pieces.

Composer meter

A ring in conversation.input.right, beside the shell's own ContextMeter whose ring geometry and panel chrome it mirrors so the pair reads as one family.

  • Tracks the limit that applies to the current model: a model with its own weekly limit shows that one, everything else the shared weekly pool.
  • Amber at 75%, red at 95% — the Claude Code app's steps.
  • Hovering names the window and revalidates; clicking opens every reported limit with its reset time (Resets in 4 hr 35 min, Resets Thu 11:59 AM).
  • Renders only while a Claude model is selected, so it never reports a limit the next turn will not spend.

On request volume

The usage endpoint is aggressively rate limited and shared with the settings page, so the meter is deliberately frugal — I managed to 429 myself while developing this, which is what set these numbers:

Idle session no upstream calls at all
Running turn every 3 min
Floor between any two calls 3 min, whoever asks
On hover revalidates, subject to that floor
On 429 5 min, doubling to a 10 min cap

One store serves every open session — the slot renders per session, so a fetcher per component would multiply the rate by the number of sessions. Stale-while-revalidate throughout: a refusal never clears the last good reading, so the panel degrades to slightly stale numbers rather than an error card.

On the model gate

The current model is not on the conversation snapshot, there is no model-change event in the client catalog, and the model selector's store is private to that plugin — so the gate polls sessions.models. That is a local host RPC with no upstream traffic, so it runs at 700ms, and any pointer/Enter gesture additionally probes on a short staircase, because a switch is always a gesture and waiting out a tick reads as lag. If a model/change event ever lands, this becomes exact and cheaper.

Plan tier

Max never appeared because the Claude usage mapper returned only { supported, windows }. I checked the live endpoint before assuming: its payload carries no plan, tier, or multiplier field anywhere — unlike codex's plan_type. The stored session already knows it, so the plan now comes from subscriptionType. ProviderUsage.plan needed no change.

The (20x) part of Claude Code's Max (20x) is a multiplier the credentials do not carry, so this renders Max. I would rather show what we know than hardcode a mapping.

Retry-After

oauthEndpointError kept only the status and OAuth code and dropped the header, so a 429 reached the page as bare text and every caller — this meter and the settings page both — could only guess at a backoff.

It is parsed now (both the seconds and HTTP-date forms, refusing past deadlines) and rides OAuthEndpointError.retryAfterMs.

One wart worth flagging: the internal RpcResult branch types details as z.object({}) upstream in dsh-host-apiproxy, so a structured field would be stripped in validation. The only channel that survives to the browser is the message, so the node half appends a parseable (retry-after: 300s) suffix and the meter reads it. It works and it is documented at both ends, but the honest fix is a details field upstream — happy to change this if you'd prefer it carried differently.

Also

  • Settings meters adopt the shell's bar geometry (4px, fully rounded, filled track, no border) and the same 75% step, so a plugin-drawn progress bar reads as the control the product draws elsewhere.
  • A nav glyph for the Subscriptions section, replacing the fallback gear. The shell picks icons by section id (navIcon in ui-settings-general) and registration carries no icon field, so this decorates our own cell after render — matching the live value of our own label thunk so it follows the locale, mutating the existing <svg> rather than replacing React-managed nodes, and restoring the gear on disposal. Every failure path leaves the shipped icon. It is a DOM decoration and I will not be offended if you drop it; delete the file the day registration takes a glyph.

Testing

test/usage.spec.ts gains four cases: the plan tier on both payload shapes, its absence (missing and whitespace), the header parser across both forms, and the delay surviving to the caller.

Full suite: 196 passing, 0 failing (192 before this branch; the 7 cancelled / 6 skipped are pre-existing and unchanged).

One caveat on my verification: pnpm build runs tsc, and I could not get a clean typecheck locally because the published @deepseek-ai/* packages ship no .d.ts files — types points at paths that do not exist in the npm artifacts, so every import resolves to any. That is environmental and predates this branch. I built with tsdown (which strips types without checking them) and ran the suite through tsx. Please run a real tsc against your monorepo checkout before merging — if it flags anything in the new files, tell me and I will fix it.

Surface Claude subscription limits where they are spent — the composer —
and close two gaps that kept the reading approximate.

Composer meter (src/client/UsageMeter.tsx)

A ring in `conversation.input.right`, beside the shell's own ContextMeter
whose geometry and panel chrome it mirrors. It tracks the limit that
applies to the session's current model: a model with its own weekly limit
shows that one, everything else the shared weekly pool. Amber at 75% and
red at 95%, matching the Claude Code app's steps. It renders only while a
Claude model is selected, so it never reports a limit the next turn will
not spend.

The endpoint is aggressively rate limited AND shared with the settings
page, so the meter is deliberately frugal: one store serves every session
(the slot renders per session, so a fetcher per component would multiply
the rate), an idle session issues nothing, a running one asks every three
minutes, and hovering revalidates under the same floor. Stale-while-
revalidate throughout — a refusal never clears the last good reading, so
the panel degrades to slightly stale numbers rather than an error card.

The model gate polls `sessions.models` (a local RPC, no upstream traffic)
and additionally probes just after a pointer or Enter gesture, because a
model switch is always a gesture and waiting out a poll tick reads as lag.

Plan tier (src/providers/claude.ts)

The Claude usage payload names no tier — verified against the live
endpoint, whose keys carry no plan, tier, or multiplier field, unlike
codex's `plan_type`. The stored session already knows it, so the plan now
comes from `subscriptionType`; `ProviderUsage.plan` needed no change.

Retry-After (src/providers/common.ts, src/auth/rpc.ts)

`oauthEndpointError` dropped the header, so a 429 reached the page as bare
text and every caller could only guess at a backoff. It is parsed now —
both the seconds and HTTP-date forms, refusing past deadlines — and rides
`OAuthEndpointError.retryAfterMs`. The `internal` RpcResult branch types
`details` as an empty object upstream, so crossing the RPC boundary means
appending a parseable suffix to the message; the meter reads it and honours
the interval the provider actually asked for.

Also

- Settings meters adopt the shell's bar geometry (4px, fully rounded,
  filled track, no border) and the 75% warning step, so a plugin-drawn
  progress bar reads as the control the product draws elsewhere.
- A nav glyph for the Subscriptions section. The shell picks icons by
  section id and gives every unknown id the gear, and registration carries
  no icon field, so this decorates our own cell after render — matching on
  the live value of our own label thunk (so it follows the locale),
  mutating the existing svg rather than replacing React-managed nodes, and
  restoring the gear on disposal. Delete it the day registration takes a
  glyph.
- Tests cover the plan tier on both payload shapes, its absence, the
  header parser, and the delay surviving to the caller.
daveycodez pushed a commit to daveycodez/dsh-plugin-usage-meter that referenced this pull request Aug 26, 2026
Two corrections found while porting this meter upstream
(V1ki/dsh-plugin-subscriptions#41).

pickWindow tested for Fable by name, so the ring would have silently
fallen back to the shared weekly pool the day a limit is scoped to any
other model. It now compares the reported scope against the model id, so
it keeps working as the plan shape changes.

The retry hint now reads the `(retry-after: 300s)` suffix that PR adds to
the flattened RPC message — the only channel that survives a boundary
whose `details` is typed as an empty object upstream — so a 429 waits the
interval the provider actually named rather than a fixed guess. The looser
pattern stays for other phrasings, and an installation without that change
keeps the fixed backoff.

The README also corrects two claims: the Claude usage payload names no
tier anywhere (checked against the live endpoint, not assumed), and a
settings section CAN carry a custom nav glyph — not through registration,
which has no icon field, but by decorating the rendered cell, which is
what dsh-archived-chats does. Recorded in case this plugin ever grows a
settings page.
daveycodez pushed a commit to daveycodez/dsh-plugin-usage-meter that referenced this pull request Aug 26, 2026
The section belongs to dsh-plugin-subscriptions, which this plugin already
depends on for its usage data, so fixing its icon here needs no change to
that package and no fork of it.

The settings shell picks nav icons by section id and falls back to the gear
for every id it does not know; `settings.section` registration carries only
id, order, and label, and the rendered cell exposes no section id, so the
label is the only handle a decorator has. This matches the labels that
plugin ships in both locales, mutates the existing svg rather than
replacing React-managed nodes, and restores the gear on disposal.

It also skips any row already carrying the upstream patch's marker, so if
V1ki/dsh-plugin-subscriptions#41 lands and ships the same glyph, the two do
not fight over the node — this simply goes inert.
@V1ki

V1ki commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Thanks for putting this together. I'd like to discuss how this should fit into the current UI before merging it.

We've already merged a subscription usage readout in the status strip below the composer (SubscriptionUsageBadge, from #39). It displays usage for the default accounts of supported providers, including Claude, Codex, and Grok. Adding another meter beside the model selector would give Claude two usage displays, potentially with different refresh times and readings.

I'm also hesitant to introduce a dedicated Claude-only UI when the plugin supports usage reporting for other providers. A consistent entry point across providers would probably be easier for users to understand.

Could we discuss integrating the useful parts of this PR—such as the detailed limits panel, reset times, and plan name—into the existing status-strip display? A few things to agree on:

  • Whether to enhance the existing display with a click-to-expand panel or use another single, shared presentation.
  • Whether the summary should show all connected providers or focus on the current model's provider, and how to clearly identify the account being reported, especially with account pools.
  • How to share the existing backend usage cache so the settings page and composer stay consistent without unnecessary upstream requests.

The Claude plan-name improvement and HTTP-date support for Retry-After could also be handled separately while we settle the UI design. I'm open to your suggestions on the final interaction and layout; let's agree on the presentation and provider coverage first rather than adding a second Claude-specific meter as-is.

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