@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
package/src/accounts.ts CHANGED
@@ -1,1067 +1,711 @@
1
1
  import type { RateLimitReason } from "./backoff.js";
2
2
  import { calculateBackoffMs } from "./backoff.js";
3
3
  import { readCCCredentials } from "./cc-credentials.js";
4
+ import { resolveIdentityFromCCCredential, type AccountIdentity } from "./account-identity.js";
4
5
  import {
5
- findByIdentity,
6
- resolveIdentity,
7
- resolveIdentityFromCCCredential,
8
- type AccountIdentity,
9
- } from "./account-identity.js";
6
+ createBootstrapAccountFromFallback,
7
+ loadManagedAccountsFromStorage,
8
+ mergeAuthFallbackIntoAccounts,
9
+ prepareStorageForSave,
10
+ reconcileManagedAccountsWithStorage,
11
+ } from "./accounts/persistence.js";
12
+ import {
13
+ createManagedAccount,
14
+ findMatchingManagedAccount,
15
+ reindexManagedAccounts,
16
+ resolveManagedAccountIdentity,
17
+ } from "./accounts/matching.js";
18
+ import { inferCCSourceFromId, repairCorruptedCCAccounts } from "./accounts/repair.js";
10
19
  import type { AnthropicAuthConfig } from "./config.js";
11
20
  import { HealthScoreTracker, selectAccount, TokenBucketTracker } from "./rotation.js";
12
- import type { AccountMetadata, AccountStats, AccountStorage } from "./storage.js";
21
+ import type { AccountStats, AccountStorage } from "./storage.js";
13
22
  import { createDefaultStats, loadAccounts, saveAccounts } from "./storage.js";
14
23
 
15
24
  export interface ManagedAccount {
16
- id: string;
17
- index: number;
18
- email?: string;
19
- identity?: AccountIdentity;
20
- label?: string;
21
- refreshToken: string;
22
- access?: string;
23
- expires?: number;
24
- tokenUpdatedAt: number;
25
- addedAt: number;
26
- lastUsed: number;
27
- enabled: boolean;
28
- rateLimitResetTimes: Record<string, number>;
29
- consecutiveFailures: number;
30
- lastFailureTime: number | null;
31
- lastSwitchReason?: string;
32
- stats: AccountStats;
33
- source?: "cc-keychain" | "cc-file" | "oauth";
25
+ id: string;
26
+ index: number;
27
+ email?: string;
28
+ identity?: AccountIdentity;
29
+ label?: string;
30
+ refreshToken: string;
31
+ access?: string;
32
+ expires?: number;
33
+ tokenUpdatedAt: number;
34
+ addedAt: number;
35
+ lastUsed: number;
36
+ enabled: boolean;
37
+ rateLimitResetTimes: Record<string, number>;
38
+ consecutiveFailures: number;
39
+ lastFailureTime: number | null;
40
+ lastSwitchReason?: string;
41
+ stats: AccountStats;
42
+ source?: "cc-keychain" | "cc-file" | "oauth";
34
43
  }
35
44
 
36
45
  export interface StatsDelta {
37
- requests: number;
38
- inputTokens: number;
39
- outputTokens: number;
40
- cacheReadTokens: number;
41
- cacheWriteTokens: number;
42
- /** If true, this delta represents an absolute reset, not an increment */
43
- isReset: boolean;
44
- /** The lastReset value when isReset is true */
45
- resetTimestamp?: number;
46
+ requests: number;
47
+ inputTokens: number;
48
+ outputTokens: number;
49
+ cacheReadTokens: number;
50
+ cacheWriteTokens: number;
51
+ /** If true, this delta represents an absolute reset, not an increment */
52
+ isReset: boolean;
53
+ /** The lastReset value when isReset is true */
54
+ resetTimestamp?: number;
46
55
  }
47
56
 
48
57
  const MAX_ACCOUNTS = 10;
49
58
  const RATE_LIMIT_KEY = "anthropic";
50
59
 
51
- type ManagedAccountSource = ManagedAccount["source"];
52
-
53
60
  type AddAccountOptions = {
54
- identity?: AccountIdentity;
55
- label?: string;
56
- source?: ManagedAccountSource;
61
+ identity?: AccountIdentity;
62
+ label?: string;
63
+ source?: ManagedAccount["source"];
57
64
  };
58
65
 
59
- type ManagedAccountInit = {
60
- id?: string;
61
- index: number;
62
- email?: string;
63
- identity?: AccountIdentity;
64
- label?: string;
65
- refreshToken: string;
66
- access?: string;
67
- expires?: number;
68
- tokenUpdatedAt?: number;
69
- addedAt?: number;
70
- lastUsed?: number;
71
- enabled?: boolean;
72
- rateLimitResetTimes?: Record<string, number>;
73
- consecutiveFailures?: number;
74
- lastFailureTime?: number | null;
75
- lastSwitchReason?: string;
76
- stats?: AccountStats;
77
- source?: ManagedAccountSource;
78
- now?: number;
79
- };
66
+ export class AccountManager {
67
+ #accounts: ManagedAccount[] = [];
68
+ #cursor = 0;
69
+ #currentIndex = -1;
70
+ #healthTracker: HealthScoreTracker;
71
+ #tokenTracker: TokenBucketTracker;
72
+ #config: AnthropicAuthConfig;
73
+ #saveTimeout: ReturnType<typeof setTimeout> | null = null;
74
+ #statsDeltas = new Map<string, StatsDelta>();
75
+ #pendingDroppedIds = new Set<string>();
76
+ /**
77
+ * Cap on pending stats deltas. When hit, a forced flush is scheduled so the
78
+ * map does not grow without bound between debounced saves. This is only a
79
+ * safety net — under normal load the 1s debounced save in `requestSaveToDisk`
80
+ * keeps the delta count below this cap.
81
+ */
82
+ readonly #MAX_STATS_DELTAS = 100;
83
+
84
+ constructor(config: AnthropicAuthConfig) {
85
+ this.#config = config;
86
+ this.#healthTracker = new HealthScoreTracker(config.health_score);
87
+ this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
88
+ }
80
89
 
81
- function resolveAccountIdentity(params: {
82
- refreshToken: string;
83
- email?: string;
84
- identity?: AccountIdentity;
85
- label?: string;
86
- source?: ManagedAccountSource;
87
- }): AccountIdentity {
88
- if (params.identity) {
89
- return params.identity;
90
- }
91
-
92
- if ((params.source === "cc-keychain" || params.source === "cc-file") && params.label) {
93
- return {
94
- kind: "cc",
95
- source: params.source,
96
- label: params.label,
97
- };
98
- }
99
-
100
- if (params.email) {
101
- return {
102
- kind: "oauth",
103
- email: params.email,
104
- };
105
- }
106
-
107
- return {
108
- kind: "legacy",
109
- refreshToken: params.refreshToken,
110
- };
111
- }
90
+ #rebuildTrackers(): void {
91
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
92
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
93
+ }
112
94
 
113
- function createManagedAccount(init: ManagedAccountInit): ManagedAccount {
114
- const now = init.now ?? Date.now();
115
- const addedAt = init.addedAt ?? now;
116
- const tokenUpdatedAt = init.tokenUpdatedAt ?? addedAt;
117
- const identity = resolveAccountIdentity({
118
- refreshToken: init.refreshToken,
119
- email: init.email,
120
- identity: init.identity,
121
- label: init.label,
122
- source: init.source,
123
- });
124
- const email = init.email ?? (identity.kind === "oauth" ? identity.email : undefined);
125
- const label = init.label ?? (identity.kind === "cc" ? identity.label : undefined);
126
- const source = init.source ?? (identity.kind === "cc" ? identity.source : "oauth");
127
-
128
- return {
129
- id: init.id ?? `${addedAt}:${init.refreshToken.slice(0, 12)}`,
130
- index: init.index,
131
- email,
132
- identity,
133
- label,
134
- refreshToken: init.refreshToken,
135
- access: init.access,
136
- expires: init.expires,
137
- tokenUpdatedAt,
138
- addedAt,
139
- lastUsed: init.lastUsed ?? 0,
140
- enabled: init.enabled ?? true,
141
- rateLimitResetTimes: { ...(init.rateLimitResetTimes ?? {}) },
142
- consecutiveFailures: init.consecutiveFailures ?? 0,
143
- lastFailureTime: init.lastFailureTime ?? null,
144
- lastSwitchReason: init.lastSwitchReason ?? "initial",
145
- stats: init.stats ?? createDefaultStats(addedAt),
146
- source,
147
- };
148
- }
95
+ /**
96
+ * Load accounts from disk, optionally merging with an OpenCode auth fallback.
97
+ */
98
+ static async load(
99
+ config: AnthropicAuthConfig,
100
+ authFallback?: {
101
+ refresh: string;
102
+ access?: string;
103
+ expires?: number;
104
+ } | null,
105
+ ): Promise<AccountManager> {
106
+ const manager = new AccountManager(config);
107
+ const stored = await loadAccounts();
108
+
109
+ // If storage exists (even with zero accounts), treat disk as authoritative.
110
+ if (stored) {
111
+ const loaded = loadManagedAccountsFromStorage(stored);
112
+ manager.#accounts = loaded.accounts;
113
+ manager.#currentIndex = loaded.currentIndex;
114
+
115
+ if (authFallback && manager.#accounts.length > 0) {
116
+ mergeAuthFallbackIntoAccounts(manager.#accounts, authFallback);
117
+ }
149
118
 
150
- function findMatchingAccount(
151
- accounts: ManagedAccount[],
152
- params: {
153
- id?: string;
154
- identity?: AccountIdentity;
155
- refreshToken?: string;
156
- },
157
- ): ManagedAccount | null {
158
- if (params.id) {
159
- const byId = accounts.find((account) => account.id === params.id);
160
- if (byId) return byId;
161
- }
162
-
163
- if (params.identity) {
164
- const byIdentity = findByIdentity(accounts, params.identity);
165
- if (byIdentity) return byIdentity;
166
- }
167
-
168
- if (params.refreshToken) {
169
- return accounts.find((account) => account.refreshToken === params.refreshToken) ?? null;
170
- }
171
-
172
- return null;
173
- }
119
+ // No stored accounts — bootstrap from fallback if available
120
+ } else if (authFallback && authFallback.refresh) {
121
+ manager.#accounts = [createBootstrapAccountFromFallback(authFallback)];
122
+ manager.#currentIndex = 0;
123
+ }
174
124
 
175
- function reindexAccounts(accounts: ManagedAccount[]): void {
176
- accounts.forEach((account, index) => {
177
- account.index = index;
178
- });
179
- }
125
+ if (config.cc_credential_reuse?.enabled && config.cc_credential_reuse?.auto_detect) {
126
+ const currentAccountId = manager.#accounts[manager.#currentIndex]?.id ?? null;
127
+ const ccCredentials: ReturnType<typeof readCCCredentials> = (() => {
128
+ try {
129
+ return readCCCredentials();
130
+ } catch {
131
+ return [];
132
+ }
133
+ })();
134
+
135
+ // Heal corrupted CC rows that lost source/identity/label in an older
136
+ // write path, and collapse any resulting duplicates BEFORE auto-import
137
+ // runs. Otherwise auto-import would fail to match the corrupted row
138
+ // and create a fresh duplicate every load. Dropped ids are stashed on
139
+ // the manager so the next saveToDisk can tell prepareStorageForSave
140
+ // not to restore them via the disk-only union.
141
+ const repair = repairCorruptedCCAccounts(manager.#accounts, ccCredentials);
142
+ if (repair.result.collapsed > 0 || repair.result.repaired > 0) {
143
+ const beforeIds = new Set(manager.#accounts.map((account) => account.id));
144
+ manager.#accounts = repair.accounts;
145
+ reindexManagedAccounts(manager.#accounts);
146
+ const afterIds = new Set(manager.#accounts.map((account) => account.id));
147
+ for (const id of beforeIds) {
148
+ if (!afterIds.has(id)) manager.#pendingDroppedIds.add(id);
149
+ }
150
+ if (manager.#currentIndex >= manager.#accounts.length) {
151
+ manager.#currentIndex = manager.#accounts.length > 0 ? 0 : -1;
152
+ }
153
+ }
180
154
 
181
- function updateManagedAccountFromStorage(existing: ManagedAccount, account: AccountMetadata, index: number): void {
182
- const source = account.source || existing.source || "oauth";
183
- const label = account.label ?? existing.label;
184
- const email = account.email ?? existing.email;
185
-
186
- existing.id = account.id || existing.id || `${account.addedAt}:${account.refreshToken.slice(0, 12)}`;
187
- existing.index = index;
188
- existing.email = email;
189
- existing.label = label;
190
- existing.identity = resolveAccountIdentity({
191
- refreshToken: account.refreshToken,
192
- email,
193
- identity: account.identity ?? existing.identity,
194
- label,
195
- source,
196
- });
197
- existing.refreshToken = account.refreshToken;
198
- existing.access = account.access ?? existing.access;
199
- existing.expires = account.expires ?? existing.expires;
200
- existing.tokenUpdatedAt = account.token_updated_at ?? existing.tokenUpdatedAt ?? account.addedAt;
201
- existing.addedAt = account.addedAt;
202
- existing.lastUsed = account.lastUsed;
203
- existing.enabled = account.enabled;
204
- existing.rateLimitResetTimes = { ...account.rateLimitResetTimes };
205
- existing.consecutiveFailures = account.consecutiveFailures;
206
- existing.lastFailureTime = account.lastFailureTime;
207
- existing.lastSwitchReason = account.lastSwitchReason || existing.lastSwitchReason || "initial";
208
- existing.stats = account.stats ?? existing.stats ?? createDefaultStats(account.addedAt);
209
- existing.source = source;
210
- }
155
+ for (const ccCredential of ccCredentials) {
156
+ const ccIdentity = resolveIdentityFromCCCredential(ccCredential);
157
+ let existingMatch = findMatchingManagedAccount(manager.#accounts, {
158
+ identity: ccIdentity,
159
+ refreshToken: ccCredential.refreshToken,
160
+ });
161
+
162
+ if (!existingMatch) {
163
+ const legacyUnlabeledMatches = manager.#accounts.filter(
164
+ (account) => account.source === ccCredential.source && !account.label && !account.email,
165
+ );
166
+ if (legacyUnlabeledMatches.length === 1) {
167
+ existingMatch = legacyUnlabeledMatches[0]!;
168
+ }
169
+ }
170
+
171
+ if (existingMatch) {
172
+ existingMatch.refreshToken = ccCredential.refreshToken;
173
+ existingMatch.identity = ccIdentity;
174
+ existingMatch.source = ccCredential.source;
175
+ existingMatch.label = ccCredential.label;
176
+ existingMatch.enabled = true;
177
+ if (ccCredential.accessToken) {
178
+ existingMatch.access = ccCredential.accessToken;
179
+ }
180
+ if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
181
+ existingMatch.expires = ccCredential.expiresAt;
182
+ }
183
+ existingMatch.tokenUpdatedAt = Math.max(
184
+ existingMatch.tokenUpdatedAt || 0,
185
+ ccCredential.expiresAt || 0,
186
+ );
187
+ continue;
188
+ }
189
+
190
+ if (manager.#accounts.length >= MAX_ACCOUNTS) {
191
+ continue;
192
+ }
193
+
194
+ const emailCollision = manager
195
+ .getOAuthAccounts()
196
+ .find((account) => account.email && ccCredential.label.includes(account.email));
197
+ if (emailCollision?.email) {
198
+ // Duplicate detection: CC credential may match existing OAuth account
199
+ // This is informational only - both accounts are kept
200
+ }
201
+
202
+ const now = Date.now();
203
+ const ccAccount = createManagedAccount({
204
+ id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
205
+ index: manager.#accounts.length,
206
+ refreshToken: ccCredential.refreshToken,
207
+ access: ccCredential.accessToken,
208
+ expires: ccCredential.expiresAt,
209
+ tokenUpdatedAt: now,
210
+ addedAt: now,
211
+ identity: ccIdentity,
212
+ label: ccCredential.label,
213
+ lastSwitchReason: "cc-auto-detected",
214
+ source: ccCredential.source,
215
+ });
216
+
217
+ manager.#accounts.push(ccAccount);
218
+ }
211
219
 
212
- export class AccountManager {
213
- #accounts: ManagedAccount[] = [];
214
- #cursor = 0;
215
- #currentIndex = -1;
216
- #healthTracker: HealthScoreTracker;
217
- #tokenTracker: TokenBucketTracker;
218
- #config: AnthropicAuthConfig;
219
- #saveTimeout: ReturnType<typeof setTimeout> | null = null;
220
- #statsDeltas = new Map<string, StatsDelta>();
221
- /**
222
- * Cap on pending stats deltas. When hit, a forced flush is scheduled so the
223
- * map does not grow without bound between debounced saves. This is only a
224
- * safety net — under normal load the 1s debounced save in `requestSaveToDisk`
225
- * keeps the delta count below this cap.
226
- */
227
- readonly #MAX_STATS_DELTAS = 100;
228
-
229
- constructor(config: AnthropicAuthConfig) {
230
- this.#config = config;
231
- this.#healthTracker = new HealthScoreTracker(config.health_score);
232
- this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
233
- }
234
-
235
- #rebuildTrackers(): void {
236
- this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
237
- this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
238
- }
239
-
240
- /**
241
- * Load accounts from disk, optionally merging with an OpenCode auth fallback.
242
- */
243
- static async load(
244
- config: AnthropicAuthConfig,
245
- authFallback?: {
246
- refresh: string;
247
- access?: string;
248
- expires?: number;
249
- } | null,
250
- ): Promise<AccountManager> {
251
- const manager = new AccountManager(config);
252
- const stored = await loadAccounts();
253
-
254
- // If storage exists (even with zero accounts), treat disk as authoritative.
255
- if (stored) {
256
- manager.#accounts = stored.accounts.map((acc, index) =>
257
- createManagedAccount({
258
- id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
259
- index,
260
- email: acc.email,
261
- identity: acc.identity,
262
- label: acc.label,
263
- refreshToken: acc.refreshToken,
264
- access: acc.access,
265
- expires: acc.expires,
266
- tokenUpdatedAt: acc.token_updated_at,
267
- addedAt: acc.addedAt,
268
- lastUsed: acc.lastUsed,
269
- enabled: acc.enabled,
270
- rateLimitResetTimes: acc.rateLimitResetTimes,
271
- consecutiveFailures: acc.consecutiveFailures,
272
- lastFailureTime: acc.lastFailureTime,
273
- lastSwitchReason: acc.lastSwitchReason,
274
- stats: acc.stats,
275
- source: acc.source || "oauth",
276
- }),
277
- );
278
-
279
- manager.#currentIndex =
280
- manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
281
-
282
- if (authFallback && manager.#accounts.length > 0) {
283
- const fallbackIdentity = resolveAccountIdentity({
284
- refreshToken: authFallback.refresh,
285
- source: "oauth",
286
- });
287
- const match = findMatchingAccount(manager.#accounts, {
288
- identity: fallbackIdentity,
289
- refreshToken: authFallback.refresh,
290
- });
291
- if (match) {
292
- const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
293
- const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
294
- const matchExpires = typeof match.expires === "number" ? match.expires : 0;
295
- const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
296
- const shouldAdoptFallback =
297
- fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
298
- if (shouldAdoptFallback) {
299
- match.access = authFallback.access;
300
- match.expires = authFallback.expires;
301
- match.tokenUpdatedAt = Math.max(match.tokenUpdatedAt || 0, fallbackExpires);
302
- }
220
+ if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
221
+ manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
222
+ }
223
+
224
+ reindexManagedAccounts(manager.#accounts);
225
+
226
+ if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
227
+ manager.#currentIndex = 0;
228
+ } else if (currentAccountId) {
229
+ manager.#currentIndex = manager.#accounts.findIndex((account) => account.id === currentAccountId);
230
+ } else if (manager.#currentIndex < 0 && manager.#accounts.length > 0) {
231
+ manager.#currentIndex = 0;
232
+ }
303
233
  }
304
- }
305
-
306
- // No stored accounts — bootstrap from fallback if available
307
- } else if (authFallback && authFallback.refresh) {
308
- const now = Date.now();
309
- manager.#accounts = [
310
- createManagedAccount({
311
- id: `${now}:${authFallback.refresh.slice(0, 12)}`,
312
- index: 0,
313
- refreshToken: authFallback.refresh,
314
- access: authFallback.access,
315
- expires: authFallback.expires,
316
- tokenUpdatedAt: now,
317
- addedAt: now,
318
- lastSwitchReason: "initial",
319
- source: "oauth",
320
- }),
321
- ];
322
- manager.#currentIndex = 0;
234
+
235
+ return manager;
323
236
  }
324
237
 
325
- if (config.cc_credential_reuse?.enabled && config.cc_credential_reuse?.auto_detect) {
326
- const currentAccountId = manager.#accounts[manager.#currentIndex]?.id ?? null;
327
- const ccCredentials: ReturnType<typeof readCCCredentials> = (() => {
328
- try {
329
- return readCCCredentials();
330
- } catch {
331
- return [];
332
- }
333
- })();
238
+ /**
239
+ * Get the number of enabled accounts.
240
+ */
241
+ getAccountCount(): number {
242
+ return this.#accounts.filter((acc) => acc.enabled).length;
243
+ }
334
244
 
335
- for (const ccCredential of ccCredentials) {
336
- const ccIdentity = resolveIdentityFromCCCredential(ccCredential);
337
- let existingMatch = findMatchingAccount(manager.#accounts, {
338
- identity: ccIdentity,
339
- refreshToken: ccCredential.refreshToken,
340
- });
245
+ /**
246
+ * Get the total number of accounts (including disabled).
247
+ */
248
+ getTotalAccountCount(): number {
249
+ return this.#accounts.length;
250
+ }
341
251
 
342
- if (!existingMatch) {
343
- const legacyUnlabeledMatches = manager.#accounts.filter(
344
- (account) => account.source === ccCredential.source && !account.label && !account.email,
345
- );
346
- if (legacyUnlabeledMatches.length === 1) {
347
- existingMatch = legacyUnlabeledMatches[0]!;
348
- }
349
- }
252
+ /**
253
+ * Get a snapshot of all accounts (for display/management).
254
+ */
255
+ getAccountsSnapshot(): ManagedAccount[] {
256
+ return this.#accounts.map((acc) => ({ ...acc }));
257
+ }
350
258
 
351
- if (existingMatch) {
352
- existingMatch.refreshToken = ccCredential.refreshToken;
353
- existingMatch.identity = ccIdentity;
354
- existingMatch.source = ccCredential.source;
355
- existingMatch.label = ccCredential.label;
356
- existingMatch.enabled = true;
357
- if (ccCredential.accessToken) {
358
- existingMatch.access = ccCredential.accessToken;
359
- }
360
- if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
361
- existingMatch.expires = ccCredential.expiresAt;
362
- }
363
- existingMatch.tokenUpdatedAt = Math.max(existingMatch.tokenUpdatedAt || 0, ccCredential.expiresAt || 0);
364
- continue;
365
- }
259
+ /**
260
+ * Get the current active account index.
261
+ */
262
+ getCurrentIndex(): number {
263
+ return this.#currentIndex;
264
+ }
265
+
266
+ /**
267
+ * Force the active account to a specific index.
268
+ * Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
269
+ */
270
+ forceCurrentIndex(index: number): boolean {
271
+ const account = this.#accounts[index];
272
+ if (!account || !account.enabled) return false;
273
+ this.#currentIndex = index;
274
+ this.#cursor = index;
275
+ return true;
276
+ }
366
277
 
367
- if (manager.#accounts.length >= MAX_ACCOUNTS) {
368
- continue;
278
+ /**
279
+ * Get enabled account references for internal plugin operations.
280
+ */
281
+ getEnabledAccounts(excludedIndices?: Set<number>): ManagedAccount[] {
282
+ return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
283
+ }
284
+
285
+ getCCAccounts(): ManagedAccount[] {
286
+ return this.#accounts.filter((acc) => acc.source === "cc-keychain" || acc.source === "cc-file");
287
+ }
288
+
289
+ getOAuthAccounts(): ManagedAccount[] {
290
+ return this.#accounts.filter((acc) => !acc.source || acc.source === "oauth");
291
+ }
292
+
293
+ #clearExpiredRateLimits(account: ManagedAccount): void {
294
+ const now = Date.now();
295
+ for (const key of Object.keys(account.rateLimitResetTimes)) {
296
+ if (account.rateLimitResetTimes[key]! <= now) {
297
+ delete account.rateLimitResetTimes[key];
298
+ }
369
299
  }
300
+ }
370
301
 
371
- const emailCollision = manager
372
- .getOAuthAccounts()
373
- .find((account) => account.email && ccCredential.label.includes(account.email));
374
- if (emailCollision?.email) {
375
- // Duplicate detection: CC credential may match existing OAuth account
376
- // This is informational only - both accounts are kept
302
+ #isRateLimited(account: ManagedAccount): boolean {
303
+ this.#clearExpiredRateLimits(account);
304
+ const resetTime = account.rateLimitResetTimes[RATE_LIMIT_KEY];
305
+ return resetTime !== undefined && Date.now() < resetTime;
306
+ }
307
+
308
+ /**
309
+ * Select the best account for the current request.
310
+ */
311
+ getCurrentAccount(excludedIndices?: Set<number>): ManagedAccount | null {
312
+ if (this.#accounts.length === 0) return null;
313
+
314
+ const candidates = this.#accounts
315
+ .filter((acc) => acc.enabled && !excludedIndices?.has(acc.index))
316
+ .map((acc) => {
317
+ this.#clearExpiredRateLimits(acc);
318
+ return {
319
+ index: acc.index,
320
+ lastUsed: acc.lastUsed,
321
+ healthScore: this.#healthTracker.getScore(acc.index),
322
+ isRateLimited: this.#isRateLimited(acc),
323
+ enabled: acc.enabled,
324
+ };
325
+ });
326
+
327
+ const result = selectAccount(
328
+ candidates,
329
+ this.#config.account_selection_strategy,
330
+ this.#currentIndex >= 0 ? this.#currentIndex : null,
331
+ this.#healthTracker,
332
+ this.#tokenTracker,
333
+ this.#cursor,
334
+ );
335
+
336
+ if (!result) return null;
337
+
338
+ this.#cursor = result.cursor;
339
+ this.#currentIndex = result.index;
340
+
341
+ const account = this.#accounts[result.index];
342
+ if (account) {
343
+ account.lastUsed = Date.now();
344
+ this.#tokenTracker.consume(account.index);
377
345
  }
378
346
 
347
+ return account ?? null;
348
+ }
349
+
350
+ /**
351
+ * Mark an account as rate-limited.
352
+ * @returns The backoff duration in ms
353
+ */
354
+ markRateLimited(account: ManagedAccount, reason: RateLimitReason, retryAfterMs?: number | null): number {
379
355
  const now = Date.now();
380
- const ccAccount = createManagedAccount({
381
- id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
382
- index: manager.#accounts.length,
383
- refreshToken: ccCredential.refreshToken,
384
- access: ccCredential.accessToken,
385
- expires: ccCredential.expiresAt,
386
- tokenUpdatedAt: now,
387
- addedAt: now,
388
- identity: ccIdentity,
389
- label: ccCredential.label,
390
- lastSwitchReason: "cc-auto-detected",
391
- source: ccCredential.source,
392
- });
393
356
 
394
- manager.#accounts.push(ccAccount);
395
- }
357
+ if (
358
+ account.lastFailureTime !== null &&
359
+ now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000
360
+ ) {
361
+ account.consecutiveFailures = 0;
362
+ }
363
+
364
+ account.consecutiveFailures += 1;
365
+ account.lastFailureTime = now;
396
366
 
397
- if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
398
- manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
399
- }
367
+ const backoffMs = calculateBackoffMs(reason, account.consecutiveFailures - 1, retryAfterMs);
400
368
 
401
- reindexAccounts(manager.#accounts);
369
+ account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + backoffMs;
402
370
 
403
- if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
404
- manager.#currentIndex = 0;
405
- } else if (currentAccountId) {
406
- manager.#currentIndex = manager.#accounts.findIndex((account) => account.id === currentAccountId);
407
- } else if (manager.#currentIndex < 0 && manager.#accounts.length > 0) {
408
- manager.#currentIndex = 0;
409
- }
371
+ this.#healthTracker.recordRateLimit(account.index);
372
+
373
+ this.requestSaveToDisk();
374
+
375
+ return backoffMs;
410
376
  }
411
377
 
412
- return manager;
413
- }
414
-
415
- /**
416
- * Get the number of enabled accounts.
417
- */
418
- getAccountCount(): number {
419
- return this.#accounts.filter((acc) => acc.enabled).length;
420
- }
421
-
422
- /**
423
- * Get the total number of accounts (including disabled).
424
- */
425
- getTotalAccountCount(): number {
426
- return this.#accounts.length;
427
- }
428
-
429
- /**
430
- * Get a snapshot of all accounts (for display/management).
431
- */
432
- getAccountsSnapshot(): ManagedAccount[] {
433
- return this.#accounts.map((acc) => ({ ...acc }));
434
- }
435
-
436
- /**
437
- * Get the current active account index.
438
- */
439
- getCurrentIndex(): number {
440
- return this.#currentIndex;
441
- }
442
-
443
- /**
444
- * Force the active account to a specific index.
445
- * Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
446
- */
447
- forceCurrentIndex(index: number): boolean {
448
- const account = this.#accounts[index];
449
- if (!account || !account.enabled) return false;
450
- this.#currentIndex = index;
451
- this.#cursor = index;
452
- return true;
453
- }
454
-
455
- /**
456
- * Get enabled account references for internal plugin operations.
457
- */
458
- getEnabledAccounts(excludedIndices?: Set<number>): ManagedAccount[] {
459
- return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
460
- }
461
-
462
- getCCAccounts(): ManagedAccount[] {
463
- return this.#accounts.filter((acc) => acc.source === "cc-keychain" || acc.source === "cc-file");
464
- }
465
-
466
- getOAuthAccounts(): ManagedAccount[] {
467
- return this.#accounts.filter((acc) => !acc.source || acc.source === "oauth");
468
- }
469
-
470
- #clearExpiredRateLimits(account: ManagedAccount): void {
471
- const now = Date.now();
472
- for (const key of Object.keys(account.rateLimitResetTimes)) {
473
- if (account.rateLimitResetTimes[key]! <= now) {
474
- delete account.rateLimitResetTimes[key];
475
- }
378
+ /**
379
+ * Mark a successful request for an account.
380
+ */
381
+ markSuccess(account: ManagedAccount): void {
382
+ account.consecutiveFailures = 0;
383
+ account.lastFailureTime = null;
384
+ this.#healthTracker.recordSuccess(account.index);
476
385
  }
477
- }
478
-
479
- #isRateLimited(account: ManagedAccount): boolean {
480
- this.#clearExpiredRateLimits(account);
481
- const resetTime = account.rateLimitResetTimes[RATE_LIMIT_KEY];
482
- return resetTime !== undefined && Date.now() < resetTime;
483
- }
484
-
485
- /**
486
- * Select the best account for the current request.
487
- */
488
- getCurrentAccount(excludedIndices?: Set<number>): ManagedAccount | null {
489
- if (this.#accounts.length === 0) return null;
490
-
491
- const candidates = this.#accounts
492
- .filter((acc) => acc.enabled && !excludedIndices?.has(acc.index))
493
- .map((acc) => {
494
- this.#clearExpiredRateLimits(acc);
495
- return {
496
- index: acc.index,
497
- lastUsed: acc.lastUsed,
498
- healthScore: this.#healthTracker.getScore(acc.index),
499
- isRateLimited: this.#isRateLimited(acc),
500
- enabled: acc.enabled,
501
- };
502
- });
503
-
504
- const result = selectAccount(
505
- candidates,
506
- this.#config.account_selection_strategy,
507
- this.#currentIndex >= 0 ? this.#currentIndex : null,
508
- this.#healthTracker,
509
- this.#tokenTracker,
510
- this.#cursor,
511
- );
512
-
513
- if (!result) return null;
514
-
515
- this.#cursor = result.cursor;
516
- this.#currentIndex = result.index;
517
-
518
- const account = this.#accounts[result.index];
519
- if (account) {
520
- account.lastUsed = Date.now();
521
- this.#tokenTracker.consume(account.index);
386
+
387
+ /**
388
+ * Mark a general failure (not rate limit) for an account.
389
+ */
390
+ markFailure(account: ManagedAccount): void {
391
+ this.#healthTracker.recordFailure(account.index);
392
+ this.#tokenTracker.refund(account.index);
522
393
  }
523
394
 
524
- return account ?? null;
525
- }
395
+ /**
396
+ * Add a new account to the pool.
397
+ * @returns The new account, or null if at capacity
398
+ */
399
+ addAccount(
400
+ refreshToken: string,
401
+ accessToken: string,
402
+ expires: number,
403
+ email?: string,
404
+ options?: AddAccountOptions,
405
+ ): ManagedAccount | null {
406
+ const identity = resolveManagedAccountIdentity({
407
+ refreshToken,
408
+ email,
409
+ identity: options?.identity,
410
+ label: options?.label,
411
+ source: options?.source ?? "oauth",
412
+ });
413
+ const existing = findMatchingManagedAccount(this.#accounts, {
414
+ identity,
415
+ refreshToken,
416
+ });
417
+
418
+ if (existing) {
419
+ // Refuse to downgrade a CC-sourced row to oauth/legacy. The id prefix
420
+ // `cc-cc-(keychain|file)-` proves the row was born as a CC import; a
421
+ // caller without explicit CC options must only refresh tokens, never
422
+ // reshape source/identity/label/email.
423
+ const isExistingCC = existing.source === "cc-keychain" || existing.source === "cc-file";
424
+ const isNewCC = options?.source === "cc-keychain" || options?.source === "cc-file";
425
+ const isCCBornId = inferCCSourceFromId(existing.id) !== null;
426
+ const callerWouldDowngrade = (isExistingCC || isCCBornId) && !isNewCC;
427
+
428
+ existing.refreshToken = refreshToken;
429
+ existing.access = accessToken;
430
+ existing.expires = expires;
431
+ existing.tokenUpdatedAt = Date.now();
432
+ existing.enabled = true;
433
+
434
+ if (!callerWouldDowngrade) {
435
+ existing.email = email ?? existing.email;
436
+ existing.identity = identity;
437
+ existing.label = options?.label ?? existing.label;
438
+ existing.source = options?.source ?? existing.source ?? "oauth";
439
+ }
440
+
441
+ this.requestSaveToDisk();
442
+ return existing;
443
+ }
526
444
 
527
- /**
528
- * Mark an account as rate-limited.
529
- * @returns The backoff duration in ms
530
- */
531
- markRateLimited(account: ManagedAccount, reason: RateLimitReason, retryAfterMs?: number | null): number {
532
- const now = Date.now();
445
+ if (this.#accounts.length >= MAX_ACCOUNTS) return null;
446
+
447
+ const now = Date.now();
448
+ const account = createManagedAccount({
449
+ id: `${now}:${refreshToken.slice(0, 12)}`,
450
+ index: this.#accounts.length,
451
+ refreshToken,
452
+ access: accessToken,
453
+ expires,
454
+ tokenUpdatedAt: now,
455
+ addedAt: now,
456
+ lastSwitchReason: "initial",
457
+ email,
458
+ identity,
459
+ label: options?.label,
460
+ source: options?.source ?? "oauth",
461
+ });
533
462
 
534
- if (account.lastFailureTime !== null && now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000) {
535
- account.consecutiveFailures = 0;
463
+ this.#accounts.push(account);
464
+
465
+ if (this.#accounts.length === 1) {
466
+ this.#currentIndex = 0;
467
+ }
468
+
469
+ this.requestSaveToDisk();
470
+ return account;
536
471
  }
537
472
 
538
- account.consecutiveFailures += 1;
539
- account.lastFailureTime = now;
540
-
541
- const backoffMs = calculateBackoffMs(reason, account.consecutiveFailures - 1, retryAfterMs);
542
-
543
- account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + backoffMs;
544
-
545
- this.#healthTracker.recordRateLimit(account.index);
546
-
547
- this.requestSaveToDisk();
548
-
549
- return backoffMs;
550
- }
551
-
552
- /**
553
- * Mark a successful request for an account.
554
- */
555
- markSuccess(account: ManagedAccount): void {
556
- account.consecutiveFailures = 0;
557
- account.lastFailureTime = null;
558
- this.#healthTracker.recordSuccess(account.index);
559
- }
560
-
561
- /**
562
- * Mark a general failure (not rate limit) for an account.
563
- */
564
- markFailure(account: ManagedAccount): void {
565
- this.#healthTracker.recordFailure(account.index);
566
- this.#tokenTracker.refund(account.index);
567
- }
568
-
569
- /**
570
- * Add a new account to the pool.
571
- * @returns The new account, or null if at capacity
572
- */
573
- addAccount(
574
- refreshToken: string,
575
- accessToken: string,
576
- expires: number,
577
- email?: string,
578
- options?: AddAccountOptions,
579
- ): ManagedAccount | null {
580
- const identity = resolveAccountIdentity({
581
- refreshToken,
582
- email,
583
- identity: options?.identity,
584
- label: options?.label,
585
- source: options?.source ?? "oauth",
586
- });
587
- const existing = findMatchingAccount(this.#accounts, {
588
- identity,
589
- refreshToken,
590
- });
591
-
592
- if (existing) {
593
- existing.refreshToken = refreshToken;
594
- existing.access = accessToken;
595
- existing.expires = expires;
596
- existing.tokenUpdatedAt = Date.now();
597
- existing.email = email ?? existing.email;
598
- existing.identity = identity;
599
- existing.label = options?.label ?? existing.label;
600
- existing.source = options?.source ?? existing.source ?? "oauth";
601
- existing.enabled = true;
602
- this.requestSaveToDisk();
603
- return existing;
473
+ /**
474
+ * Remove an account by index.
475
+ */
476
+ removeAccount(index: number): boolean {
477
+ if (index < 0 || index >= this.#accounts.length) return false;
478
+
479
+ this.#accounts.splice(index, 1);
480
+
481
+ reindexManagedAccounts(this.#accounts);
482
+
483
+ if (this.#accounts.length === 0) {
484
+ this.#currentIndex = -1;
485
+ this.#cursor = 0;
486
+ } else {
487
+ if (this.#currentIndex >= this.#accounts.length) {
488
+ this.#currentIndex = this.#accounts.length - 1;
489
+ }
490
+ if (this.#cursor > 0) {
491
+ this.#cursor = Math.min(this.#cursor, this.#accounts.length);
492
+ }
493
+ }
494
+
495
+ this.#rebuildTrackers();
496
+ this.requestSaveToDisk();
497
+ return true;
604
498
  }
605
499
 
606
- if (this.#accounts.length >= MAX_ACCOUNTS) return null;
607
-
608
- const now = Date.now();
609
- const account = createManagedAccount({
610
- id: `${now}:${refreshToken.slice(0, 12)}`,
611
- index: this.#accounts.length,
612
- refreshToken,
613
- access: accessToken,
614
- expires,
615
- tokenUpdatedAt: now,
616
- addedAt: now,
617
- lastSwitchReason: "initial",
618
- email,
619
- identity,
620
- label: options?.label,
621
- source: options?.source ?? "oauth",
622
- });
623
-
624
- this.#accounts.push(account);
625
-
626
- if (this.#accounts.length === 1) {
627
- this.#currentIndex = 0;
500
+ /**
501
+ * Toggle an account's enabled state.
502
+ * @returns New enabled state
503
+ */
504
+ toggleAccount(index: number): boolean {
505
+ const account = this.#accounts[index];
506
+ if (!account) return false;
507
+
508
+ account.enabled = !account.enabled;
509
+ this.requestSaveToDisk();
510
+ return account.enabled;
628
511
  }
629
512
 
630
- this.requestSaveToDisk();
631
- return account;
632
- }
633
-
634
- /**
635
- * Remove an account by index.
636
- */
637
- removeAccount(index: number): boolean {
638
- if (index < 0 || index >= this.#accounts.length) return false;
639
-
640
- this.#accounts.splice(index, 1);
641
-
642
- reindexAccounts(this.#accounts);
643
-
644
- if (this.#accounts.length === 0) {
645
- this.#currentIndex = -1;
646
- this.#cursor = 0;
647
- } else {
648
- if (this.#currentIndex >= this.#accounts.length) {
649
- this.#currentIndex = this.#accounts.length - 1;
650
- }
651
- if (this.#cursor > 0) {
652
- this.#cursor = Math.min(this.#cursor, this.#accounts.length);
653
- }
513
+ /**
514
+ * Clear all accounts and reset state.
515
+ */
516
+ clearAll(): void {
517
+ this.#accounts = [];
518
+ this.#currentIndex = -1;
519
+ this.#cursor = 0;
654
520
  }
655
521
 
656
- this.#rebuildTrackers();
657
- this.requestSaveToDisk();
658
- return true;
659
- }
660
-
661
- /**
662
- * Toggle an account's enabled state.
663
- * @returns New enabled state
664
- */
665
- toggleAccount(index: number): boolean {
666
- const account = this.#accounts[index];
667
- if (!account) return false;
668
-
669
- account.enabled = !account.enabled;
670
- this.requestSaveToDisk();
671
- return account.enabled;
672
- }
673
-
674
- /**
675
- * Clear all accounts and reset state.
676
- */
677
- clearAll(): void {
678
- this.#accounts = [];
679
- this.#currentIndex = -1;
680
- this.#cursor = 0;
681
- }
682
-
683
- /**
684
- * Request a debounced save to disk.
685
- */
686
- requestSaveToDisk(): void {
687
- if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
688
- this.#saveTimeout = setTimeout(() => {
689
- this.#saveTimeout = null;
690
- this.saveToDisk().catch((err) => {
691
- if (this.#config.debug) {
692
- // eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
693
- console.error("[opencode-anthropic-auth] saveToDisk failed:", (err as Error).message);
694
- }
695
- });
696
- }, 1000);
697
- }
698
-
699
- /**
700
- * Persist current state to disk immediately.
701
- * Stats use merge-on-save: read disk values, add this instance's deltas,
702
- * write merged result.
703
- */
704
- async saveToDisk(): Promise<void> {
705
- let diskAccountsById: Map<string, AccountMetadata> | null = null;
706
- let diskAccountsByAddedAt: Map<number, AccountMetadata[]> | null = null;
707
- let diskAccountsByRefreshToken: Map<string, AccountMetadata> | null = null;
708
- let diskAccounts: AccountMetadata[] = [];
709
- try {
710
- const diskData = await loadAccounts();
711
- if (diskData) {
712
- diskAccounts = diskData.accounts;
713
- diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
714
- diskAccountsByAddedAt = new Map();
715
- diskAccountsByRefreshToken = new Map();
716
- for (const diskAcc of diskData.accounts) {
717
- const bucket = diskAccountsByAddedAt.get(diskAcc.addedAt) || [];
718
- bucket.push(diskAcc);
719
- diskAccountsByAddedAt.set(diskAcc.addedAt, bucket);
720
- diskAccountsByRefreshToken.set(diskAcc.refreshToken, diskAcc);
721
- }
722
- }
723
- } catch {
724
- // If we can't read, fall through to writing absolute values
522
+ /**
523
+ * Request a debounced save to disk.
524
+ */
525
+ requestSaveToDisk(): void {
526
+ if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
527
+ this.#saveTimeout = setTimeout(() => {
528
+ this.#saveTimeout = null;
529
+ this.saveToDisk().catch((err) => {
530
+ if (this.#config.debug) {
531
+ // eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
532
+ console.error("[opencode-anthropic-auth] saveToDisk failed:", (err as Error).message);
533
+ }
534
+ });
535
+ }, 1000);
725
536
  }
726
537
 
727
- const findDiskAccount = (account: ManagedAccount): AccountMetadata | null => {
728
- const byId = diskAccountsById?.get(account.id);
729
- if (byId) return byId;
730
-
731
- const byIdentity = findByIdentity(diskAccounts, resolveIdentity(account));
732
- if (byIdentity) return byIdentity;
733
-
734
- const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
735
- if (byAddedAt?.length === 1) return byAddedAt[0]!;
736
-
737
- const byToken = diskAccountsByRefreshToken?.get(account.refreshToken);
738
- if (byToken) return byToken;
739
-
740
- if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0]!;
741
- return null;
742
- };
743
-
744
- const matchedDiskAccounts = new Set<AccountMetadata>();
745
- const activeAccountId = this.#accounts[this.#currentIndex]?.id ?? null;
746
- const accountsToPersist = this.#accounts.filter((account) => account.enabled || !!findDiskAccount(account));
747
-
748
- const persistedAccounts = accountsToPersist.map((acc) => {
749
- const delta = this.#statsDeltas.get(acc.id);
750
- let mergedStats = acc.stats;
751
- const diskAcc = findDiskAccount(acc);
752
-
753
- if (diskAcc) {
754
- matchedDiskAccounts.add(diskAcc);
755
- }
756
-
757
- if (delta) {
758
- const diskStats = diskAcc?.stats;
759
-
760
- if (delta.isReset) {
761
- mergedStats = {
762
- requests: delta.requests,
763
- inputTokens: delta.inputTokens,
764
- outputTokens: delta.outputTokens,
765
- cacheReadTokens: delta.cacheReadTokens,
766
- cacheWriteTokens: delta.cacheWriteTokens,
767
- lastReset: delta.resetTimestamp ?? acc.stats.lastReset,
768
- };
769
- } else if (diskStats) {
770
- mergedStats = {
771
- requests: diskStats.requests + delta.requests,
772
- inputTokens: diskStats.inputTokens + delta.inputTokens,
773
- outputTokens: diskStats.outputTokens + delta.outputTokens,
774
- cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
775
- cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
776
- lastReset: diskStats.lastReset,
777
- };
538
+ /**
539
+ * Persist current state to disk immediately.
540
+ * Stats use merge-on-save: read disk values, add this instance's deltas,
541
+ * write merged result.
542
+ */
543
+ async saveToDisk(): Promise<void> {
544
+ let diskData: AccountStorage | null = null;
545
+ try {
546
+ diskData = await loadAccounts();
547
+ } catch {
548
+ // If we can't read, fall through to writing absolute values
778
549
  }
779
- }
780
-
781
- const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
782
- const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
783
- const freshestAuth =
784
- diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt
785
- ? {
786
- refreshToken: diskAcc.refreshToken,
787
- access: diskAcc.access,
788
- expires: diskAcc.expires,
789
- tokenUpdatedAt: diskTokenUpdatedAt,
790
- }
791
- : {
792
- refreshToken: acc.refreshToken,
793
- access: acc.access,
794
- expires: acc.expires,
795
- tokenUpdatedAt: memTokenUpdatedAt,
796
- };
797
-
798
- acc.refreshToken = freshestAuth.refreshToken;
799
- acc.access = freshestAuth.access;
800
- acc.expires = freshestAuth.expires;
801
- acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
802
-
803
- return {
804
- id: acc.id,
805
- email: acc.email,
806
- identity: acc.identity,
807
- label: acc.label,
808
- refreshToken: freshestAuth.refreshToken,
809
- access: freshestAuth.access,
810
- expires: freshestAuth.expires,
811
- token_updated_at: freshestAuth.tokenUpdatedAt,
812
- addedAt: acc.addedAt,
813
- lastUsed: acc.lastUsed,
814
- enabled: acc.enabled,
815
- rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
816
- consecutiveFailures: acc.consecutiveFailures,
817
- lastFailureTime: acc.lastFailureTime,
818
- lastSwitchReason: acc.lastSwitchReason,
819
- stats: mergedStats,
820
- source: acc.source,
821
- };
822
- });
823
-
824
- const diskOnlyAccounts = diskAccounts.filter((account) => !matchedDiskAccounts.has(account));
825
- const allAccounts = accountsToPersist.length > 0 ? [...persistedAccounts, ...diskOnlyAccounts] : persistedAccounts;
826
- const resolvedActiveIndex = activeAccountId
827
- ? allAccounts.findIndex((account) => account.id === activeAccountId)
828
- : -1;
829
-
830
- const storage: AccountStorage = {
831
- version: 1,
832
- accounts: allAccounts,
833
- activeIndex:
834
- resolvedActiveIndex >= 0
835
- ? resolvedActiveIndex
836
- : allAccounts.length > 0
837
- ? Math.max(0, Math.min(this.#currentIndex, allAccounts.length - 1))
838
- : 0,
839
- };
840
-
841
- await saveAccounts(storage);
842
-
843
- this.#statsDeltas.clear();
844
-
845
- for (const saved of storage.accounts) {
846
- const acc = this.#accounts.find((a) => a.id === saved.id);
847
- if (acc) {
848
- acc.stats = saved.stats;
849
- }
850
- }
851
- }
852
-
853
- /**
854
- * Sync activeIndex from disk (picks up CLI changes while OpenCode is running).
855
- */
856
- async syncActiveIndexFromDisk(): Promise<void> {
857
- const stored = await loadAccounts();
858
- if (!stored) return;
859
-
860
- const matchedAccounts = new Set<ManagedAccount>();
861
- const reconciledAccounts: ManagedAccount[] = [];
862
- let structuralChange = false;
863
-
864
- for (const [index, storedAccount] of stored.accounts.entries()) {
865
- const existing = findMatchingAccount(this.#accounts, {
866
- id: storedAccount.id,
867
- identity: resolveIdentity(storedAccount),
868
- refreshToken: storedAccount.refreshToken,
869
- });
870
-
871
- if (existing) {
872
- updateManagedAccountFromStorage(existing, storedAccount, index);
873
- matchedAccounts.add(existing);
874
- reconciledAccounts.push(existing);
875
- continue;
876
- }
877
-
878
- const addedAccount = createManagedAccount({
879
- id: storedAccount.id,
880
- index,
881
- email: storedAccount.email,
882
- identity: storedAccount.identity,
883
- label: storedAccount.label,
884
- refreshToken: storedAccount.refreshToken,
885
- access: storedAccount.access,
886
- expires: storedAccount.expires,
887
- tokenUpdatedAt: storedAccount.token_updated_at,
888
- addedAt: storedAccount.addedAt,
889
- lastUsed: storedAccount.lastUsed,
890
- enabled: storedAccount.enabled,
891
- rateLimitResetTimes: storedAccount.rateLimitResetTimes,
892
- consecutiveFailures: storedAccount.consecutiveFailures,
893
- lastFailureTime: storedAccount.lastFailureTime,
894
- lastSwitchReason: storedAccount.lastSwitchReason,
895
- stats: storedAccount.stats,
896
- source: storedAccount.source || "oauth",
897
- });
898
- matchedAccounts.add(addedAccount);
899
- reconciledAccounts.push(addedAccount);
900
- structuralChange = true;
901
- }
902
550
 
903
- for (const account of this.#accounts) {
904
- if (matchedAccounts.has(account)) {
905
- continue;
906
- }
551
+ const droppedIdsSnapshot = new Set(this.#pendingDroppedIds);
552
+ const prepared = prepareStorageForSave({
553
+ accounts: this.#accounts,
554
+ currentIndex: this.#currentIndex,
555
+ statsDeltas: this.#statsDeltas,
556
+ diskData,
557
+ droppedIds: droppedIdsSnapshot,
558
+ });
559
+
560
+ await saveAccounts(prepared.storage, { droppedIds: droppedIdsSnapshot });
907
561
 
908
- if (account.enabled) {
909
- account.enabled = false;
910
- structuralChange = true;
911
- }
562
+ this.#statsDeltas.clear();
563
+ this.#pendingDroppedIds.clear();
912
564
 
913
- reconciledAccounts.push(account);
565
+ for (const [id, persistedState] of prepared.persistedStateById.entries()) {
566
+ const account = this.#accounts.find((candidate) => candidate.id === id);
567
+ if (account) {
568
+ account.stats = persistedState.stats;
569
+ }
570
+ }
914
571
  }
915
572
 
916
- const orderChanged =
917
- reconciledAccounts.length !== this.#accounts.length ||
918
- reconciledAccounts.some((account, index) => this.#accounts[index] !== account);
573
+ /**
574
+ * Sync activeIndex from disk (picks up CLI changes while OpenCode is running).
575
+ */
576
+ async syncActiveIndexFromDisk(): Promise<void> {
577
+ const stored = await loadAccounts();
578
+ if (!stored) return;
579
+
580
+ const reconciled = reconcileManagedAccountsWithStorage({
581
+ accounts: this.#accounts,
582
+ stored,
583
+ currentIndex: this.#currentIndex,
584
+ statsDeltaIds: this.#statsDeltas.keys(),
585
+ });
919
586
 
920
- this.#accounts = reconciledAccounts;
921
- reindexAccounts(this.#accounts);
587
+ this.#accounts = reconciled.accounts;
588
+ reindexManagedAccounts(this.#accounts);
922
589
 
923
- if (orderChanged || structuralChange) {
924
- this.#rebuildTrackers();
925
- }
590
+ if (reconciled.shouldRebuildTrackers) {
591
+ this.#rebuildTrackers();
592
+ }
926
593
 
927
- const currentIds = new Set(this.#accounts.map((account) => account.id));
928
- for (const id of this.#statsDeltas.keys()) {
929
- if (!currentIds.has(id)) {
930
- this.#statsDeltas.delete(id);
931
- }
932
- }
594
+ for (const id of reconciled.staleDeltaIds) {
595
+ this.#statsDeltas.delete(id);
596
+ }
933
597
 
934
- const enabledAccounts = this.#accounts.filter((account) => account.enabled);
935
- if (enabledAccounts.length === 0) {
936
- this.#currentIndex = -1;
937
- this.#cursor = 0;
938
- return;
939
- }
598
+ this.#currentIndex = reconciled.currentIndex;
599
+ this.#cursor = reconciled.cursor;
940
600
 
941
- const diskIndex = Math.min(stored.activeIndex, stored.accounts.length - 1);
942
- const diskAccount = diskIndex >= 0 ? stored.accounts[diskIndex] : undefined;
943
- if (!diskAccount || !diskAccount.enabled) {
944
- if (!this.#accounts[this.#currentIndex]?.enabled) {
945
- const fallback = enabledAccounts[0]!;
946
- this.#currentIndex = fallback.index;
947
- this.#cursor = fallback.index;
948
- }
949
- return;
601
+ if (reconciled.resetHealthTrackerIndex !== null) {
602
+ this.#healthTracker.reset(reconciled.resetHealthTrackerIndex);
603
+ }
950
604
  }
951
605
 
952
- const activeAccount = findMatchingAccount(this.#accounts, {
953
- id: diskAccount.id,
954
- identity: resolveIdentity(diskAccount),
955
- refreshToken: diskAccount.refreshToken,
956
- });
606
+ /**
607
+ * Record token usage for an account after a successful API response.
608
+ */
609
+ recordUsage(
610
+ index: number,
611
+ usage: {
612
+ inputTokens?: number;
613
+ outputTokens?: number;
614
+ cacheReadTokens?: number;
615
+ cacheWriteTokens?: number;
616
+ },
617
+ ): void {
618
+ const account = this.#accounts[index];
619
+ if (!account) return;
620
+
621
+ const inTok = usage.inputTokens || 0;
622
+ const outTok = usage.outputTokens || 0;
623
+ const crTok = usage.cacheReadTokens || 0;
624
+ const cwTok = usage.cacheWriteTokens || 0;
625
+
626
+ account.stats.requests += 1;
627
+ account.stats.inputTokens += inTok;
628
+ account.stats.outputTokens += outTok;
629
+ account.stats.cacheReadTokens += crTok;
630
+ account.stats.cacheWriteTokens += cwTok;
631
+
632
+ const delta = this.#statsDeltas.get(account.id);
633
+ if (delta) {
634
+ delta.requests += 1;
635
+ delta.inputTokens += inTok;
636
+ delta.outputTokens += outTok;
637
+ delta.cacheReadTokens += crTok;
638
+ delta.cacheWriteTokens += cwTok;
639
+ } else {
640
+ if (this.#statsDeltas.size >= this.#MAX_STATS_DELTAS) {
641
+ this.saveToDisk().catch((err) => {
642
+ if (this.#config.debug) {
643
+ // eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
644
+ console.error(
645
+ "[opencode-anthropic-auth] forced statsDeltas flush failed:",
646
+ (err as Error).message,
647
+ );
648
+ }
649
+ });
650
+ }
651
+ this.#statsDeltas.set(account.id, {
652
+ requests: 1,
653
+ inputTokens: inTok,
654
+ outputTokens: outTok,
655
+ cacheReadTokens: crTok,
656
+ cacheWriteTokens: cwTok,
657
+ isReset: false,
658
+ });
659
+ }
957
660
 
958
- if (activeAccount && activeAccount.enabled && activeAccount.index !== this.#currentIndex) {
959
- this.#currentIndex = activeAccount.index;
960
- this.#cursor = activeAccount.index;
961
- this.#healthTracker.reset(activeAccount.index);
661
+ this.requestSaveToDisk();
962
662
  }
963
- }
964
-
965
- /**
966
- * Record token usage for an account after a successful API response.
967
- */
968
- recordUsage(
969
- index: number,
970
- usage: {
971
- inputTokens?: number;
972
- outputTokens?: number;
973
- cacheReadTokens?: number;
974
- cacheWriteTokens?: number;
975
- },
976
- ): void {
977
- const account = this.#accounts[index];
978
- if (!account) return;
979
-
980
- const inTok = usage.inputTokens || 0;
981
- const outTok = usage.outputTokens || 0;
982
- const crTok = usage.cacheReadTokens || 0;
983
- const cwTok = usage.cacheWriteTokens || 0;
984
-
985
- account.stats.requests += 1;
986
- account.stats.inputTokens += inTok;
987
- account.stats.outputTokens += outTok;
988
- account.stats.cacheReadTokens += crTok;
989
- account.stats.cacheWriteTokens += cwTok;
990
-
991
- const delta = this.#statsDeltas.get(account.id);
992
- if (delta) {
993
- delta.requests += 1;
994
- delta.inputTokens += inTok;
995
- delta.outputTokens += outTok;
996
- delta.cacheReadTokens += crTok;
997
- delta.cacheWriteTokens += cwTok;
998
- } else {
999
- if (this.#statsDeltas.size >= this.#MAX_STATS_DELTAS) {
1000
- this.saveToDisk().catch((err) => {
1001
- if (this.#config.debug) {
1002
- // eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
1003
- console.error("[opencode-anthropic-auth] forced statsDeltas flush failed:", (err as Error).message);
1004
- }
1005
- });
1006
- }
1007
- this.#statsDeltas.set(account.id, {
1008
- requests: 1,
1009
- inputTokens: inTok,
1010
- outputTokens: outTok,
1011
- cacheReadTokens: crTok,
1012
- cacheWriteTokens: cwTok,
1013
- isReset: false,
1014
- });
663
+
664
+ /**
665
+ * Reset stats for a specific account or all accounts.
666
+ */
667
+ resetStats(target: number | "all"): void {
668
+ const now = Date.now();
669
+ const resetAccount = (acc: ManagedAccount) => {
670
+ acc.stats = createDefaultStats(now);
671
+ this.#statsDeltas.set(acc.id, {
672
+ requests: 0,
673
+ inputTokens: 0,
674
+ outputTokens: 0,
675
+ cacheReadTokens: 0,
676
+ cacheWriteTokens: 0,
677
+ isReset: true,
678
+ resetTimestamp: now,
679
+ });
680
+ };
681
+
682
+ if (target === "all") {
683
+ for (const acc of this.#accounts) {
684
+ resetAccount(acc);
685
+ }
686
+ } else {
687
+ const account = this.#accounts[target];
688
+ if (account) {
689
+ resetAccount(account);
690
+ }
691
+ }
692
+ this.requestSaveToDisk();
1015
693
  }
1016
694
 
1017
- this.requestSaveToDisk();
1018
- }
1019
-
1020
- /**
1021
- * Reset stats for a specific account or all accounts.
1022
- */
1023
- resetStats(target: number | "all"): void {
1024
- const now = Date.now();
1025
- const resetAccount = (acc: ManagedAccount) => {
1026
- acc.stats = createDefaultStats(now);
1027
- this.#statsDeltas.set(acc.id, {
1028
- requests: 0,
1029
- inputTokens: 0,
1030
- outputTokens: 0,
1031
- cacheReadTokens: 0,
1032
- cacheWriteTokens: 0,
1033
- isReset: true,
1034
- resetTimestamp: now,
1035
- });
1036
- };
1037
-
1038
- if (target === "all") {
1039
- for (const acc of this.#accounts) {
1040
- resetAccount(acc);
1041
- }
1042
- } else {
1043
- const account = this.#accounts[target];
1044
- if (account) {
1045
- resetAccount(account);
1046
- }
695
+ /**
696
+ * Convert a managed account to the format expected by OpenCode's auth.json.
697
+ */
698
+ toAuthDetails(account: ManagedAccount): {
699
+ type: "oauth";
700
+ refresh: string;
701
+ access: string | undefined;
702
+ expires: number | undefined;
703
+ } {
704
+ return {
705
+ type: "oauth",
706
+ refresh: account.refreshToken,
707
+ access: account.access,
708
+ expires: account.expires,
709
+ };
1047
710
  }
1048
- this.requestSaveToDisk();
1049
- }
1050
-
1051
- /**
1052
- * Convert a managed account to the format expected by OpenCode's auth.json.
1053
- */
1054
- toAuthDetails(account: ManagedAccount): {
1055
- type: "oauth";
1056
- refresh: string;
1057
- access: string | undefined;
1058
- expires: number | undefined;
1059
- } {
1060
- return {
1061
- type: "oauth",
1062
- refresh: account.refreshToken,
1063
- access: account.access,
1064
- expires: account.expires,
1065
- };
1066
- }
1067
711
  }