@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1

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 (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/src/accounts.ts CHANGED
@@ -1,15 +1,23 @@
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 {
5
+ findByIdentity,
6
+ resolveIdentity,
7
+ resolveIdentityFromCCCredential,
8
+ type AccountIdentity,
9
+ } from "./account-identity.js";
4
10
  import type { AnthropicAuthConfig } from "./config.js";
5
11
  import { HealthScoreTracker, selectAccount, TokenBucketTracker } from "./rotation.js";
6
- import type { AccountMetadata, AccountStorage } from "./storage.js";
12
+ import type { AccountMetadata, AccountStats, AccountStorage } from "./storage.js";
7
13
  import { createDefaultStats, loadAccounts, saveAccounts } from "./storage.js";
8
14
 
9
15
  export interface ManagedAccount {
10
16
  id: string;
11
17
  index: number;
12
18
  email?: string;
19
+ identity?: AccountIdentity;
20
+ label?: string;
13
21
  refreshToken: string;
14
22
  access?: string;
15
23
  expires?: number;
@@ -21,7 +29,7 @@ export interface ManagedAccount {
21
29
  consecutiveFailures: number;
22
30
  lastFailureTime: number | null;
23
31
  lastSwitchReason?: string;
24
- stats: import("./storage.js").AccountStats;
32
+ stats: AccountStats;
25
33
  source?: "cc-keychain" | "cc-file" | "oauth";
26
34
  }
27
35
 
@@ -40,6 +48,167 @@ export interface StatsDelta {
40
48
  const MAX_ACCOUNTS = 10;
41
49
  const RATE_LIMIT_KEY = "anthropic";
42
50
 
51
+ type ManagedAccountSource = ManagedAccount["source"];
52
+
53
+ type AddAccountOptions = {
54
+ identity?: AccountIdentity;
55
+ label?: string;
56
+ source?: ManagedAccountSource;
57
+ };
58
+
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
+ };
80
+
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
+ }
112
+
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
+ }
149
+
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
+ }
174
+
175
+ function reindexAccounts(accounts: ManagedAccount[]): void {
176
+ accounts.forEach((account, index) => {
177
+ account.index = index;
178
+ });
179
+ }
180
+
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
+ }
211
+
43
212
  export class AccountManager {
44
213
  #accounts: ManagedAccount[] = [];
45
214
  #cursor = 0;
@@ -49,6 +218,13 @@ export class AccountManager {
49
218
  #config: AnthropicAuthConfig;
50
219
  #saveTimeout: ReturnType<typeof setTimeout> | null = null;
51
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;
52
228
 
53
229
  constructor(config: AnthropicAuthConfig) {
54
230
  this.#config = config;
@@ -56,6 +232,11 @@ export class AccountManager {
56
232
  this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
57
233
  }
58
234
 
235
+ #rebuildTrackers(): void {
236
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
237
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
238
+ }
239
+
59
240
  /**
60
241
  * Load accounts from disk, optionally merging with an OpenCode auth fallback.
61
242
  */
@@ -72,30 +253,41 @@ export class AccountManager {
72
253
 
73
254
  // If storage exists (even with zero accounts), treat disk as authoritative.
74
255
  if (stored) {
75
- manager.#accounts = stored.accounts.map((acc, index) => ({
76
- id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
77
- index,
78
- email: acc.email,
79
- refreshToken: acc.refreshToken,
80
- access: acc.access,
81
- expires: acc.expires,
82
- tokenUpdatedAt: acc.token_updated_at,
83
- addedAt: acc.addedAt,
84
- lastUsed: acc.lastUsed,
85
- enabled: acc.enabled,
86
- rateLimitResetTimes: acc.rateLimitResetTimes,
87
- consecutiveFailures: acc.consecutiveFailures,
88
- lastFailureTime: acc.lastFailureTime,
89
- lastSwitchReason: acc.lastSwitchReason,
90
- stats: acc.stats ?? createDefaultStats(acc.addedAt),
91
- source: acc.source || "oauth",
92
- }));
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
+ );
93
278
 
94
279
  manager.#currentIndex =
95
280
  manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
96
281
 
97
282
  if (authFallback && manager.#accounts.length > 0) {
98
- const match = manager.#accounts.find((acc) => acc.refreshToken === authFallback.refresh);
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
+ });
99
291
  if (match) {
100
292
  const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
101
293
  const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
@@ -115,23 +307,17 @@ export class AccountManager {
115
307
  } else if (authFallback && authFallback.refresh) {
116
308
  const now = Date.now();
117
309
  manager.#accounts = [
118
- {
310
+ createManagedAccount({
119
311
  id: `${now}:${authFallback.refresh.slice(0, 12)}`,
120
312
  index: 0,
121
- email: undefined,
122
313
  refreshToken: authFallback.refresh,
123
314
  access: authFallback.access,
124
315
  expires: authFallback.expires,
125
316
  tokenUpdatedAt: now,
126
317
  addedAt: now,
127
- lastUsed: 0,
128
- enabled: true,
129
- rateLimitResetTimes: {},
130
- consecutiveFailures: 0,
131
- lastFailureTime: null,
132
318
  lastSwitchReason: "initial",
133
- stats: createDefaultStats(now),
134
- },
319
+ source: "oauth",
320
+ }),
135
321
  ];
136
322
  manager.#currentIndex = 0;
137
323
  }
@@ -147,19 +333,38 @@ export class AccountManager {
147
333
  })();
148
334
 
149
335
  for (const ccCredential of ccCredentials) {
150
- const existingMatch = manager.#accounts.find(
151
- (account) => account.refreshToken === ccCredential.refreshToken,
152
- );
153
- if (existingMatch) {
154
- // Adopt CC source tag so getCCAccounts() recognizes it
155
- if (!existingMatch.source || existingMatch.source === "oauth") {
156
- existingMatch.source = ccCredential.source;
336
+ const ccIdentity = resolveIdentityFromCCCredential(ccCredential);
337
+ let existingMatch = findMatchingAccount(manager.#accounts, {
338
+ identity: ccIdentity,
339
+ refreshToken: ccCredential.refreshToken,
340
+ });
341
+
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]!;
157
348
  }
158
- // Adopt fresh access token from CC if available
159
- if (ccCredential.accessToken && ccCredential.expiresAt > (existingMatch.expires ?? 0)) {
349
+ }
350
+
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) {
160
358
  existingMatch.access = ccCredential.accessToken;
359
+ }
360
+ if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
161
361
  existingMatch.expires = ccCredential.expiresAt;
162
362
  }
363
+ existingMatch.tokenUpdatedAt = Math.max(existingMatch.tokenUpdatedAt || 0, ccCredential.expiresAt || 0);
364
+ continue;
365
+ }
366
+
367
+ if (manager.#accounts.length >= MAX_ACCOUNTS) {
163
368
  continue;
164
369
  }
165
370
 
@@ -172,24 +377,19 @@ export class AccountManager {
172
377
  }
173
378
 
174
379
  const now = Date.now();
175
- const ccAccount: ManagedAccount = {
380
+ const ccAccount = createManagedAccount({
176
381
  id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
177
382
  index: manager.#accounts.length,
178
- email: undefined,
179
383
  refreshToken: ccCredential.refreshToken,
180
384
  access: ccCredential.accessToken,
181
385
  expires: ccCredential.expiresAt,
182
386
  tokenUpdatedAt: now,
183
387
  addedAt: now,
184
- lastUsed: 0,
185
- enabled: true,
186
- rateLimitResetTimes: {},
187
- consecutiveFailures: 0,
188
- lastFailureTime: null,
388
+ identity: ccIdentity,
389
+ label: ccCredential.label,
189
390
  lastSwitchReason: "cc-auto-detected",
190
- stats: createDefaultStats(now),
191
391
  source: ccCredential.source,
192
- };
392
+ });
193
393
 
194
394
  manager.#accounts.push(ccAccount);
195
395
  }
@@ -198,9 +398,7 @@ export class AccountManager {
198
398
  manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
199
399
  }
200
400
 
201
- manager.#accounts.forEach((account, index) => {
202
- account.index = index;
203
- });
401
+ reindexAccounts(manager.#accounts);
204
402
 
205
403
  if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
206
404
  manager.#currentIndex = 0;
@@ -372,37 +570,56 @@ export class AccountManager {
372
570
  * Add a new account to the pool.
373
571
  * @returns The new account, or null if at capacity
374
572
  */
375
- addAccount(refreshToken: string, accessToken: string, expires: number, email?: string): ManagedAccount | null {
376
- if (this.#accounts.length >= MAX_ACCOUNTS) return null;
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
+ });
377
591
 
378
- const existing = this.#accounts.find((acc) => acc.refreshToken === refreshToken);
379
592
  if (existing) {
593
+ existing.refreshToken = refreshToken;
380
594
  existing.access = accessToken;
381
595
  existing.expires = expires;
382
596
  existing.tokenUpdatedAt = Date.now();
383
- if (email) existing.email = email;
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";
384
601
  existing.enabled = true;
602
+ this.requestSaveToDisk();
385
603
  return existing;
386
604
  }
387
605
 
606
+ if (this.#accounts.length >= MAX_ACCOUNTS) return null;
607
+
388
608
  const now = Date.now();
389
- const account: ManagedAccount = {
609
+ const account = createManagedAccount({
390
610
  id: `${now}:${refreshToken.slice(0, 12)}`,
391
611
  index: this.#accounts.length,
392
- email,
393
612
  refreshToken,
394
613
  access: accessToken,
395
614
  expires,
396
615
  tokenUpdatedAt: now,
397
616
  addedAt: now,
398
- lastUsed: 0,
399
- enabled: true,
400
- rateLimitResetTimes: {},
401
- consecutiveFailures: 0,
402
- lastFailureTime: null,
403
617
  lastSwitchReason: "initial",
404
- stats: createDefaultStats(now),
405
- };
618
+ email,
619
+ identity,
620
+ label: options?.label,
621
+ source: options?.source ?? "oauth",
622
+ });
406
623
 
407
624
  this.#accounts.push(account);
408
625
 
@@ -422,9 +639,7 @@ export class AccountManager {
422
639
 
423
640
  this.#accounts.splice(index, 1);
424
641
 
425
- this.#accounts.forEach((acc, i) => {
426
- acc.index = i;
427
- });
642
+ reindexAccounts(this.#accounts);
428
643
 
429
644
  if (this.#accounts.length === 0) {
430
645
  this.#currentIndex = -1;
@@ -438,9 +653,7 @@ export class AccountManager {
438
653
  }
439
654
  }
440
655
 
441
- for (let i = 0; i < this.#accounts.length; i++) {
442
- this.#healthTracker.reset(i);
443
- }
656
+ this.#rebuildTrackers();
444
657
  this.requestSaveToDisk();
445
658
  return true;
446
659
  }
@@ -474,7 +687,12 @@ export class AccountManager {
474
687
  if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
475
688
  this.#saveTimeout = setTimeout(() => {
476
689
  this.#saveTimeout = null;
477
- this.saveToDisk().catch(() => {});
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
+ });
478
696
  }, 1000);
479
697
  }
480
698
 
@@ -487,9 +705,11 @@ export class AccountManager {
487
705
  let diskAccountsById: Map<string, AccountMetadata> | null = null;
488
706
  let diskAccountsByAddedAt: Map<number, AccountMetadata[]> | null = null;
489
707
  let diskAccountsByRefreshToken: Map<string, AccountMetadata> | null = null;
708
+ let diskAccounts: AccountMetadata[] = [];
490
709
  try {
491
710
  const diskData = await loadAccounts();
492
711
  if (diskData) {
712
+ diskAccounts = diskData.accounts;
493
713
  diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
494
714
  diskAccountsByAddedAt = new Map();
495
715
  diskAccountsByRefreshToken = new Map();
@@ -508,6 +728,9 @@ export class AccountManager {
508
728
  const byId = diskAccountsById?.get(account.id);
509
729
  if (byId) return byId;
510
730
 
731
+ const byIdentity = findByIdentity(diskAccounts, resolveIdentity(account));
732
+ if (byIdentity) return byIdentity;
733
+
511
734
  const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
512
735
  if (byAddedAt?.length === 1) return byAddedAt[0]!;
513
736
 
@@ -518,78 +741,101 @@ export class AccountManager {
518
741
  return null;
519
742
  };
520
743
 
521
- const storage: AccountStorage = {
522
- version: 1,
523
- accounts: this.#accounts.map((acc) => {
524
- const delta = this.#statsDeltas.get(acc.id);
525
- let mergedStats = acc.stats;
526
- const diskAcc = findDiskAccount(acc);
527
-
528
- if (delta) {
529
- const diskStats = diskAcc?.stats;
530
-
531
- if (delta.isReset) {
532
- mergedStats = {
533
- requests: delta.requests,
534
- inputTokens: delta.inputTokens,
535
- outputTokens: delta.outputTokens,
536
- cacheReadTokens: delta.cacheReadTokens,
537
- cacheWriteTokens: delta.cacheWriteTokens,
538
- lastReset: delta.resetTimestamp ?? acc.stats.lastReset,
539
- };
540
- } else if (diskStats) {
541
- mergedStats = {
542
- requests: diskStats.requests + delta.requests,
543
- inputTokens: diskStats.inputTokens + delta.inputTokens,
544
- outputTokens: diskStats.outputTokens + delta.outputTokens,
545
- cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
546
- cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
547
- lastReset: diskStats.lastReset,
548
- };
549
- }
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
+ };
550
778
  }
779
+ }
551
780
 
552
- const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
553
- const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
554
- const freshestAuth =
555
- diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt
556
- ? {
557
- refreshToken: diskAcc.refreshToken,
558
- access: diskAcc.access,
559
- expires: diskAcc.expires,
560
- tokenUpdatedAt: diskTokenUpdatedAt,
561
- }
562
- : {
563
- refreshToken: acc.refreshToken,
564
- access: acc.access,
565
- expires: acc.expires,
566
- tokenUpdatedAt: memTokenUpdatedAt,
567
- };
568
-
569
- acc.refreshToken = freshestAuth.refreshToken;
570
- acc.access = freshestAuth.access;
571
- acc.expires = freshestAuth.expires;
572
- acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
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
+ };
573
797
 
574
- return {
575
- id: acc.id,
576
- email: acc.email,
577
- refreshToken: freshestAuth.refreshToken,
578
- access: freshestAuth.access,
579
- expires: freshestAuth.expires,
580
- token_updated_at: freshestAuth.tokenUpdatedAt,
581
- addedAt: acc.addedAt,
582
- lastUsed: acc.lastUsed,
583
- enabled: acc.enabled,
584
- rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
585
- consecutiveFailures: acc.consecutiveFailures,
586
- lastFailureTime: acc.lastFailureTime,
587
- lastSwitchReason: acc.lastSwitchReason,
588
- stats: mergedStats,
589
- source: acc.source,
590
- };
591
- }),
592
- activeIndex: Math.max(0, this.#currentIndex),
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,
593
839
  };
594
840
 
595
841
  await saveAccounts(storage);
@@ -611,68 +857,108 @@ export class AccountManager {
611
857
  const stored = await loadAccounts();
612
858
  if (!stored) return;
613
859
 
614
- const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
615
- const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
860
+ const matchedAccounts = new Set<ManagedAccount>();
861
+ const reconciledAccounts: ManagedAccount[] = [];
862
+ let structuralChange = false;
616
863
 
617
- const diskSnapshot = stored.accounts
618
- .map((acc) => {
619
- const resolvedId = acc.id || existingByTokenForSnapshot.get(acc.refreshToken)?.id || acc.refreshToken;
620
- return `${resolvedId}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`;
621
- })
622
- .join("|");
623
-
624
- if (diskSnapshot !== memSnapshot) {
625
- const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
626
- const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
627
-
628
- this.#accounts = stored.accounts.map((acc, index) => {
629
- const existing =
630
- (acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
631
- return {
632
- id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
633
- index,
634
- email: acc.email ?? existing?.email,
635
- refreshToken: acc.refreshToken,
636
- access: acc.access ?? existing?.access,
637
- expires: acc.expires ?? existing?.expires,
638
- tokenUpdatedAt: acc.token_updated_at ?? existing?.tokenUpdatedAt ?? acc.addedAt,
639
- addedAt: acc.addedAt,
640
- lastUsed: acc.lastUsed,
641
- enabled: acc.enabled,
642
- rateLimitResetTimes: acc.rateLimitResetTimes,
643
- consecutiveFailures: acc.consecutiveFailures,
644
- lastFailureTime: acc.lastFailureTime,
645
- lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
646
- stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
647
- };
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",
648
897
  });
898
+ matchedAccounts.add(addedAccount);
899
+ reconciledAccounts.push(addedAccount);
900
+ structuralChange = true;
901
+ }
649
902
 
650
- this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
651
- this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
903
+ for (const account of this.#accounts) {
904
+ if (matchedAccounts.has(account)) {
905
+ continue;
906
+ }
652
907
 
653
- const currentIds = new Set(this.#accounts.map((a) => a.id));
654
- for (const id of this.#statsDeltas.keys()) {
655
- if (!currentIds.has(id)) this.#statsDeltas.delete(id);
908
+ if (account.enabled) {
909
+ account.enabled = false;
910
+ structuralChange = true;
656
911
  }
657
912
 
658
- if (this.#accounts.length === 0) {
659
- this.#currentIndex = -1;
660
- this.#cursor = 0;
661
- return;
913
+ reconciledAccounts.push(account);
914
+ }
915
+
916
+ const orderChanged =
917
+ reconciledAccounts.length !== this.#accounts.length ||
918
+ reconciledAccounts.some((account, index) => this.#accounts[index] !== account);
919
+
920
+ this.#accounts = reconciledAccounts;
921
+ reindexAccounts(this.#accounts);
922
+
923
+ if (orderChanged || structuralChange) {
924
+ this.#rebuildTrackers();
925
+ }
926
+
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);
662
931
  }
663
932
  }
664
933
 
665
- const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
666
- if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
667
- const diskAccount = stored.accounts[diskIndex];
668
- if (!diskAccount || !diskAccount.enabled) return;
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
+ }
669
940
 
670
- const account = this.#accounts[diskIndex];
671
- if (account && account.enabled) {
672
- this.#currentIndex = diskIndex;
673
- this.#cursor = diskIndex;
674
- this.#healthTracker.reset(diskIndex);
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;
675
948
  }
949
+ return;
950
+ }
951
+
952
+ const activeAccount = findMatchingAccount(this.#accounts, {
953
+ id: diskAccount.id,
954
+ identity: resolveIdentity(diskAccount),
955
+ refreshToken: diskAccount.refreshToken,
956
+ });
957
+
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);
676
962
  }
677
963
  }
678
964
 
@@ -710,6 +996,14 @@ export class AccountManager {
710
996
  delta.cacheReadTokens += crTok;
711
997
  delta.cacheWriteTokens += cwTok;
712
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
+ }
713
1007
  this.#statsDeltas.set(account.id, {
714
1008
  requests: 1,
715
1009
  inputTokens: inTok,