Skip to content

feat: remaining quota, account identity, control panel and management tools - #11

Open
pocharlies wants to merge 4 commits into
otto-assistant:mainfrom
pocharlies:feat/subscription-quota
Open

feat: remaining quota, account identity, control panel and management tools#11
pocharlies wants to merge 4 commits into
otto-assistant:mainfrom
pocharlies:feat/subscription-quota

Conversation

@pocharlies

Copy link
Copy Markdown

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-* 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 here could surface
that — 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_tokens was the obvious free probe and
returns 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/profile resolves which Claude login a token actually is. This
matters 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_HOST and
X-Forwarded-Prefix 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,
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_accounts and claude_account_manage, so accounts can be managed from a
session rather than reaching the panel's port from whatever machine you are on.
OPENCODE_CLAUDE_TOOLS=0 removes them.


bun test passes and tsc is clean.

dibanez added 3 commits August 15, 2026 19:56
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.
@pocharlies

Copy link
Copy Markdown
Author

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
single provider produced one flat list — twenty-four rows for four accounts,
each repeating the account label and truncating the quota that followed it. The
grouping the UI already offers was going unused.

Each account now declares its own provider, so the list reads as labelled
groups:

Claude Code · Current            claude-code                6 models
Claude Code · Personal           claude-code-personal       6 models
Claude Code · Work personal      claude-code-tercera        6 models
Claude Code · Works Shared       claude-code-works-shared   6 models

Model names inside a group carry only the quota (Opus 5 · 5h 94% · 7d 15%),
since the group already says whose it is.

The account is taken from the provider the model was picked from.
model@account still resolves for anything pinned earlier, and the default
account keeps the bare claude-code id — single-account installs see no rename
at all.

One thing worth flagging for review: enabled_providers is an allowlist, so an
account provider missing from it is filtered out however well it is configured.
The config hook adds them, but only when claude-code is already listed —
it never enables the plugin in a config that had not opted in.

Verified against a live server: four providers of six models each, and a real
turn through claude-code-works-shared bound the session to that account.
bun test passes and tsc is clean.

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.
@pocharlies

Copy link
Copy Markdown
Author

One more: the provider name now carries the Claude login, not just the label.

Claude Code · Current · me@e-dani.com
Claude Code · Personal · me@e-dani.com
Claude Code · Work personal · someone@company.com

The host renders this under the model on hover and as the picker's group header
— the one moment where "which subscription is this about to spend" can still be
answered. A label alone cannot answer it: labels are operator-chosen, and they
go stale precisely when it matters, since re-logging a Claude home to a
different account leaves the old label in place. The email comes from the
profile lookup and cannot drift.

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
the same login now sit side by side in the picker saying so, which is where
that mistake actually costs something.

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.

1 participant