opencode-anthropic-fix 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -103,7 +103,7 @@ async function promptManageAccounts(accountManager) {
103
103
  // Request building helpers (extracted from original fetch interceptor)
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.68";
106
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.76";
107
107
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
108
108
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
109
109
  const BILLING_HASH_SALT = "59cf53e54c78";
@@ -292,7 +292,10 @@ function supportsThinking(model) {
292
292
  */
293
293
  function isOpus46Model(model) {
294
294
  if (!model) return false;
295
- return /claude-opus-4[._-]6/i.test(model);
295
+ // Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
296
+ // (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
297
+ // Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
298
+ return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
296
299
  }
297
300
 
298
301
  /**
@@ -300,7 +303,8 @@ function isOpus46Model(model) {
300
303
  * @returns {boolean}
301
304
  */
302
305
  function hasOneMillionContext(model) {
303
- return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
306
+ // Models with explicit 1m suffix, or Opus 4.6 (1M by default since v2.1.75).
307
+ return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model) || isOpus46Model(model);
304
308
  }
305
309
 
306
310
  /**
@@ -732,7 +736,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
732
736
  return mergedBetas.join(",");
733
737
  }
734
738
 
735
- /** @typedef {'low' | 'medium' | 'high' | 'max'} ThinkingEffort */
739
+ /** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
736
740
 
737
741
  /**
738
742
  * Map budgetTokens to an adaptive thinking effort level.
@@ -743,8 +747,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
743
747
  function budgetTokensToEffort(budgetTokens) {
744
748
  if (budgetTokens <= 1024) return "low";
745
749
  if (budgetTokens <= 8000) return "medium";
746
- if (budgetTokens <= 16000) return "high";
747
- return "max";
750
+ return "high";
748
751
  }
749
752
 
750
753
  /**
@@ -753,7 +756,7 @@ function budgetTokensToEffort(budgetTokens) {
753
756
  * @returns {value is ThinkingEffort}
754
757
  */
755
758
  function isValidEffort(value) {
756
- return value === "low" || value === "medium" || value === "high" || value === "max";
759
+ return value === "low" || value === "medium" || value === "high";
757
760
  }
758
761
 
759
762
  /**
@@ -1427,10 +1430,14 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
1427
1430
  * @param {import('./lib/accounts.mjs').ManagedAccount} account
1428
1431
  * @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
1429
1432
  * @param {"foreground" | "idle"} [source]
1433
+ * @param {{ onTokensUpdated?: () => Promise<void> }} [options] - If provided,
1434
+ * called under the cross-process lock after token update to persist rotated
1435
+ * tokens before the lock is released. Omitting means tokens won't be saved
1436
+ * to disk until the caller arranges it (risking the rotation race).
1430
1437
  * @returns {Promise<string>} The new access token
1431
1438
  * @throws {Error} If refresh fails
1432
1439
  */
1433
- async function refreshAccountToken(account, client, source = "foreground") {
1440
+ async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
1434
1441
  const lockResult = await acquireRefreshLock(account.id, {
1435
1442
  timeoutMs: 2_000,
1436
1443
  backoffMs: 60,
@@ -1471,6 +1478,22 @@ async function refreshAccountToken(account, client, source = "foreground") {
1471
1478
  }
1472
1479
  markTokenStateUpdated(account);
1473
1480
 
1481
+ // Persist new tokens to disk BEFORE releasing the cross-process lock.
1482
+ // This is critical: if we release the lock first, another process can
1483
+ // acquire it and read the old (now-rotated) refresh token from disk,
1484
+ // leading to an invalid_grant failure. The debounced requestSaveToDisk()
1485
+ // that callers used previously left a ~1 s window where this race could
1486
+ // (and did) happen.
1487
+ if (onTokensUpdated) {
1488
+ try {
1489
+ await onTokensUpdated();
1490
+ } catch {
1491
+ // Best-effort: in-memory tokens remain valid for this process.
1492
+ // The callback is responsible for scheduling its own fallback
1493
+ // (e.g. a debounced retry) if the synchronous save fails.
1494
+ }
1495
+ }
1496
+
1474
1497
  // Also persist to OpenCode's auth.json for compatibility.
1475
1498
  // This should be best-effort: a persistence hiccup should not invalidate an
1476
1499
  // otherwise successful refresh token exchange.
@@ -2011,7 +2034,21 @@ export async function AnthropicAuthPlugin({ client }) {
2011
2034
  const entry = { source, promise: Promise.resolve("") };
2012
2035
  const p = (async () => {
2013
2036
  try {
2014
- return await refreshAccountToken(account, client, source);
2037
+ return await refreshAccountToken(account, client, source, {
2038
+ onTokensUpdated: async () => {
2039
+ try {
2040
+ await accountManager.saveToDisk();
2041
+ } catch {
2042
+ // Synchronous save failed (disk full, permissions, etc.).
2043
+ // Schedule a debounced retry so the rotated token eventually
2044
+ // reaches disk. Another process may hit invalid_grant in the
2045
+ // interim, but its retry-from-disk logic can recover once this
2046
+ // save lands.
2047
+ accountManager.requestSaveToDisk();
2048
+ throw new Error("save failed, debounced retry scheduled");
2049
+ }
2050
+ },
2051
+ });
2015
2052
  } finally {
2016
2053
  if (refreshInFlight.get(key) === entry) {
2017
2054
  refreshInFlight.delete(key);
@@ -2040,7 +2077,6 @@ export async function AnthropicAuthPlugin({ client }) {
2040
2077
  try {
2041
2078
  try {
2042
2079
  await refreshAccountTokenSingleFlight(account, "idle");
2043
- accountManager.requestSaveToDisk();
2044
2080
  return;
2045
2081
  } catch (err) {
2046
2082
  let details = parseRefreshFailure(err);
@@ -2068,7 +2104,6 @@ export async function AnthropicAuthPlugin({ client }) {
2068
2104
 
2069
2105
  try {
2070
2106
  await refreshAccountTokenSingleFlight(account, "idle");
2071
- accountManager.requestSaveToDisk();
2072
2107
  return;
2073
2108
  } catch (retryErr) {
2074
2109
  details = parseRefreshFailure(retryErr);
@@ -2252,8 +2287,8 @@ export async function AnthropicAuthPlugin({ client }) {
2252
2287
  const attemptedRefreshToken = account.refreshToken;
2253
2288
  try {
2254
2289
  accessToken = await refreshAccountTokenSingleFlight(account);
2255
- // Persist updated tokens (especially if refresh token rotated)
2256
- accountManager.requestSaveToDisk();
2290
+ // Tokens are now saved under the refresh lock (inside
2291
+ // refreshAccountToken) so no debounced save needed here.
2257
2292
  } catch (err) {
2258
2293
  // Token refresh failed — check if another instance rotated the
2259
2294
  // refresh token and persisted it between attempts.
@@ -2287,7 +2322,6 @@ export async function AnthropicAuthPlugin({ client }) {
2287
2322
 
2288
2323
  try {
2289
2324
  accessToken = await refreshAccountTokenSingleFlight(account);
2290
- accountManager.requestSaveToDisk();
2291
2325
  } catch (retryErr) {
2292
2326
  finalError = retryErr;
2293
2327
  details = parseRefreshFailure(retryErr);
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "main": "./index.mjs",
5
+ "files": [
6
+ "index.mjs",
7
+ "lib/**/*.mjs",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
5
11
  "scripts": {
6
12
  "build": "node scripts/build.mjs",
7
13
  "test": "vitest run",
@@ -1,31 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- workflow_dispatch:
5
- inputs:
6
- bump:
7
- description: "Version bump type"
8
- required: false
9
- default: "patch"
10
-
11
- permissions:
12
- id-token: write
13
- contents: read
14
-
15
- jobs:
16
- publish:
17
- runs-on: ubuntu-latest
18
- steps:
19
- - uses: actions/checkout@v4
20
-
21
- - uses: actions/setup-node@v4
22
- with:
23
- node-version: "24"
24
- registry-url: "https://registry.npmjs.org"
25
-
26
- - run: npm install
27
-
28
- - run: npm publish --access public
29
- env:
30
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
31
- NPM_CONFIG_PROVENANCE: false
package/.husky/pre-commit DELETED
@@ -1,2 +0,0 @@
1
- npm test
2
- npx lint-staged
package/.husky/pre-push DELETED
@@ -1,3 +0,0 @@
1
- npm test
2
- npx prettier --check .
3
- npx eslint .
@@ -1,221 +0,0 @@
1
- # CLI Enhancements: Full Account Management
2
-
3
- **Status:** Complete
4
- **Created:** 2026-02-09
5
- **Completed:** 2026-02-09
6
-
7
- ---
8
-
9
- ## Problem Statement
10
-
11
- The plugin CLI (`opencode-anthropic-auth`) can **manage** existing accounts (enable, disable, remove, switch, reset) but cannot **authenticate** new ones or re-authenticate broken ones. The only way to add an account is through `opencode auth login` → selecting "Claude Pro/Max" → browser OAuth flow inside the OpenCode TUI.
12
-
13
- This creates friction in several scenarios:
14
-
15
- 1. **Disabled account recovery** — Account #1 was disabled due to a token refresh failure. To fix it, you'd have to `remove` it via CLI, then re-login via `opencode auth login`, which is the exact workflow we want to avoid.
16
- 2. **No re-authentication** — There's no way to get fresh tokens for an existing broken account without deleting and re-adding it.
17
- 3. **No proper logout** — The `remove` command just deletes local data without revoking tokens server-side.
18
- 4. **Incomplete standalone tool** — The CLI should be a self-contained account management tool that doesn't depend on the OpenCode TUI for any operation.
19
-
20
- ## Solution Overview
21
-
22
- Add four new commands to `cli.mjs` that give full account lifecycle management:
23
-
24
- | Command | Alias | Description |
25
- | -------------- | ----- | -------------------------------------------------- |
26
- | `login` | `ln` | Add a new account via browser OAuth flow |
27
- | `logout <N>` | `lo` | Revoke tokens + remove a specific account |
28
- | `logout --all` | — | Revoke all tokens + clear all accounts |
29
- | `reauth <N>` | `ra` | Re-authenticate an account with fresh OAuth tokens |
30
- | `refresh <N>` | `rf` | Attempt token refresh without browser (quick fix) |
31
-
32
- ### Architecture Change
33
-
34
- Extract the OAuth `authorize()` and `exchange()` functions from `index.mjs` into a new shared module `lib/oauth.mjs`, plus add a new `revoke()` function. Both `index.mjs` (plugin) and `cli.mjs` (standalone) import from this shared module.
35
-
36
- ```
37
- lib/oauth.mjs (NEW)
38
- ├── authorize(mode) — Generate PKCE + build auth URL
39
- ├── exchange(code, verifier) — Exchange auth code for tokens
40
- └── revoke(refreshToken) — Revoke a refresh token (best-effort)
41
- ```
42
-
43
- ## Design Decisions
44
-
45
- | Decision | Choice | Rationale |
46
- | --------------------- | -------------------- | ----------------------------------------------------------------------- |
47
- | Reauth matching | Replace at index | Simpler UX; user explicitly chooses which slot to replace |
48
- | Logout behavior | Revoke + remove | Clean break; user can re-add via `login` |
49
- | Token refresh command | Yes, add `refresh` | Quick fix for expired access tokens without browser round-trip |
50
- | Browser opening | Auto-open + show URL | Best UX: tries `open`/`xdg-open`/`start`, always prints URL as fallback |
51
-
52
- ## Detailed Command Specifications
53
-
54
- ### `login` (alias: `ln`)
55
-
56
- **Flow:**
57
-
58
- 1. Call `authorize("max")` to get PKCE URL + verifier
59
- 2. Auto-open the URL in the user's default browser
60
- 3. Print the URL to terminal as fallback
61
- 4. Prompt user to paste the authorization code (readline)
62
- 5. Call `exchange(code, verifier)` to get tokens
63
- 6. Deduplicate: check if an account with the same refresh token already exists
64
- 7. Add the account to `anthropic-accounts.json`, save, print confirmation
65
-
66
- **Edge cases:**
67
-
68
- - If exchange fails, print error and exit with code 1
69
- - If max accounts (10) reached, print error
70
- - If duplicate refresh token, update existing account instead of adding
71
-
72
- ### `logout <N>` (alias: `lo <N>`)
73
-
74
- **Flow:**
75
-
76
- 1. Validate index N exists
77
- 2. Attempt `revoke(account.refreshToken)` — best-effort, won't block on failure
78
- 3. Remove the account from array (splice)
79
- 4. Adjust `activeIndex` if needed (same logic as existing `remove` command)
80
- 5. Save + print confirmation
81
-
82
- **Flags:**
83
-
84
- - `--force` — Skip confirmation prompt (same as `remove`)
85
- - `--all` — Logout all accounts
86
-
87
- ### `logout --all`
88
-
89
- **Flow:**
90
-
91
- 1. Iterate all accounts, attempt `revoke()` for each
92
- 2. Write explicit empty storage `{ version: 1, accounts: [], activeIndex: 0 }`
93
- 3. Print confirmation with count of revoked accounts
94
-
95
- **Important behavior:** empty storage is treated as authoritative by `AccountManager.load()`, so plugin instances stay logged out and do not re-bootstrap from fallback auth when the accounts file explicitly contains zero accounts.
96
-
97
- ### `reauth <N>` (alias: `ra <N>`)
98
-
99
- **Flow:**
100
-
101
- 1. Validate index N exists
102
- 2. Run full OAuth flow (same as `login` steps 1-5)
103
- 3. Replace the account at index N with the new tokens
104
- 4. Preserve: `id`, `addedAt`, `stats`
105
- 5. Reset: `enabled=true`, `consecutiveFailures=0`, `lastFailureTime=null`, `rateLimitResetTimes={}`
106
- 6. Save + print confirmation
107
-
108
- **Key detail:** Replaces whatever's at index N with whatever the user logs in with, regardless of email matching.
109
-
110
- ### `refresh <N>` (alias: `rf <N>`)
111
-
112
- **Flow:**
113
-
114
- 1. Validate index N exists
115
- 2. Call the existing `refreshAccessToken(account)` function
116
- 3. If success: update account, re-enable if disabled, save + print new token expiry
117
- 4. If failure: print error and suggest `reauth <N>` instead
118
-
119
- ## Files Modified
120
-
121
- | File | Change | Status |
122
- | ------------------- | -------------------------------------------------------------------- | ------ |
123
- | `lib/oauth.mjs` | **NEW** — Extracted `authorize()`, `exchange()`, plus new `revoke()` | Done |
124
- | `index.mjs` | Replace inline OAuth functions with imports from `lib/oauth.mjs` | Done |
125
- | `cli.mjs` | Add 5 new commands + browser opener utility | Done |
126
- | `scripts/build.mjs` | No change needed — esbuild follows imports automatically | N/A |
127
-
128
- ## Task List
129
-
130
- - [x] **Task 1:** Create `lib/oauth.mjs` — extract `authorize()` and `exchange()` from `index.mjs`, add `revoke()`
131
- - [x] **Task 2:** Update `index.mjs` — replace inline functions with imports from `lib/oauth.mjs`
132
- - [x] **Task 3:** Add `openBrowser()` utility to `cli.mjs`
133
- - [x] **Task 4:** Implement `cmdLogin` in `cli.mjs`
134
- - [x] **Task 5:** Implement `cmdLogout` and `cmdLogoutAll` in `cli.mjs`
135
- - [x] **Task 6:** Implement `cmdReauth` in `cli.mjs`
136
- - [x] **Task 7:** Implement `cmdRefresh` in `cli.mjs`
137
- - [x] **Task 8:** Wire new commands into `main()` dispatcher and update `cmdHelp()`
138
- - [x] **Task 9:** Build — passes (esbuild picks up `lib/oauth.mjs` automatically)
139
- - [x] **Task 10:** Tests — all 349 tests pass across 7 test files, zero failures
140
- - [x] **Task 11:** Lint — eslint passes cleanly
141
- - [x] **Task 12:** Live verification — `refresh 1` correctly fails (broken token), `refresh 2` succeeds (7h 59m expiry)
142
- - [x] **Task 13:** Update this document with completion status
143
-
144
- ## Token Revocation Notes
145
-
146
- Anthropic's OAuth implementation may or may not support RFC 7009 token revocation at `POST /v1/oauth/revoke`. The `revoke()` function is implemented as best-effort:
147
-
148
- - Attempts `POST https://console.anthropic.com/v1/oauth/revoke` with `{ token: refreshToken, token_type_hint: "refresh_token", client_id: CLIENT_ID }`
149
- - Returns `true` if 2xx response, `false` otherwise
150
- - `logout` proceeds with local cleanup regardless of revocation result
151
- - No error is surfaced to the user if revocation fails — only a dim note
152
-
153
- ## Immediate Use Case
154
-
155
- To fix the disabled account #1:
156
-
157
- ```bash
158
- # Option A: Try a token refresh first (no browser)
159
- opencode-anthropic-auth refresh 1
160
- # Tested: correctly fails with "token refresh failed" and suggests reauth
161
-
162
- # Option B: If refresh fails, do a full re-auth
163
- opencode-anthropic-auth reauth 1
164
-
165
- # Option C: Full nuke and re-login
166
- opencode-anthropic-auth logout 1
167
- opencode-anthropic-auth login
168
- ```
169
-
170
- **Verified:** `refresh 2` (healthy account) succeeds and reports "Token refreshed, expires in 7h 59m".
171
-
172
- ## Updated Help Text (Preview)
173
-
174
- ```
175
- Anthropic Multi-Account Auth CLI
176
-
177
- Usage:
178
- opencode-anthropic-auth [command] [args]
179
-
180
- Commands:
181
- list Show all accounts with status (default)
182
- status Compact one-liner for scripts/prompts
183
- login Add a new account via browser OAuth flow
184
- logout <N> Revoke tokens and remove account N
185
- logout --all Revoke all tokens and clear all accounts
186
- reauth <N> Re-authenticate account N with fresh OAuth tokens
187
- refresh <N> Attempt token refresh (no browser needed)
188
- switch <N> Set account N as active
189
- enable <N> Enable a disabled account
190
- disable <N> Disable an account (skipped in rotation)
191
- remove <N> Remove an account permanently
192
- reset <N|all> Clear rate-limit / failure tracking
193
- stats Show per-account usage statistics
194
- reset-stats [N|all] Reset usage statistics
195
- strategy [name] Show or change selection strategy
196
- config Show configuration and file paths
197
- manage Interactive account management menu
198
- help Show this help message
199
- ```
200
-
201
- ## Post-Review Remediations
202
-
203
- Following delegated code review, the following fixes were applied:
204
-
205
- 1. **Logout-all semantics hardened**
206
- - `cmdLogoutAll` now writes explicit empty account storage instead of deleting the accounts file.
207
- - `AccountManager.load` now treats existing storage (including empty storage) as authoritative, preventing fallback re-bootstrap when users explicitly logout-all.
208
-
209
- 2. **Windows browser launch fixed**
210
- - `openBrowser` now uses `cmd /c start "" <url>` on Windows for correct URL handling.
211
-
212
- 3. **Reauth state message bug fixed**
213
- - `cmdReauth` now captures prior disabled state (`wasDisabled`) before mutation and correctly prints "re-enabled" message when applicable.
214
-
215
- 4. **Login dedupe ordering corrected**
216
- - `cmdLogin` now performs duplicate-token update path before applying max-account rejection, so existing accounts can still be refreshed when at capacity.
217
-
218
- 5. **Unit test coverage expanded for new auth commands**
219
- - Added direct CLI unit tests for `cmdLogin`, `cmdLogout` (single + `--all`), `cmdReauth`, and `cmdRefresh`.
220
- - Added main routing tests for new aliases/paths: `ln`, `lo --all --force`, `ra`, and `rf`.
221
- - Added help text assertions for new commands/examples.
@@ -1,201 +0,0 @@
1
- # Architecture
2
-
3
- **Analysis Date:** 2026-02-07
4
-
5
- ## Pattern Overview
6
-
7
- **Overall:** Plugin + CLI dual-entry-point architecture with shared library layer
8
-
9
- **Key Characteristics:**
10
-
11
- - OpenCode plugin providing OAuth-based multi-account Anthropic authentication
12
- - Standalone CLI binary for account management (shares storage layer with plugin)
13
- - Fetch interceptor pattern: the plugin wraps HTTP requests to Anthropic API, injecting OAuth tokens, transforming request/response bodies, and rotating accounts on failures
14
- - Stateful account management with disk persistence and in-memory health tracking
15
- - No framework — pure Node.js ESM modules with zero runtime dependencies beyond `@openauthjs/openauth`
16
-
17
- ## Layers
18
-
19
- **Plugin Entry (`index.mjs`):**
20
-
21
- - Purpose: OpenCode plugin that intercepts Anthropic API calls with OAuth multi-account support
22
- - Location: `index.mjs`
23
- - Contains: OAuth authorize/exchange helpers, request/response transform functions, `AnthropicAuthPlugin` factory, token refresh logic, CLI-style interactive prompts for auth flow
24
- - Depends on: `lib/accounts.mjs`, `lib/config.mjs`, `lib/storage.mjs`, `lib/backoff.mjs`, `@openauthjs/openauth`
25
- - Used by: OpenCode runtime (loaded as plugin)
26
-
27
- **CLI Entry (`cli.mjs`):**
28
-
29
- - Purpose: Standalone CLI for managing accounts outside of the OpenCode TUI
30
- - Location: `cli.mjs`
31
- - Contains: All CLI commands (list, switch, enable, disable, remove, reset, stats, strategy, config, manage), formatting/color helpers, usage quota fetching
32
- - Depends on: `lib/storage.mjs`, `lib/config.mjs`
33
- - Used by: End users via `opencode-anthropic-auth` binary
34
-
35
- **Account Management (`lib/accounts.mjs`):**
36
-
37
- - Purpose: In-memory account pool with health scoring, rotation, and disk sync
38
- - Location: `lib/accounts.mjs`
39
- - Contains: `AccountManager` class — the core runtime coordinator for account selection, failure tracking, rate limit handling, and usage statistics
40
- - Depends on: `lib/storage.mjs`, `lib/rotation.mjs`, `lib/backoff.mjs`
41
- - Used by: `index.mjs`
42
-
43
- **Rotation Logic (`lib/rotation.mjs`):**
44
-
45
- - Purpose: Account selection algorithms and health/token tracking
46
- - Location: `lib/rotation.mjs`
47
- - Contains: `HealthScoreTracker` class, `TokenBucketTracker` class, `selectAccount()` function implementing sticky/round-robin/hybrid strategies
48
- - Depends on: `lib/config.mjs` (for defaults only)
49
- - Used by: `lib/accounts.mjs`
50
-
51
- **Backoff Logic (`lib/backoff.mjs`):**
52
-
53
- - Purpose: Error classification and retry backoff calculation
54
- - Location: `lib/backoff.mjs`
55
- - Contains: `isAccountSpecificError()`, `parseRateLimitReason()`, `calculateBackoffMs()`, `parseRetryAfterHeader()`
56
- - Depends on: Nothing
57
- - Used by: `index.mjs`, `lib/accounts.mjs`
58
-
59
- **Configuration (`lib/config.mjs`):**
60
-
61
- - Purpose: Config file loading, validation, and env var overrides
62
- - Location: `lib/config.mjs`
63
- - Contains: `loadConfig()`, `saveConfig()`, `DEFAULT_CONFIG`, `CLIENT_ID`, strategy validation, XDG-compliant path resolution
64
- - Depends on: Node.js builtins only
65
- - Used by: `index.mjs`, `cli.mjs`, `lib/accounts.mjs`, `lib/rotation.mjs`
66
-
67
- **Storage (`lib/storage.mjs`):**
68
-
69
- - Purpose: Atomic JSON file persistence for account data
70
- - Location: `lib/storage.mjs`
71
- - Contains: `loadAccounts()`, `saveAccounts()`, `clearAccounts()`, account validation/deduplication, `.gitignore` management
72
- - Depends on: `lib/config.mjs` (for config dir path)
73
- - Used by: `index.mjs`, `cli.mjs`, `lib/accounts.mjs`
74
-
75
- ## Data Flow
76
-
77
- **API Request (Plugin fetch interceptor):**
78
-
79
- 1. OpenCode calls `fetch(input, init)` on the plugin's custom fetch
80
- 2. `transformRequestBody()` sanitizes system prompts and prefixes tool names with `mcp_`
81
- 3. `transformRequestUrl()` adds `?beta=true` to `/v1/messages` endpoint
82
- 4. `AccountManager.getCurrentAccount()` selects best account via strategy (sticky/round-robin/hybrid)
83
- 5. If token expired, `refreshAccountTokenSingleFlight()` refreshes OAuth token (single-flight deduplication)
84
- 6. `buildRequestHeaders()` sets Authorization bearer, anthropic-beta headers, removes x-api-key
85
- 7. Actual `fetch()` to Anthropic API executes
86
- 8. On account-specific error (429, 401, 400/403 with account language): mark account, try next account (up to N accounts)
87
- 9. On service-wide error (529, 503, 500): return immediately (switching won't help)
88
- 10. On success: `transformResponse()` wraps the SSE stream to strip `mcp_` prefix from tool names, extract token usage, and detect mid-stream account errors
89
-
90
- **Account Selection (per-request):**
91
-
92
- 1. `AccountManager.syncActiveIndexFromDisk()` reconciles with CLI changes
93
- 2. Build candidate list: enabled accounts, not in per-request exclusion set
94
- 3. `selectAccount()` applies configured strategy:
95
- - **sticky**: Stay on current account unless rate-limited/failed
96
- - **round-robin**: Rotate cursor through available accounts
97
- - **hybrid**: Score accounts by health (weighted ×2) + token bucket (weighted ×5) + freshness, with stickiness bonus (150 points) and switch threshold (100 points)
98
- 4. Consume a token bucket token for the selected account
99
- 5. Return selected `ManagedAccount`
100
-
101
- **OAuth Login Flow:**
102
-
103
- 1. User triggers `opencode auth login` → plugin's `auth.methods[0].authorize()` runs
104
- 2. If existing accounts: prompt menu (add/fresh/manage/cancel)
105
- 3. Generate PKCE challenge via `@openauthjs/openauth`
106
- 4. Build authorize URL for `claude.ai/oauth/authorize`
107
- 5. User pastes authorization code
108
- 6. `exchange()` POSTs to `console.anthropic.com/v1/oauth/token` for tokens
109
- 7. `AccountManager.addAccount()` stores new credentials (deduplicates by refresh token)
110
- 8. `AccountManager.saveToDisk()` persists to `~/.config/opencode/anthropic-accounts.json`
111
-
112
- **State Management:**
113
-
114
- - **In-memory (plugin process):** `AccountManager` with `HealthScoreTracker` and `TokenBucketTracker` — these are ephemeral, rebuilt on plugin load
115
- - **On-disk (shared):** `anthropic-accounts.json` stores account credentials, failure tracking, rate limit reset times, usage stats, and active index
116
- - **Sync mechanism:** Plugin reads `activeIndex` from disk before each request to pick up CLI changes; debounced writes (1s) persist plugin-side changes back
117
- - **Stats merge-on-save:** To handle concurrent instances, `saveToDisk()` reads disk values and applies in-memory deltas rather than overwriting
118
-
119
- ## Key Abstractions
120
-
121
- **AccountManager (`lib/accounts.mjs`):**
122
-
123
- - Purpose: Central coordinator for multi-account state and request routing
124
- - Pattern: Singleton per plugin instance with private state (`#accounts`, `#healthTracker`, `#tokenTracker`)
125
- - Static factory: `AccountManager.load(config, authFallback)` bootstraps from disk + OpenCode auth
126
- - Key methods: `getCurrentAccount()`, `markRateLimited()`, `markSuccess()`, `markFailure()`, `recordUsage()`, `syncActiveIndexFromDisk()`
127
-
128
- **HealthScoreTracker (`lib/rotation.mjs`):**
129
-
130
- - Purpose: Track per-account health with passive time-based recovery
131
- - Pattern: In-memory score map with configurable initial/penalty/recovery values
132
- - Score range: 0–100, min_usable threshold (default 50) gates account selection
133
-
134
- **TokenBucketTracker (`lib/rotation.mjs`):**
135
-
136
- - Purpose: Rate-limit requests per account using token bucket algorithm
137
- - Pattern: Per-account bucket with configurable max tokens, regeneration rate
138
- - Default: 50 tokens, 6/minute regen
139
-
140
- **ManagedAccount (typedef in `lib/accounts.mjs`):**
141
-
142
- - Purpose: Runtime representation of an Anthropic OAuth account
143
- - Contains: id, index, email, refreshToken, access, expires, enabled, rateLimitResetTimes, consecutiveFailures, stats
144
- - Pattern: Mutable in-memory state with periodic disk sync
145
-
146
- ## Entry Points
147
-
148
- **Plugin (`index.mjs` → `AnthropicAuthPlugin`):**
149
-
150
- - Location: `index.mjs`
151
- - Triggers: Loaded by OpenCode plugin system as ESM module
152
- - Responsibilities: Exports an async plugin factory function returning hooks for `experimental.chat.system.transform`, `auth.loader`, and `auth.methods`
153
- - Exported as: `export async function AnthropicAuthPlugin({ client })`
154
-
155
- **CLI (`cli.mjs` → `main()`):**
156
-
157
- - Location: `cli.mjs`
158
- - Triggers: Executed as `opencode-anthropic-auth [command]` via `~/.local/bin` symlink/copy
159
- - Responsibilities: Parse argv, route to command handlers, manage accounts via direct storage manipulation
160
- - Self-detection: Uses `import.meta.url === pathToFileURL(process.argv[1]).href` with symlink resolution
161
-
162
- **Build (`scripts/build.mjs`):**
163
-
164
- - Location: `scripts/build.mjs`
165
- - Triggers: `npm run build`
166
- - Responsibilities: Bundle `index.mjs` → `dist/opencode-anthropic-auth-plugin.js` and `cli.mjs` → `dist/opencode-anthropic-auth-cli.mjs` using esbuild (ESM, node20 target, all deps bundled except node: builtins)
167
-
168
- **Install (`scripts/install.mjs`):**
169
-
170
- - Location: `scripts/install.mjs`
171
- - Triggers: `npm run install:link` (dev) or `npm run install:copy` (production)
172
- - Responsibilities: Symlink or copy plugin to `~/.config/opencode/plugin/` and CLI to `~/.local/bin/`
173
-
174
- ## Error Handling
175
-
176
- **Strategy:** Account-specific vs. service-wide error classification with automatic account rotation
177
-
178
- **Patterns:**
179
-
180
- - **Account-specific errors** (429, 401, 400/403 with account error body): Mark account with backoff, rotate to next account. Auth failures (401) invalidate cached token.
181
- - **Service-wide errors** (529, 503, 500): Return immediately to caller — switching accounts won't help.
182
- - **Token refresh failures**: Transient errors skip account for current request; terminal errors (400, 401, 403, `invalid_grant`) disable the account permanently.
183
- - **Mid-stream SSE errors**: Detected in response stream transformer. Account marked for next request (not retried mid-stream).
184
- - **Single-flight token refresh**: Concurrent requests to the same account share one in-flight refresh promise to prevent race conditions.
185
- - **Backoff calculation** (`lib/backoff.mjs`): Retry-After header takes precedence. Otherwise, reason-based: AUTH_FAILED=5s, RATE_LIMIT_EXCEEDED=30s, QUOTA_EXHAUSTED=escalating [1m, 5m, 30m, 2h].
186
-
187
- ## Cross-Cutting Concerns
188
-
189
- **Logging:** Debug logging via `console.error("[opencode-anthropic-auth]", ...)` gated by `config.debug` flag (env: `OPENCODE_ANTHROPIC_DEBUG=1`). CLI uses direct `console.log` with ANSI color helpers.
190
-
191
- **Validation:** Config values are clamped to valid ranges in `lib/config.mjs` (`clampNumber()`). Account data is validated on load with type checks and fallbacks in `lib/storage.mjs` (`validateAccount()`). Duplicate refresh tokens are deduplicated.
192
-
193
- **Persistence:** All disk writes use atomic temp-file-and-rename pattern. Account storage uses `randomBytes(6).hex` temp names; config uses PID-based temp names. Files are written with `mode: 0o600` for security.
194
-
195
- **Configuration Precedence:** Config file (`~/.config/opencode/anthropic-auth.json`) → environment variable overrides (`OPENCODE_ANTHROPIC_STRATEGY`, `OPENCODE_ANTHROPIC_DEBUG`, `OPENCODE_ANTHROPIC_QUIET`).
196
-
197
- **Toast Notifications:** Plugin sends TUI toasts via `client.tui?.showToast()` for account usage, switching, and errors. Toasts are debounced per category and suppressible via quiet mode.
198
-
199
- ---
200
-
201
- _Architecture analysis: 2026-02-07_