feat: remaining quota, account identity, control panel and management tools - #11
feat: remaining quota, account identity, control panel and management tools#11pocharlies wants to merge 4 commits into
Conversation
One OpenCode server can drive several Claude subscriptions at once, with each session pinned to one: this chat on `work`, that one on `personal`. Each account is a CLAUDE_CONFIG_DIR — a self-contained Claude CLI home with its own credentials, transcripts and settings. That shape is forced by the rotation constraint this codebase already documents in auth-login.ts: Anthropic rotates the refresh token on every use, and a chain with two owners gets the whole grant revoked for replay. Giving each account its own CLI home keeps exactly one owner per chain — the CLI — so accounts cannot race each other's rotation. The plugin reads those credentials and never rotates them. Selection rides the model id (`opus@personal`, named `Opus 5 · Personal`) so it flows through the existing chat.headers → EFFORT_HEADER path and appears in the host's model picker with no UI work. The first turn binds the session; later turns stay on that account even when the request carries none. Everything previously global is now keyed by account: - rate-limit state and its 429 fast-fail gate — an exhausted subscription used to block every other account, which defeats having them. Existing single-account stores migrate on read. - session bindings, and resume lookups against the owning account's transcript dir; moving accounts drops the stale resume target rather than continuing a foreign conversation. - the pre-flight credential probe and credential reads, which now refuse to fall back to the ambient ~/.claude for a scoped account — that would silently run the turn on the wrong subscription. Declaring no accounts leaves behaviour unchanged: same model ids, same stores, same everything. Configure via the `accounts` plugin option, OPENCODE_CLAUDE_ACCOUNTS, or accounts.json. Also isolates XDG_DATA_HOME for the test run. The suite was reading the operator's real accounts.json — so "nothing configured" stopped being true once they had accounts — writing fixture sessions into their store, and unlinking their debug.log.
… tools Everything built on top of multi-account support. Grouped into one PR because the pieces landed interleaved in the same files; each is described below and they can be taken separately if only some are wanted. **Remaining quota, from Anthropic.** Every Messages response carries `anthropic-ratelimit-unified-*` headers describing BOTH limit windows and naming which one binds. Probed against a live subscription: the five-hour window read 57% used while the seven-day window sat at 93% and was the representative claim — the Agent SDK's rate_limit_event reports one window at a time, so nothing could surface that. Harvested free from requests the plugin already makes (429s included, where the numbers matter most) and merged from SDK events during ordinary turns; an explicit refresh sends a minimal Messages call. `count_tokens` was the obvious free probe and returns zero rate-limit headers — verified, and noted in the code so nobody retries it. **Account identity.** `GET /api/oauth/profile` resolves which Claude login a token actually is. This matters because the OAuth consent screen is approved by whatever claude.ai session the browser already holds and never offers an account picker, so "connect a second account" routinely re-authorizes the first: separate grants, separate refresh chains, one quota pool. The check now runs between the token exchange and the disk write — the only point where refusing costs nothing — and holds the exchanged tokens for ten minutes so the operator can still commit them knowingly rather than repeat the browser round trip. **Control panel** at the proxy root: accounts, their logins, quota and usage, which account each session runs on, and add/connect/rename/remove. Self-contained (no external CSS, fonts or scripts) since the process holds subscription tokens. Mutating routes require a same-origin request. `OPENCODE_CLAUDE_PANEL_HOST` and `X-Forwarded-Prefix` support let it sit behind a reverse proxy; the automatic loopback OAuth callback is then offered only to browsers that arrived on 127.0.0.1, since the redirect would otherwise land on the operator's own machine. **Tools** `claude_accounts` and `claude_account_manage`, so accounts can be managed from a session instead of reaching the panel's port from whatever machine the operator is on. `OPENCODE_CLAUDE_TOOLS=0` removes them. `bun test` passes and `tsc` is clean.
The host groups the picker by provider, so putting every account's models in a single provider produced one flat list: twenty-four rows for four accounts, each row repeating the account label and truncating the quota that follows it. The grouping the UI already offers was going unused. Each account now declares its own provider — Claude Code · Personal — so the list becomes four labelled groups of six, and the model names inside carry only the quota since the group already says whose it is. The account is taken from the provider the model was picked from. The model@account form still resolves for anything pinned before this, and the default account keeps the bare claude-code id so single-account installs see no rename at all. enabled_providers needed handling: it is an allowlist, so an account provider missing from it is filtered out however well it is configured. Verified against the live server: four providers of six models each, and a real turn through claude-code-works-shared bound the session to that account.
|
Pushed one more commit: one provider per account in the model picker. The host groups the picker by provider, so putting every account's models in a Each account now declares its own provider, so the list reads as labelled Model names inside a group carry only the quota ( The account is taken from the provider the model was picked from. One thing worth flagging for review: Verified against a live server: four providers of six models each, and a real |
The picker group and the hover card read 'Claude Code · Current' — a label the operator chose, which does not answer the question being asked at that moment: which subscription is this about to spend? Labels cannot answer it. They are arbitrary, and they go stale precisely when it matters: re-log a Claude home to a different account and the label still names the old one. The email comes from the profile lookup and cannot drift. Appended to the provider name, skipped when the label already contains the address so it is not printed twice. The immediate payoff on a real setup: two accounts that had been connected as the same login now read 'Current · me@e-dani.com' and 'Personal · me@e-dani.com' side by side in the picker, which is where that mistake actually costs something.
|
One more: the provider name now carries the Claude login, not just the label. The host renders this under the model on hover and as the picker's group header Skipped when the label already contains the address, so it is not printed twice. The payoff shows in the example above: two accounts that had been connected as |
Builds on #10 — the diff shows that PR's commit too until it lands.
Grouped into one PR because these landed interleaved in the same files. Each
piece is independent in spirit; happy to split further if you want only some.
Remaining quota, from Anthropic
Every Messages response carries
anthropic-ratelimit-unified-*headersdescribing both limit windows and naming which one binds. Probed against a
live subscription: the five-hour window read 57% used while the seven-day window
sat at 93% and was the
representative-claim. The Agent SDK'srate_limit_eventreports one window at a time, so nothing here could surfacethat — and a healthy-looking 5h window is exactly what misleads you.
Harvested for free from requests the plugin already makes (429s included, where
the numbers matter most) and merged — not replaced — from SDK events during
ordinary turns, since an event carries a single window. An explicit refresh
sends a minimal Messages call.
count_tokenswas the obvious free probe andreturns zero rate-limit headers; verified, and noted in the code so nobody
retries it.
Surfaced in the model name, the in-stream rate-limit note, the 429 body and
/health, as percent left rather than percent used.Account identity
GET /api/oauth/profileresolves which Claude login a token actually is. Thismatters more than it sounds: the OAuth consent screen is approved by whatever
claude.ai session the browser already holds and never offers an account
picker, so "connect a second account" routinely re-authorizes the first —
separate grants, separate refresh chains, one quota pool, and every check short
of asking says two accounts.
The duplicate check runs between the token exchange and the disk write, the only
point where refusing costs nothing, and holds the exchanged tokens for ten
minutes so the operator can commit them knowingly instead of repeating the
browser round trip for a single-use code.
Control panel
At the proxy root: accounts, logins, quota, usage, which account each session
runs on, and add/connect/rename/remove. Self-contained — no external CSS, fonts
or scripts, since this process holds subscription tokens — and mutating routes
require a same-origin request.
OPENCODE_CLAUDE_PANEL_HOSTandX-Forwarded-Prefixlet it sit behind a reverse proxy; the automatic loopbackOAuth callback is then offered only to browsers that arrived on
127.0.0.1,because the redirect would otherwise land on the operator's own machine.
This is the most opinionated part — if you would rather not carry a web UI in
the plugin, the quota and identity work stands without it.
Tools
claude_accountsandclaude_account_manage, so accounts can be managed from asession rather than reaching the panel's port from whatever machine you are on.
OPENCODE_CLAUDE_TOOLS=0removes them.bun testpasses andtscis clean.