feat: pasted setup-token accounts with automatic rotation on rate limits - #269
feat: pasted setup-token accounts with automatic rotation on rate limits#269psalkowski wants to merge 2 commits into
Conversation
Adds long-lived `claude setup-token` values as a credential source and rotates off a rate-limited account onto the next healthy one. Also fixes an undefined `hint` in the account picker that made GET /provider/auth return 500, taking the TUI's /connect offline.
Greptile SummaryThe PR adds static setup-token accounts and automatic account rotation after rate limits, along with credential-store and logging hardening.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; current HEAD addresses the previously reported session-token persistence, unusable rotation-target, and temporary-file symlink issues.
|
| Filename | Overview |
|---|---|
| src/token-store.ts | Adds validation, environment and session token sources, secure persistence, and failed-write session fallback. |
| src/credentials.ts | Integrates static credentials and validates rotation candidates before persisting an active-account switch. |
| src/index.ts | Adds token-management authentication methods and bounded API-request rotation while excluding long-context rate limits. |
| src/rotation.ts | Implements cooldown parsing, bench persistence, account ordering, and candidate selection. |
| src/keychain.ts | Combines discovered Claude accounts with static token accounts while preserving credential usability semantics. |
| src/logger.ts | Extends value-based secret redaction to Anthropic token formats. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Credential sources] --> B[Build account roster]
B --> C[Select active healthy account]
C --> D[Send Anthropic API request]
D -->|Success| E[Clear account bench]
D -->|Rate limited| F[Bench current account]
F --> G{Usable untried candidate?}
G -->|Yes| H[Load candidate credentials]
H -->|Usable| I[Persist active source]
I --> D
H -->|Unusable| G
G -->|No| J[Restore prior source and return rate limit]
Reviews (2): Last reviewed commit: "fix: address review findings on token pe..." | Re-trigger Greptile
- Tokens accepted when the store write fails are held in memory for the session, instead of being silently dropped while the flow reported they applied to this session. - Rotation persists a target only after it produces usable credentials, skipping candidates that cannot and restoring the previous account when none can. Committing first stranded the session, and later ones via the state file, on a broken account. - Token and rotation state files are written with exclusive creation, so an env-configured path in a shared directory cannot be redirected through a pre-created symlink.
|
Thanks — all three P1s were valid. Fixed in 313b04f. 1. Unpersisted tokens are discarded ( Correct, and the flow actively lied about it: it reported "tokens apply to this session only" while Fixed by giving the store a session-only tier. Tokens accepted when the write fails are held in memory and surfaced by 2. Unusable rotation target is committed ( Also correct, and worse than described: the account was persisted, so the strand outlived the process. Rotating off a rate-limited-but-authenticating account onto a broken one is a net loss. Candidate selection is now a loop. Each candidate is made active only to probe it — 3. Temporary write follows symlinks ( Valid. It needs the configured path to sit in a directory another local user can write, so it's misconfiguration-gated rather than reachable by default — but the payload is a bearer token, and the fix is three lines, so it's worth closing regardless. Both state writers now use Testing
One note for anyone else running the suite locally: |
Summary
Two related additions for people with more than one Claude subscription, plus one bug fix found along the way.
1. Pasted
claude setup-tokenvalues as a credential source. Today multiple accounts means multiple Claude Code logins in the Keychain.claude setup-tokenalready mints exactly what's needed — a long-lived, inference-only OAuth token — so this accepts them directly viaopencode auth login("Add Claude token"), several at once, plusOPENCODE_CLAUDE_AUTH_TOKENS/CLAUDE_CODE_OAUTH_TOKENfor headless use. Works with no Keychain and no Claude Code login on the machine.These tokens carry no refresh token, so they're modelled as
kind: "static"rather than as OAuth credentials with an empty refresh token. That distinction is load-bearing: every refresh site decides what to do from expiry plus refresh-token presence, so an empty refresh token is indistinguishable from a credential that failed to parse — which routes intoclaudeCLI spawns and cross-account borrowing on every cache miss.isCredentialUsable()treats static credentials as always usable andrefreshIfNeeded()short-circuits them before any refresh machinery runs.2. Automatic rotation on rate limits.
fetchWithRetryhandles transient 429s andsrc/index.tsalready re-reads the source in case an external tool rotated the credential — but nothing switches accounts on its own, so a session that exhausts its account stays exhausted even with healthy accounts available. This benches the limited account and retries on the next healthy one in priority order, within the same request.Cooldown length comes from
retry-after, thenanthropic-ratelimit-unified-*-reset, then a short default. Two bounds are deliberate: an unexplained 429 gets only 60s (it's more likely a burst limit than an exhausted subscription, and writing an account off for hours on that evidence would be wrong), and every bench is capped at 6h (a bench is only "when to reconsider", so capping costs at most one extra 429 whereas an uncapped weekly reset would park an account for days). Benches persist across restarts and clear as soon as an account serves a request again.Rotation policy lives in
src/rotation.tsas pure bookkeeping over source strings, and deliberately does not decide when to rotate.fetchWithRetryis shared with the OAuth token endpoint, and a 429 from that must never bench a subscription — the same reasoning that keptspecs/2026-07-29-external-credential-rotation-design.mdout ofhttp.ts. The trigger stays at the one call site insrc/index.tsthat can see an API response.Ordering there is load-bearing: after the existing external-switch check (if another process already moved us to a healthy account, that costs no cooldown), and before the long-context beta loop but with long-context 429s excluded explicitly rather than by ordering — that error is a header problem every account shares, so rotating around it would bench the whole pool for a fault no account can avoid, and reaching the beta loop first would mean the account was already benched by then.
Notification goes through
client.tui.showToast, notconsole.warn— the latter draws over the TUI, which is why API errors were moved off it in 2.0.1 and why a test asserts a quota 429 prints nothing.3. Bug fix:
hint: undefinedin the account picker returned HTTP 500 fromGET /provider/auth.The schema requires the key to be absent, not undefined. This is latent on
maintoday — it only triggers when the first listed account isn't the active one, which is uncommon there but routine once rotation exists or pasted tokens (sorted last) are active. Since that endpoint backs the TUI's/connect, the symptom is that switching accounts inside OpenCode fails with an opaque 500.Worth noting for the existing test suite:
buildSelectOptionsinsrc/index.test.tsreimplemented the option builder and asserted the broken shape, so it couldn't catch this. Replaced with tests that read the real plugin'sauth.methodsprompts.Also redacts
sk-ant-*values in the debug log. Redaction covered JWT-shaped values and three key names, butsk-ant-oat…isn't JWT-shaped, so a token logged under any other key could reach a file the README describes as safe to attach to an issue.Everything is additive — pasted tokens come after discovered accounts, and rotation is disableable with
OPENCODE_CLAUDE_AUTH_ROTATE=0. No behaviour changes if you never paste a token and never hit a rate limit. Design notes inspecs/2026-08-06-multi-token-rotation-design.md.Related issue
None — happy to open one first if you'd prefer to discuss the approach before reviewing the diff.
Testing
make allgreen: lint 0 errors (4 warnings, all pre-existing onmain), build clean, 419/419 tests pass.One environment caveat worth flagging since it may bite others:
ANTHROPIC_CLI_VERSION overrides version in billing headerfails whenCLAUDE_CODE_ENTRYPOINTis set in the shell —transforms.ts:358reads it and defaults tosdk-cli, so running the suite from inside Claude Code (which sets it tocli) fails that assertion. It passes with the variable unset, and I confirmed the same failure on unmodifiedmainat the same base commit, so it's unrelated to this PR. Happy to add an env guard to that test in a separate PR if useful.New tests:
src/token-store.test.ts(39) — multi-token paste parsing, dedupe, partial-invalid input, futureoatversions, validation messages that never echo the secret, content-derived ids, store CRUD,0600permissions, env-token precedence, and resilience to malformed or partially broken files.src/rotation.test.ts(40) — cooldown derivation from each signal and the cap, bench persistence and the never-shorten rule, ordering and candidate selection, initial-account choice stepping over a benched selection, disabled-rotation behaviour.src/index.test.ts(+8) — integration: a 429 rotating onto the next account with the retry carrying that account's token; rotating onto a pasted token; the limit surfacing once every account is exhausted (bounded, no loop); no rotation when disabled; no rotation on a long-context 429; configured order deciding both start and target; plus two schema-compatibility tests for thehintfix, which I verified fail without it.Existing harnesses in
credentials.test.ts/index.test.ts/keychain.test.tsneeded the two new modules added to their temp-dir copies, and I pointed the new state files at temp paths so no test can touch real user state.Verified end to end against a running server (
opencode serve+GET /provider/auth→ 200, all three auth methods serialising correctly) and in a live OpenCode session on macOS with two Keychain accounts and two pasted tokens.Checklist
feat:,fix:,docs:,chore:, etc.)make allpasses locally (runs lint, build, and test)Happy to split this into separate PRs — the
hintfix stands alone and is worth landing regardless of the rest, and the token source and rotation are independent of each other. Say the word and I'll break it up.