@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
@@ -31,6 +31,84 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  mod
32
32
  ));
33
33
 
34
+ // src/account-identity.ts
35
+ function isCCAccountSource(source) {
36
+ return source === "cc-keychain" || source === "cc-file";
37
+ }
38
+ function isAccountIdentity(value) {
39
+ if (!value || typeof value !== "object") return false;
40
+ const candidate = value;
41
+ switch (candidate.kind) {
42
+ case "oauth":
43
+ return typeof candidate.email === "string" && candidate.email.length > 0;
44
+ case "cc":
45
+ return isCCAccountSource(candidate.source) && typeof candidate.label === "string";
46
+ case "legacy":
47
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
48
+ default:
49
+ return false;
50
+ }
51
+ }
52
+ function resolveIdentity(account) {
53
+ if (isAccountIdentity(account.identity)) {
54
+ return account.identity;
55
+ }
56
+ if (account.source === "oauth" && account.email) {
57
+ return { kind: "oauth", email: account.email };
58
+ }
59
+ if (isCCAccountSource(account.source) && account.label) {
60
+ return { kind: "cc", source: account.source, label: account.label };
61
+ }
62
+ return { kind: "legacy", refreshToken: account.refreshToken };
63
+ }
64
+ function resolveIdentityFromCCCredential(cred) {
65
+ return {
66
+ kind: "cc",
67
+ source: cred.source,
68
+ label: cred.label
69
+ };
70
+ }
71
+ function resolveIdentityFromOAuthExchange(result) {
72
+ if (result.email) {
73
+ return {
74
+ kind: "oauth",
75
+ email: result.email
76
+ };
77
+ }
78
+ return {
79
+ kind: "legacy",
80
+ refreshToken: result.refresh
81
+ };
82
+ }
83
+ function identitiesMatch(a, b) {
84
+ if (a.kind !== b.kind) return false;
85
+ switch (a.kind) {
86
+ case "oauth": {
87
+ return a.email === b.email;
88
+ }
89
+ case "cc": {
90
+ const ccIdentity = b;
91
+ return a.source === ccIdentity.source && a.label === ccIdentity.label;
92
+ }
93
+ case "legacy": {
94
+ return a.refreshToken === b.refreshToken;
95
+ }
96
+ }
97
+ }
98
+ function findByIdentity(accounts, id) {
99
+ for (const account of accounts) {
100
+ if (identitiesMatch(resolveIdentity(account), id)) {
101
+ return account;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ var init_account_identity = __esm({
107
+ "src/account-identity.ts"() {
108
+ "use strict";
109
+ }
110
+ });
111
+
34
112
  // src/config.ts
35
113
  import { existsSync, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
36
114
  import { homedir as homedir2 } from "node:os";
@@ -404,6 +482,8 @@ function validateAccount(raw, now) {
404
482
  return {
405
483
  id,
406
484
  email: typeof acc.email === "string" ? acc.email : void 0,
485
+ identity: isAccountIdentity2(acc.identity) ? acc.identity : void 0,
486
+ label: typeof acc.label === "string" ? acc.label : void 0,
407
487
  refreshToken: acc.refreshToken,
408
488
  access: typeof acc.access === "string" ? acc.access : void 0,
409
489
  expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : void 0,
@@ -419,6 +499,106 @@ function validateAccount(raw, now) {
419
499
  source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : void 0
420
500
  };
421
501
  }
502
+ function isAccountIdentity2(value) {
503
+ if (!value || typeof value !== "object") return false;
504
+ const candidate = value;
505
+ switch (candidate.kind) {
506
+ case "oauth":
507
+ return typeof candidate.email === "string" && candidate.email.length > 0;
508
+ case "cc":
509
+ return (candidate.source === "cc-keychain" || candidate.source === "cc-file") && typeof candidate.label === "string";
510
+ case "legacy":
511
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
512
+ default:
513
+ return false;
514
+ }
515
+ }
516
+ function resolveStoredIdentity(candidate) {
517
+ if (isAccountIdentity2(candidate.identity)) {
518
+ return candidate.identity;
519
+ }
520
+ if (candidate.source === "oauth" && candidate.email) {
521
+ return { kind: "oauth", email: candidate.email };
522
+ }
523
+ if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
524
+ return {
525
+ kind: "cc",
526
+ source: candidate.source,
527
+ label: candidate.label
528
+ };
529
+ }
530
+ return { kind: "legacy", refreshToken: candidate.refreshToken };
531
+ }
532
+ function resolveTokenUpdatedAt(account) {
533
+ return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at) ? account.token_updated_at : account.addedAt;
534
+ }
535
+ function clampActiveIndex(accounts, activeIndex) {
536
+ if (accounts.length === 0) {
537
+ return 0;
538
+ }
539
+ return Math.max(0, Math.min(activeIndex, accounts.length - 1));
540
+ }
541
+ function findStoredAccountMatch(accounts, candidate) {
542
+ const byId = accounts.find((account) => account.id === candidate.id);
543
+ if (byId) {
544
+ return byId;
545
+ }
546
+ const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
547
+ if (byIdentity) {
548
+ return byIdentity;
549
+ }
550
+ const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
551
+ if (byAddedAt.length === 1) {
552
+ return byAddedAt[0];
553
+ }
554
+ const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
555
+ if (byRefreshToken) {
556
+ return byRefreshToken;
557
+ }
558
+ return byAddedAt[0] ?? null;
559
+ }
560
+ function mergeAccountWithFresherAuth(account, diskMatch) {
561
+ const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
562
+ const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
563
+ if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
564
+ return {
565
+ ...account,
566
+ token_updated_at: memoryTokenUpdatedAt
567
+ };
568
+ }
569
+ return {
570
+ ...account,
571
+ refreshToken: diskMatch.refreshToken,
572
+ access: diskMatch.access,
573
+ expires: diskMatch.expires,
574
+ token_updated_at: diskTokenUpdatedAt
575
+ };
576
+ }
577
+ function unionAccountsWithDisk(storage, disk) {
578
+ if (!disk || storage.accounts.length === 0) {
579
+ return {
580
+ ...storage,
581
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex)
582
+ };
583
+ }
584
+ const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
585
+ const matchedDiskAccounts = /* @__PURE__ */ new Set();
586
+ const mergedAccounts = storage.accounts.map((account) => {
587
+ const diskMatch = findStoredAccountMatch(disk.accounts, account);
588
+ if (diskMatch) {
589
+ matchedDiskAccounts.add(diskMatch);
590
+ }
591
+ return mergeAccountWithFresherAuth(account, diskMatch);
592
+ });
593
+ const diskOnlyAccounts = disk.accounts.filter((account) => !matchedDiskAccounts.has(account));
594
+ const accounts = [...mergedAccounts, ...diskOnlyAccounts];
595
+ const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
596
+ return {
597
+ ...storage,
598
+ accounts,
599
+ activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex)
600
+ };
601
+ }
422
602
  async function loadAccounts() {
423
603
  const storagePath = getStoragePath();
424
604
  try {
@@ -428,7 +608,9 @@ async function loadAccounts() {
428
608
  return null;
429
609
  }
430
610
  if (data.version !== CURRENT_VERSION) {
431
- return null;
611
+ console.warn(
612
+ `Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`
613
+ );
432
614
  }
433
615
  const now = Date.now();
434
616
  const accounts = data.accounts.map((raw) => validateAccount(raw, now)).filter((acc) => acc !== null);
@@ -455,53 +637,13 @@ async function saveAccounts(storage) {
455
637
  const configDir = dirname2(storagePath);
456
638
  await fs.mkdir(configDir, { recursive: true });
457
639
  ensureGitignore(configDir);
458
- let storageToWrite = storage;
640
+ let storageToWrite = {
641
+ ...storage,
642
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex)
643
+ };
459
644
  try {
460
645
  const disk = await loadAccounts();
461
- if (disk && storage.accounts.length > 0) {
462
- const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
463
- const diskByAddedAt = /* @__PURE__ */ new Map();
464
- const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
465
- for (const d3 of disk.accounts) {
466
- const bucket = diskByAddedAt.get(d3.addedAt) || [];
467
- bucket.push(d3);
468
- diskByAddedAt.set(d3.addedAt, bucket);
469
- }
470
- const findDiskMatch = (acc) => {
471
- const byId = diskById.get(acc.id);
472
- if (byId) return byId;
473
- const byAddedAt = diskByAddedAt.get(acc.addedAt);
474
- if (byAddedAt?.length === 1) return byAddedAt[0];
475
- const byToken = diskByToken.get(acc.refreshToken);
476
- if (byToken) return byToken;
477
- if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
478
- return null;
479
- };
480
- const mergedAccounts = storage.accounts.map((acc) => {
481
- const diskAcc = findDiskMatch(acc);
482
- const memTs = typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at) ? acc.token_updated_at : acc.addedAt;
483
- const diskTs = diskAcc?.token_updated_at || 0;
484
- const useDiskAuth = !!diskAcc && diskTs > memTs;
485
- return {
486
- ...acc,
487
- refreshToken: useDiskAuth ? diskAcc.refreshToken : acc.refreshToken,
488
- access: useDiskAuth ? diskAcc.access : acc.access,
489
- expires: useDiskAuth ? diskAcc.expires : acc.expires,
490
- token_updated_at: useDiskAuth ? diskTs : memTs
491
- };
492
- });
493
- let activeIndex = storage.activeIndex;
494
- if (mergedAccounts.length > 0) {
495
- activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
496
- } else {
497
- activeIndex = 0;
498
- }
499
- storageToWrite = {
500
- ...storage,
501
- accounts: mergedAccounts,
502
- activeIndex
503
- };
504
- }
646
+ storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
505
647
  } catch {
506
648
  }
507
649
  const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
@@ -530,6 +672,7 @@ var CURRENT_VERSION, GITIGNORE_ENTRIES;
530
672
  var init_storage = __esm({
531
673
  "src/storage.ts"() {
532
674
  "use strict";
675
+ init_account_identity();
533
676
  init_config();
534
677
  CURRENT_VERSION = 1;
535
678
  GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-accounts.json.*.tmp"];
@@ -1944,14 +2087,20 @@ async function cmdLogin() {
1944
2087
  const credentials = await runOAuthFlow();
1945
2088
  if (!credentials) return 1;
1946
2089
  const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
1947
- const existingIdx = storage.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
1948
- if (existingIdx >= 0) {
1949
- storage.accounts[existingIdx].access = credentials.access;
1950
- storage.accounts[existingIdx].expires = credentials.expires;
1951
- if (credentials.email) storage.accounts[existingIdx].email = credentials.email;
1952
- storage.accounts[existingIdx].enabled = true;
2090
+ const identity = resolveIdentityFromOAuthExchange(credentials);
2091
+ const existing = findByIdentity(storage.accounts, identity) || storage.accounts.find((acc) => acc.refreshToken === credentials.refresh);
2092
+ if (existing) {
2093
+ const existingIdx = storage.accounts.indexOf(existing);
2094
+ existing.refreshToken = credentials.refresh;
2095
+ existing.access = credentials.access;
2096
+ existing.expires = credentials.expires;
2097
+ existing.token_updated_at = Date.now();
2098
+ if (credentials.email) existing.email = credentials.email;
2099
+ existing.identity = identity;
2100
+ existing.source = existing.source ?? "oauth";
2101
+ existing.enabled = true;
1953
2102
  await saveAccounts(storage);
1954
- const label2 = credentials.email || `Account ${existingIdx + 1}`;
2103
+ const label2 = credentials.email || existing.email || `Account ${existingIdx + 1}`;
1955
2104
  O2.success(`Updated existing account #${existingIdx + 1} (${label2}).`);
1956
2105
  return 0;
1957
2106
  }
@@ -1963,6 +2112,7 @@ async function cmdLogin() {
1963
2112
  storage.accounts.push({
1964
2113
  id: `${now}:${credentials.refresh.slice(0, 12)}`,
1965
2114
  email: credentials.email,
2115
+ identity,
1966
2116
  refreshToken: credentials.refresh,
1967
2117
  access: credentials.access,
1968
2118
  expires: credentials.expires,
@@ -1973,7 +2123,8 @@ async function cmdLogin() {
1973
2123
  rateLimitResetTimes: {},
1974
2124
  consecutiveFailures: 0,
1975
2125
  lastFailureTime: null,
1976
- stats: createDefaultStats(now)
2126
+ stats: createDefaultStats(now),
2127
+ source: "oauth"
1977
2128
  });
1978
2129
  await saveAccounts(storage);
1979
2130
  const label = credentials.email || `Account ${storage.accounts.length}`;
@@ -2171,7 +2322,8 @@ async function cmdList() {
2171
2322
  }
2172
2323
  }
2173
2324
  if (anyRefreshed) {
2174
- await saveAccounts(stored).catch(() => {
2325
+ await saveAccounts(stored).catch((err) => {
2326
+ console.error("[opencode-anthropic-auth] failed to persist refreshed tokens:", err);
2175
2327
  });
2176
2328
  }
2177
2329
  O2.message(c2.bold("Anthropic Multi-Account Status"));
@@ -3167,6 +3319,7 @@ var USE_COLOR, ansi, c2, QUOTA_BUCKETS, USAGE_INDENT, USAGE_LABEL_WIDTH, ioConte
3167
3319
  var init_cli = __esm({
3168
3320
  async "src/cli.ts"() {
3169
3321
  "use strict";
3322
+ init_account_identity();
3170
3323
  init_config();
3171
3324
  init_oauth();
3172
3325
  init_storage();
@@ -3452,6 +3605,9 @@ function readCCCredentials() {
3452
3605
  return credentials;
3453
3606
  }
3454
3607
 
3608
+ // src/accounts.ts
3609
+ init_account_identity();
3610
+
3455
3611
  // src/rotation.ts
3456
3612
  init_config();
3457
3613
  var HealthScoreTracker = class {
@@ -3637,6 +3793,111 @@ function selectAccount(candidates, strategy, currentIndex, healthTracker, tokenT
3637
3793
  init_storage();
3638
3794
  var MAX_ACCOUNTS = 10;
3639
3795
  var RATE_LIMIT_KEY = "anthropic";
3796
+ function resolveAccountIdentity(params) {
3797
+ if (params.identity) {
3798
+ return params.identity;
3799
+ }
3800
+ if ((params.source === "cc-keychain" || params.source === "cc-file") && params.label) {
3801
+ return {
3802
+ kind: "cc",
3803
+ source: params.source,
3804
+ label: params.label
3805
+ };
3806
+ }
3807
+ if (params.email) {
3808
+ return {
3809
+ kind: "oauth",
3810
+ email: params.email
3811
+ };
3812
+ }
3813
+ return {
3814
+ kind: "legacy",
3815
+ refreshToken: params.refreshToken
3816
+ };
3817
+ }
3818
+ function createManagedAccount(init) {
3819
+ const now = init.now ?? Date.now();
3820
+ const addedAt = init.addedAt ?? now;
3821
+ const tokenUpdatedAt = init.tokenUpdatedAt ?? addedAt;
3822
+ const identity = resolveAccountIdentity({
3823
+ refreshToken: init.refreshToken,
3824
+ email: init.email,
3825
+ identity: init.identity,
3826
+ label: init.label,
3827
+ source: init.source
3828
+ });
3829
+ const email = init.email ?? (identity.kind === "oauth" ? identity.email : void 0);
3830
+ const label = init.label ?? (identity.kind === "cc" ? identity.label : void 0);
3831
+ const source = init.source ?? (identity.kind === "cc" ? identity.source : "oauth");
3832
+ return {
3833
+ id: init.id ?? `${addedAt}:${init.refreshToken.slice(0, 12)}`,
3834
+ index: init.index,
3835
+ email,
3836
+ identity,
3837
+ label,
3838
+ refreshToken: init.refreshToken,
3839
+ access: init.access,
3840
+ expires: init.expires,
3841
+ tokenUpdatedAt,
3842
+ addedAt,
3843
+ lastUsed: init.lastUsed ?? 0,
3844
+ enabled: init.enabled ?? true,
3845
+ rateLimitResetTimes: { ...init.rateLimitResetTimes ?? {} },
3846
+ consecutiveFailures: init.consecutiveFailures ?? 0,
3847
+ lastFailureTime: init.lastFailureTime ?? null,
3848
+ lastSwitchReason: init.lastSwitchReason ?? "initial",
3849
+ stats: init.stats ?? createDefaultStats(addedAt),
3850
+ source
3851
+ };
3852
+ }
3853
+ function findMatchingAccount(accounts, params) {
3854
+ if (params.id) {
3855
+ const byId = accounts.find((account) => account.id === params.id);
3856
+ if (byId) return byId;
3857
+ }
3858
+ if (params.identity) {
3859
+ const byIdentity = findByIdentity(accounts, params.identity);
3860
+ if (byIdentity) return byIdentity;
3861
+ }
3862
+ if (params.refreshToken) {
3863
+ return accounts.find((account) => account.refreshToken === params.refreshToken) ?? null;
3864
+ }
3865
+ return null;
3866
+ }
3867
+ function reindexAccounts(accounts) {
3868
+ accounts.forEach((account, index) => {
3869
+ account.index = index;
3870
+ });
3871
+ }
3872
+ function updateManagedAccountFromStorage(existing, account, index) {
3873
+ const source = account.source || existing.source || "oauth";
3874
+ const label = account.label ?? existing.label;
3875
+ const email = account.email ?? existing.email;
3876
+ existing.id = account.id || existing.id || `${account.addedAt}:${account.refreshToken.slice(0, 12)}`;
3877
+ existing.index = index;
3878
+ existing.email = email;
3879
+ existing.label = label;
3880
+ existing.identity = resolveAccountIdentity({
3881
+ refreshToken: account.refreshToken,
3882
+ email,
3883
+ identity: account.identity ?? existing.identity,
3884
+ label,
3885
+ source
3886
+ });
3887
+ existing.refreshToken = account.refreshToken;
3888
+ existing.access = account.access ?? existing.access;
3889
+ existing.expires = account.expires ?? existing.expires;
3890
+ existing.tokenUpdatedAt = account.token_updated_at ?? existing.tokenUpdatedAt ?? account.addedAt;
3891
+ existing.addedAt = account.addedAt;
3892
+ existing.lastUsed = account.lastUsed;
3893
+ existing.enabled = account.enabled;
3894
+ existing.rateLimitResetTimes = { ...account.rateLimitResetTimes };
3895
+ existing.consecutiveFailures = account.consecutiveFailures;
3896
+ existing.lastFailureTime = account.lastFailureTime;
3897
+ existing.lastSwitchReason = account.lastSwitchReason || existing.lastSwitchReason || "initial";
3898
+ existing.stats = account.stats ?? existing.stats ?? createDefaultStats(account.addedAt);
3899
+ existing.source = source;
3900
+ }
3640
3901
  var AccountManager = class _AccountManager {
3641
3902
  #accounts = [];
3642
3903
  #cursor = 0;
@@ -3646,11 +3907,22 @@ var AccountManager = class _AccountManager {
3646
3907
  #config;
3647
3908
  #saveTimeout = null;
3648
3909
  #statsDeltas = /* @__PURE__ */ new Map();
3910
+ /**
3911
+ * Cap on pending stats deltas. When hit, a forced flush is scheduled so the
3912
+ * map does not grow without bound between debounced saves. This is only a
3913
+ * safety net — under normal load the 1s debounced save in `requestSaveToDisk`
3914
+ * keeps the delta count below this cap.
3915
+ */
3916
+ #MAX_STATS_DELTAS = 100;
3649
3917
  constructor(config) {
3650
3918
  this.#config = config;
3651
3919
  this.#healthTracker = new HealthScoreTracker(config.health_score);
3652
3920
  this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
3653
3921
  }
3922
+ #rebuildTrackers() {
3923
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
3924
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
3925
+ }
3654
3926
  /**
3655
3927
  * Load accounts from disk, optionally merging with an OpenCode auth fallback.
3656
3928
  */
@@ -3658,27 +3930,38 @@ var AccountManager = class _AccountManager {
3658
3930
  const manager = new _AccountManager(config);
3659
3931
  const stored = await loadAccounts();
3660
3932
  if (stored) {
3661
- manager.#accounts = stored.accounts.map((acc, index) => ({
3662
- id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
3663
- index,
3664
- email: acc.email,
3665
- refreshToken: acc.refreshToken,
3666
- access: acc.access,
3667
- expires: acc.expires,
3668
- tokenUpdatedAt: acc.token_updated_at,
3669
- addedAt: acc.addedAt,
3670
- lastUsed: acc.lastUsed,
3671
- enabled: acc.enabled,
3672
- rateLimitResetTimes: acc.rateLimitResetTimes,
3673
- consecutiveFailures: acc.consecutiveFailures,
3674
- lastFailureTime: acc.lastFailureTime,
3675
- lastSwitchReason: acc.lastSwitchReason,
3676
- stats: acc.stats ?? createDefaultStats(acc.addedAt),
3677
- source: acc.source || "oauth"
3678
- }));
3933
+ manager.#accounts = stored.accounts.map(
3934
+ (acc, index) => createManagedAccount({
3935
+ id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
3936
+ index,
3937
+ email: acc.email,
3938
+ identity: acc.identity,
3939
+ label: acc.label,
3940
+ refreshToken: acc.refreshToken,
3941
+ access: acc.access,
3942
+ expires: acc.expires,
3943
+ tokenUpdatedAt: acc.token_updated_at,
3944
+ addedAt: acc.addedAt,
3945
+ lastUsed: acc.lastUsed,
3946
+ enabled: acc.enabled,
3947
+ rateLimitResetTimes: acc.rateLimitResetTimes,
3948
+ consecutiveFailures: acc.consecutiveFailures,
3949
+ lastFailureTime: acc.lastFailureTime,
3950
+ lastSwitchReason: acc.lastSwitchReason,
3951
+ stats: acc.stats,
3952
+ source: acc.source || "oauth"
3953
+ })
3954
+ );
3679
3955
  manager.#currentIndex = manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
3680
3956
  if (authFallback && manager.#accounts.length > 0) {
3681
- const match = manager.#accounts.find((acc) => acc.refreshToken === authFallback.refresh);
3957
+ const fallbackIdentity = resolveAccountIdentity({
3958
+ refreshToken: authFallback.refresh,
3959
+ source: "oauth"
3960
+ });
3961
+ const match = findMatchingAccount(manager.#accounts, {
3962
+ identity: fallbackIdentity,
3963
+ refreshToken: authFallback.refresh
3964
+ });
3682
3965
  if (match) {
3683
3966
  const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
3684
3967
  const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
@@ -3695,23 +3978,17 @@ var AccountManager = class _AccountManager {
3695
3978
  } else if (authFallback && authFallback.refresh) {
3696
3979
  const now = Date.now();
3697
3980
  manager.#accounts = [
3698
- {
3981
+ createManagedAccount({
3699
3982
  id: `${now}:${authFallback.refresh.slice(0, 12)}`,
3700
3983
  index: 0,
3701
- email: void 0,
3702
3984
  refreshToken: authFallback.refresh,
3703
3985
  access: authFallback.access,
3704
3986
  expires: authFallback.expires,
3705
3987
  tokenUpdatedAt: now,
3706
3988
  addedAt: now,
3707
- lastUsed: 0,
3708
- enabled: true,
3709
- rateLimitResetTimes: {},
3710
- consecutiveFailures: 0,
3711
- lastFailureTime: null,
3712
3989
  lastSwitchReason: "initial",
3713
- stats: createDefaultStats(now)
3714
- }
3990
+ source: "oauth"
3991
+ })
3715
3992
  ];
3716
3993
  manager.#currentIndex = 0;
3717
3994
  }
@@ -3725,49 +4002,60 @@ var AccountManager = class _AccountManager {
3725
4002
  }
3726
4003
  })();
3727
4004
  for (const ccCredential of ccCredentials) {
3728
- const existingMatch = manager.#accounts.find(
3729
- (account) => account.refreshToken === ccCredential.refreshToken
3730
- );
3731
- if (existingMatch) {
3732
- if (!existingMatch.source || existingMatch.source === "oauth") {
3733
- existingMatch.source = ccCredential.source;
4005
+ const ccIdentity = resolveIdentityFromCCCredential(ccCredential);
4006
+ let existingMatch = findMatchingAccount(manager.#accounts, {
4007
+ identity: ccIdentity,
4008
+ refreshToken: ccCredential.refreshToken
4009
+ });
4010
+ if (!existingMatch) {
4011
+ const legacyUnlabeledMatches = manager.#accounts.filter(
4012
+ (account) => account.source === ccCredential.source && !account.label && !account.email
4013
+ );
4014
+ if (legacyUnlabeledMatches.length === 1) {
4015
+ existingMatch = legacyUnlabeledMatches[0];
3734
4016
  }
3735
- if (ccCredential.accessToken && ccCredential.expiresAt > (existingMatch.expires ?? 0)) {
4017
+ }
4018
+ if (existingMatch) {
4019
+ existingMatch.refreshToken = ccCredential.refreshToken;
4020
+ existingMatch.identity = ccIdentity;
4021
+ existingMatch.source = ccCredential.source;
4022
+ existingMatch.label = ccCredential.label;
4023
+ existingMatch.enabled = true;
4024
+ if (ccCredential.accessToken) {
3736
4025
  existingMatch.access = ccCredential.accessToken;
4026
+ }
4027
+ if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
3737
4028
  existingMatch.expires = ccCredential.expiresAt;
3738
4029
  }
4030
+ existingMatch.tokenUpdatedAt = Math.max(existingMatch.tokenUpdatedAt || 0, ccCredential.expiresAt || 0);
4031
+ continue;
4032
+ }
4033
+ if (manager.#accounts.length >= MAX_ACCOUNTS) {
3739
4034
  continue;
3740
4035
  }
3741
4036
  const emailCollision = manager.getOAuthAccounts().find((account) => account.email && ccCredential.label.includes(account.email));
3742
4037
  if (emailCollision?.email) {
3743
4038
  }
3744
4039
  const now = Date.now();
3745
- const ccAccount = {
4040
+ const ccAccount = createManagedAccount({
3746
4041
  id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
3747
4042
  index: manager.#accounts.length,
3748
- email: void 0,
3749
4043
  refreshToken: ccCredential.refreshToken,
3750
4044
  access: ccCredential.accessToken,
3751
4045
  expires: ccCredential.expiresAt,
3752
4046
  tokenUpdatedAt: now,
3753
4047
  addedAt: now,
3754
- lastUsed: 0,
3755
- enabled: true,
3756
- rateLimitResetTimes: {},
3757
- consecutiveFailures: 0,
3758
- lastFailureTime: null,
4048
+ identity: ccIdentity,
4049
+ label: ccCredential.label,
3759
4050
  lastSwitchReason: "cc-auto-detected",
3760
- stats: createDefaultStats(now),
3761
4051
  source: ccCredential.source
3762
- };
4052
+ });
3763
4053
  manager.#accounts.push(ccAccount);
3764
4054
  }
3765
4055
  if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
3766
4056
  manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
3767
4057
  }
3768
- manager.#accounts.forEach((account, index) => {
3769
- account.index = index;
3770
- });
4058
+ reindexAccounts(manager.#accounts);
3771
4059
  if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
3772
4060
  manager.#currentIndex = 0;
3773
4061
  } else if (currentAccountId) {
@@ -3907,35 +4195,47 @@ var AccountManager = class _AccountManager {
3907
4195
  * Add a new account to the pool.
3908
4196
  * @returns The new account, or null if at capacity
3909
4197
  */
3910
- addAccount(refreshToken2, accessToken, expires, email) {
3911
- if (this.#accounts.length >= MAX_ACCOUNTS) return null;
3912
- const existing = this.#accounts.find((acc) => acc.refreshToken === refreshToken2);
4198
+ addAccount(refreshToken2, accessToken, expires, email, options) {
4199
+ const identity = resolveAccountIdentity({
4200
+ refreshToken: refreshToken2,
4201
+ email,
4202
+ identity: options?.identity,
4203
+ label: options?.label,
4204
+ source: options?.source ?? "oauth"
4205
+ });
4206
+ const existing = findMatchingAccount(this.#accounts, {
4207
+ identity,
4208
+ refreshToken: refreshToken2
4209
+ });
3913
4210
  if (existing) {
4211
+ existing.refreshToken = refreshToken2;
3914
4212
  existing.access = accessToken;
3915
4213
  existing.expires = expires;
3916
4214
  existing.tokenUpdatedAt = Date.now();
3917
- if (email) existing.email = email;
4215
+ existing.email = email ?? existing.email;
4216
+ existing.identity = identity;
4217
+ existing.label = options?.label ?? existing.label;
4218
+ existing.source = options?.source ?? existing.source ?? "oauth";
3918
4219
  existing.enabled = true;
4220
+ this.requestSaveToDisk();
3919
4221
  return existing;
3920
4222
  }
4223
+ if (this.#accounts.length >= MAX_ACCOUNTS) return null;
3921
4224
  const now = Date.now();
3922
- const account = {
4225
+ const account = createManagedAccount({
3923
4226
  id: `${now}:${refreshToken2.slice(0, 12)}`,
3924
4227
  index: this.#accounts.length,
3925
- email,
3926
4228
  refreshToken: refreshToken2,
3927
4229
  access: accessToken,
3928
4230
  expires,
3929
4231
  tokenUpdatedAt: now,
3930
4232
  addedAt: now,
3931
- lastUsed: 0,
3932
- enabled: true,
3933
- rateLimitResetTimes: {},
3934
- consecutiveFailures: 0,
3935
- lastFailureTime: null,
3936
4233
  lastSwitchReason: "initial",
3937
- stats: createDefaultStats(now)
3938
- };
4234
+ email,
4235
+ identity,
4236
+ label: options?.label,
4237
+ source: options?.source ?? "oauth"
4238
+ });
3939
4239
  this.#accounts.push(account);
3940
4240
  if (this.#accounts.length === 1) {
3941
4241
  this.#currentIndex = 0;
@@ -3949,9 +4249,7 @@ var AccountManager = class _AccountManager {
3949
4249
  removeAccount(index) {
3950
4250
  if (index < 0 || index >= this.#accounts.length) return false;
3951
4251
  this.#accounts.splice(index, 1);
3952
- this.#accounts.forEach((acc, i) => {
3953
- acc.index = i;
3954
- });
4252
+ reindexAccounts(this.#accounts);
3955
4253
  if (this.#accounts.length === 0) {
3956
4254
  this.#currentIndex = -1;
3957
4255
  this.#cursor = 0;
@@ -3963,9 +4261,7 @@ var AccountManager = class _AccountManager {
3963
4261
  this.#cursor = Math.min(this.#cursor, this.#accounts.length);
3964
4262
  }
3965
4263
  }
3966
- for (let i = 0; i < this.#accounts.length; i++) {
3967
- this.#healthTracker.reset(i);
3968
- }
4264
+ this.#rebuildTrackers();
3969
4265
  this.requestSaveToDisk();
3970
4266
  return true;
3971
4267
  }
@@ -3995,7 +4291,10 @@ var AccountManager = class _AccountManager {
3995
4291
  if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
3996
4292
  this.#saveTimeout = setTimeout(() => {
3997
4293
  this.#saveTimeout = null;
3998
- this.saveToDisk().catch(() => {
4294
+ this.saveToDisk().catch((err) => {
4295
+ if (this.#config.debug) {
4296
+ console.error("[opencode-anthropic-auth] saveToDisk failed:", err.message);
4297
+ }
3999
4298
  });
4000
4299
  }, 1e3);
4001
4300
  }
@@ -4008,9 +4307,11 @@ var AccountManager = class _AccountManager {
4008
4307
  let diskAccountsById = null;
4009
4308
  let diskAccountsByAddedAt = null;
4010
4309
  let diskAccountsByRefreshToken = null;
4310
+ let diskAccounts = [];
4011
4311
  try {
4012
4312
  const diskData = await loadAccounts();
4013
4313
  if (diskData) {
4314
+ diskAccounts = diskData.accounts;
4014
4315
  diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
4015
4316
  diskAccountsByAddedAt = /* @__PURE__ */ new Map();
4016
4317
  diskAccountsByRefreshToken = /* @__PURE__ */ new Map();
@@ -4026,6 +4327,8 @@ var AccountManager = class _AccountManager {
4026
4327
  const findDiskAccount = (account) => {
4027
4328
  const byId = diskAccountsById?.get(account.id);
4028
4329
  if (byId) return byId;
4330
+ const byIdentity = findByIdentity(diskAccounts, resolveIdentity(account));
4331
+ if (byIdentity) return byIdentity;
4029
4332
  const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
4030
4333
  if (byAddedAt?.length === 1) return byAddedAt[0];
4031
4334
  const byToken = diskAccountsByRefreshToken?.get(account.refreshToken);
@@ -4033,70 +4336,82 @@ var AccountManager = class _AccountManager {
4033
4336
  if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
4034
4337
  return null;
4035
4338
  };
4339
+ const matchedDiskAccounts = /* @__PURE__ */ new Set();
4340
+ const activeAccountId = this.#accounts[this.#currentIndex]?.id ?? null;
4341
+ const accountsToPersist = this.#accounts.filter((account) => account.enabled || !!findDiskAccount(account));
4342
+ const persistedAccounts = accountsToPersist.map((acc) => {
4343
+ const delta = this.#statsDeltas.get(acc.id);
4344
+ let mergedStats = acc.stats;
4345
+ const diskAcc = findDiskAccount(acc);
4346
+ if (diskAcc) {
4347
+ matchedDiskAccounts.add(diskAcc);
4348
+ }
4349
+ if (delta) {
4350
+ const diskStats = diskAcc?.stats;
4351
+ if (delta.isReset) {
4352
+ mergedStats = {
4353
+ requests: delta.requests,
4354
+ inputTokens: delta.inputTokens,
4355
+ outputTokens: delta.outputTokens,
4356
+ cacheReadTokens: delta.cacheReadTokens,
4357
+ cacheWriteTokens: delta.cacheWriteTokens,
4358
+ lastReset: delta.resetTimestamp ?? acc.stats.lastReset
4359
+ };
4360
+ } else if (diskStats) {
4361
+ mergedStats = {
4362
+ requests: diskStats.requests + delta.requests,
4363
+ inputTokens: diskStats.inputTokens + delta.inputTokens,
4364
+ outputTokens: diskStats.outputTokens + delta.outputTokens,
4365
+ cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
4366
+ cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
4367
+ lastReset: diskStats.lastReset
4368
+ };
4369
+ }
4370
+ }
4371
+ const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
4372
+ const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
4373
+ const freshestAuth = diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt ? {
4374
+ refreshToken: diskAcc.refreshToken,
4375
+ access: diskAcc.access,
4376
+ expires: diskAcc.expires,
4377
+ tokenUpdatedAt: diskTokenUpdatedAt
4378
+ } : {
4379
+ refreshToken: acc.refreshToken,
4380
+ access: acc.access,
4381
+ expires: acc.expires,
4382
+ tokenUpdatedAt: memTokenUpdatedAt
4383
+ };
4384
+ acc.refreshToken = freshestAuth.refreshToken;
4385
+ acc.access = freshestAuth.access;
4386
+ acc.expires = freshestAuth.expires;
4387
+ acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
4388
+ return {
4389
+ id: acc.id,
4390
+ email: acc.email,
4391
+ identity: acc.identity,
4392
+ label: acc.label,
4393
+ refreshToken: freshestAuth.refreshToken,
4394
+ access: freshestAuth.access,
4395
+ expires: freshestAuth.expires,
4396
+ token_updated_at: freshestAuth.tokenUpdatedAt,
4397
+ addedAt: acc.addedAt,
4398
+ lastUsed: acc.lastUsed,
4399
+ enabled: acc.enabled,
4400
+ rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
4401
+ consecutiveFailures: acc.consecutiveFailures,
4402
+ lastFailureTime: acc.lastFailureTime,
4403
+ lastSwitchReason: acc.lastSwitchReason,
4404
+ stats: mergedStats,
4405
+ source: acc.source
4406
+ };
4407
+ });
4408
+ const diskOnlyAccounts = diskAccounts.filter((account) => !matchedDiskAccounts.has(account));
4409
+ const allAccounts = accountsToPersist.length > 0 ? [...persistedAccounts, ...diskOnlyAccounts] : persistedAccounts;
4410
+ const resolvedActiveIndex = activeAccountId ? allAccounts.findIndex((account) => account.id === activeAccountId) : -1;
4036
4411
  const storage = {
4037
4412
  version: 1,
4038
- accounts: this.#accounts.map((acc) => {
4039
- const delta = this.#statsDeltas.get(acc.id);
4040
- let mergedStats = acc.stats;
4041
- const diskAcc = findDiskAccount(acc);
4042
- if (delta) {
4043
- const diskStats = diskAcc?.stats;
4044
- if (delta.isReset) {
4045
- mergedStats = {
4046
- requests: delta.requests,
4047
- inputTokens: delta.inputTokens,
4048
- outputTokens: delta.outputTokens,
4049
- cacheReadTokens: delta.cacheReadTokens,
4050
- cacheWriteTokens: delta.cacheWriteTokens,
4051
- lastReset: delta.resetTimestamp ?? acc.stats.lastReset
4052
- };
4053
- } else if (diskStats) {
4054
- mergedStats = {
4055
- requests: diskStats.requests + delta.requests,
4056
- inputTokens: diskStats.inputTokens + delta.inputTokens,
4057
- outputTokens: diskStats.outputTokens + delta.outputTokens,
4058
- cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
4059
- cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
4060
- lastReset: diskStats.lastReset
4061
- };
4062
- }
4063
- }
4064
- const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
4065
- const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
4066
- const freshestAuth = diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt ? {
4067
- refreshToken: diskAcc.refreshToken,
4068
- access: diskAcc.access,
4069
- expires: diskAcc.expires,
4070
- tokenUpdatedAt: diskTokenUpdatedAt
4071
- } : {
4072
- refreshToken: acc.refreshToken,
4073
- access: acc.access,
4074
- expires: acc.expires,
4075
- tokenUpdatedAt: memTokenUpdatedAt
4076
- };
4077
- acc.refreshToken = freshestAuth.refreshToken;
4078
- acc.access = freshestAuth.access;
4079
- acc.expires = freshestAuth.expires;
4080
- acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
4081
- return {
4082
- id: acc.id,
4083
- email: acc.email,
4084
- refreshToken: freshestAuth.refreshToken,
4085
- access: freshestAuth.access,
4086
- expires: freshestAuth.expires,
4087
- token_updated_at: freshestAuth.tokenUpdatedAt,
4088
- addedAt: acc.addedAt,
4089
- lastUsed: acc.lastUsed,
4090
- enabled: acc.enabled,
4091
- rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
4092
- consecutiveFailures: acc.consecutiveFailures,
4093
- lastFailureTime: acc.lastFailureTime,
4094
- lastSwitchReason: acc.lastSwitchReason,
4095
- stats: mergedStats,
4096
- source: acc.source
4097
- };
4098
- }),
4099
- activeIndex: Math.max(0, this.#currentIndex)
4413
+ accounts: allAccounts,
4414
+ activeIndex: resolvedActiveIndex >= 0 ? resolvedActiveIndex : allAccounts.length > 0 ? Math.max(0, Math.min(this.#currentIndex, allAccounts.length - 1)) : 0
4100
4415
  };
4101
4416
  await saveAccounts(storage);
4102
4417
  this.#statsDeltas.clear();
@@ -4113,57 +4428,92 @@ var AccountManager = class _AccountManager {
4113
4428
  async syncActiveIndexFromDisk() {
4114
4429
  const stored = await loadAccounts();
4115
4430
  if (!stored) return;
4116
- const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
4117
- const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
4118
- const diskSnapshot = stored.accounts.map((acc) => {
4119
- const resolvedId = acc.id || existingByTokenForSnapshot.get(acc.refreshToken)?.id || acc.refreshToken;
4120
- return `${resolvedId}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`;
4121
- }).join("|");
4122
- if (diskSnapshot !== memSnapshot) {
4123
- const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
4124
- const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
4125
- this.#accounts = stored.accounts.map((acc, index) => {
4126
- const existing = acc.id && existingById.get(acc.id) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
4127
- return {
4128
- id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
4129
- index,
4130
- email: acc.email ?? existing?.email,
4131
- refreshToken: acc.refreshToken,
4132
- access: acc.access ?? existing?.access,
4133
- expires: acc.expires ?? existing?.expires,
4134
- tokenUpdatedAt: acc.token_updated_at ?? existing?.tokenUpdatedAt ?? acc.addedAt,
4135
- addedAt: acc.addedAt,
4136
- lastUsed: acc.lastUsed,
4137
- enabled: acc.enabled,
4138
- rateLimitResetTimes: acc.rateLimitResetTimes,
4139
- consecutiveFailures: acc.consecutiveFailures,
4140
- lastFailureTime: acc.lastFailureTime,
4141
- lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
4142
- stats: acc.stats ?? existing?.stats ?? createDefaultStats()
4143
- };
4431
+ const matchedAccounts = /* @__PURE__ */ new Set();
4432
+ const reconciledAccounts = [];
4433
+ let structuralChange = false;
4434
+ for (const [index, storedAccount] of stored.accounts.entries()) {
4435
+ const existing = findMatchingAccount(this.#accounts, {
4436
+ id: storedAccount.id,
4437
+ identity: resolveIdentity(storedAccount),
4438
+ refreshToken: storedAccount.refreshToken
4144
4439
  });
4145
- this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
4146
- this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
4147
- const currentIds = new Set(this.#accounts.map((a) => a.id));
4148
- for (const id of this.#statsDeltas.keys()) {
4149
- if (!currentIds.has(id)) this.#statsDeltas.delete(id);
4150
- }
4151
- if (this.#accounts.length === 0) {
4152
- this.#currentIndex = -1;
4153
- this.#cursor = 0;
4154
- return;
4440
+ if (existing) {
4441
+ updateManagedAccountFromStorage(existing, storedAccount, index);
4442
+ matchedAccounts.add(existing);
4443
+ reconciledAccounts.push(existing);
4444
+ continue;
4445
+ }
4446
+ const addedAccount = createManagedAccount({
4447
+ id: storedAccount.id,
4448
+ index,
4449
+ email: storedAccount.email,
4450
+ identity: storedAccount.identity,
4451
+ label: storedAccount.label,
4452
+ refreshToken: storedAccount.refreshToken,
4453
+ access: storedAccount.access,
4454
+ expires: storedAccount.expires,
4455
+ tokenUpdatedAt: storedAccount.token_updated_at,
4456
+ addedAt: storedAccount.addedAt,
4457
+ lastUsed: storedAccount.lastUsed,
4458
+ enabled: storedAccount.enabled,
4459
+ rateLimitResetTimes: storedAccount.rateLimitResetTimes,
4460
+ consecutiveFailures: storedAccount.consecutiveFailures,
4461
+ lastFailureTime: storedAccount.lastFailureTime,
4462
+ lastSwitchReason: storedAccount.lastSwitchReason,
4463
+ stats: storedAccount.stats,
4464
+ source: storedAccount.source || "oauth"
4465
+ });
4466
+ matchedAccounts.add(addedAccount);
4467
+ reconciledAccounts.push(addedAccount);
4468
+ structuralChange = true;
4469
+ }
4470
+ for (const account of this.#accounts) {
4471
+ if (matchedAccounts.has(account)) {
4472
+ continue;
4473
+ }
4474
+ if (account.enabled) {
4475
+ account.enabled = false;
4476
+ structuralChange = true;
4155
4477
  }
4478
+ reconciledAccounts.push(account);
4479
+ }
4480
+ const orderChanged = reconciledAccounts.length !== this.#accounts.length || reconciledAccounts.some((account, index) => this.#accounts[index] !== account);
4481
+ this.#accounts = reconciledAccounts;
4482
+ reindexAccounts(this.#accounts);
4483
+ if (orderChanged || structuralChange) {
4484
+ this.#rebuildTrackers();
4485
+ }
4486
+ const currentIds = new Set(this.#accounts.map((account) => account.id));
4487
+ for (const id of this.#statsDeltas.keys()) {
4488
+ if (!currentIds.has(id)) {
4489
+ this.#statsDeltas.delete(id);
4490
+ }
4491
+ }
4492
+ const enabledAccounts = this.#accounts.filter((account) => account.enabled);
4493
+ if (enabledAccounts.length === 0) {
4494
+ this.#currentIndex = -1;
4495
+ this.#cursor = 0;
4496
+ return;
4156
4497
  }
4157
- const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
4158
- if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
4159
- const diskAccount = stored.accounts[diskIndex];
4160
- if (!diskAccount || !diskAccount.enabled) return;
4161
- const account = this.#accounts[diskIndex];
4162
- if (account && account.enabled) {
4163
- this.#currentIndex = diskIndex;
4164
- this.#cursor = diskIndex;
4165
- this.#healthTracker.reset(diskIndex);
4498
+ const diskIndex = Math.min(stored.activeIndex, stored.accounts.length - 1);
4499
+ const diskAccount = diskIndex >= 0 ? stored.accounts[diskIndex] : void 0;
4500
+ if (!diskAccount || !diskAccount.enabled) {
4501
+ if (!this.#accounts[this.#currentIndex]?.enabled) {
4502
+ const fallback = enabledAccounts[0];
4503
+ this.#currentIndex = fallback.index;
4504
+ this.#cursor = fallback.index;
4166
4505
  }
4506
+ return;
4507
+ }
4508
+ const activeAccount = findMatchingAccount(this.#accounts, {
4509
+ id: diskAccount.id,
4510
+ identity: resolveIdentity(diskAccount),
4511
+ refreshToken: diskAccount.refreshToken
4512
+ });
4513
+ if (activeAccount && activeAccount.enabled && activeAccount.index !== this.#currentIndex) {
4514
+ this.#currentIndex = activeAccount.index;
4515
+ this.#cursor = activeAccount.index;
4516
+ this.#healthTracker.reset(activeAccount.index);
4167
4517
  }
4168
4518
  }
4169
4519
  /**
@@ -4189,6 +4539,13 @@ var AccountManager = class _AccountManager {
4189
4539
  delta.cacheReadTokens += crTok;
4190
4540
  delta.cacheWriteTokens += cwTok;
4191
4541
  } else {
4542
+ if (this.#statsDeltas.size >= this.#MAX_STATS_DELTAS) {
4543
+ this.saveToDisk().catch((err) => {
4544
+ if (this.#config.debug) {
4545
+ console.error("[opencode-anthropic-auth] forced statsDeltas flush failed:", err.message);
4546
+ }
4547
+ });
4548
+ }
4192
4549
  this.#statsDeltas.set(account.id, {
4193
4550
  requests: 1,
4194
4551
  inputTokens: inTok,
@@ -4721,6 +5078,14 @@ async function completeSlashOAuth(sessionID, code, deps) {
4721
5078
 
4722
5079
  // src/commands/router.ts
4723
5080
  var ANTHROPIC_COMMAND_HANDLED = "__ANTHROPIC_COMMAND_HANDLED__";
5081
+ var FILE_ACCOUNT_MAP_MAX_SIZE = 1e3;
5082
+ function capFileAccountMap(fileAccountMap, fileId, accountIndex) {
5083
+ if (fileAccountMap.size >= FILE_ACCOUNT_MAP_MAX_SIZE) {
5084
+ const oldestKey = fileAccountMap.keys().next().value;
5085
+ if (oldestKey !== void 0) fileAccountMap.delete(oldestKey);
5086
+ }
5087
+ fileAccountMap.set(fileId, accountIndex);
5088
+ }
4724
5089
  function stripAnsi(value) {
4725
5090
  return value.replace(/\x1b\[[0-9;]*m/g, "");
4726
5091
  }
@@ -5069,7 +5434,7 @@ HTTP ${res.status}: ${errBody}`
5069
5434
  }
5070
5435
  const data = await res.json();
5071
5436
  const files = data.data || [];
5072
- for (const f of files) fileAccountMap.set(f.id, account2.index);
5437
+ for (const f of files) capFileAccountMap(fileAccountMap, f.id, account2.index);
5073
5438
  if (files.length === 0) {
5074
5439
  await sendCommandMessage(input.sessionID, `\u25A3 Anthropic Files [${label2}]
5075
5440
 
@@ -5099,7 +5464,7 @@ No files uploaded.`);
5099
5464
  }
5100
5465
  const data = await res.json();
5101
5466
  const files = data.data || [];
5102
- for (const f of files) fileAccountMap.set(f.id, acct.index);
5467
+ for (const f of files) capFileAccountMap(fileAccountMap, f.id, acct.index);
5103
5468
  totalFiles += files.length;
5104
5469
  if (files.length === 0) {
5105
5470
  allLines.push(`[${label2}] No files`);
@@ -5179,7 +5544,7 @@ Upload failed (HTTP ${res.status}): ${errBody}`
5179
5544
  }
5180
5545
  const file = await res.json();
5181
5546
  const sizeKB = ((file.size || 0) / 1024).toFixed(1);
5182
- fileAccountMap.set(file.id, account.index);
5547
+ capFileAccountMap(fileAccountMap, file.id, account.index);
5183
5548
  await sendCommandMessage(
5184
5549
  input.sessionID,
5185
5550
  `\u25A3 Anthropic Files [${label}]
@@ -5211,7 +5576,7 @@ HTTP ${res.status}: ${errBody}`
5211
5576
  return;
5212
5577
  }
5213
5578
  const file = await res.json();
5214
- fileAccountMap.set(file.id, account.index);
5579
+ capFileAccountMap(fileAccountMap, file.id, account.index);
5215
5580
  const lines = [
5216
5581
  `\u25A3 Anthropic Files [${label}]`,
5217
5582
  "",
@@ -5558,17 +5923,28 @@ function buildAnthropicBillingHeader(claudeCliVersion, messages) {
5558
5923
  let versionSuffix = "";
5559
5924
  if (Array.isArray(messages)) {
5560
5925
  const firstUserMsg = messages.find(
5561
- (m) => m !== null && typeof m === "object" && m.role === "user" && typeof m.content === "string"
5926
+ (m) => m !== null && typeof m === "object" && m.role === "user"
5562
5927
  );
5563
5928
  if (firstUserMsg) {
5564
- const text = firstUserMsg.content;
5565
- const salt = "59cf53e54c78";
5566
- const picked = [4, 7, 20].map((i) => i < text.length ? text[i] : "0").join("");
5567
- const hash = createHash2("sha256").update(salt + picked + claudeCliVersion).digest("hex");
5568
- versionSuffix = `.${hash.slice(0, 3)}`;
5929
+ let text = "";
5930
+ const content = firstUserMsg.content;
5931
+ if (typeof content === "string") {
5932
+ text = content;
5933
+ } else if (Array.isArray(content)) {
5934
+ const textBlock = content.find((b) => b.type === "text");
5935
+ if (textBlock && typeof textBlock.text === "string") {
5936
+ text = textBlock.text;
5937
+ }
5938
+ }
5939
+ if (text) {
5940
+ const salt = "59cf53e54c78";
5941
+ const picked = [4, 7, 20].map((i) => i < text.length ? text[i] : "0").join("");
5942
+ const hash = createHash2("sha256").update(salt + picked + claudeCliVersion).digest("hex");
5943
+ versionSuffix = `.${hash.slice(0, 3)}`;
5944
+ }
5569
5945
  }
5570
5946
  }
5571
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
5947
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
5572
5948
  let cchValue;
5573
5949
  if (Array.isArray(messages) && messages.length > 0) {
5574
5950
  const bodyHint = JSON.stringify(messages).slice(0, 512);
@@ -5638,8 +6014,9 @@ function normalizeSystemTextBlocks(system) {
5638
6014
  }
5639
6015
 
5640
6016
  // src/system-prompt/sanitize.ts
5641
- function sanitizeSystemText(text) {
5642
- return text.replace(/OpenCode/g, "Claude Code").replace(/opencode/gi, "Claude");
6017
+ function sanitizeSystemText(text, enabled = true) {
6018
+ if (!enabled) return text;
6019
+ return text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude").replace(/OhMyClaude\s*Code/gi, "Claude Code").replace(/OhMyClaudeCode/gi, "Claude Code").replace(/\bSisyphus\b/g, "Claude Code Agent").replace(/\bMorph\s+plugin\b/gi, "edit plugin").replace(/\bmorph_edit\b/g, "edit").replace(/\bmorph_/g, "").replace(/\bOhMyClaude\b/gi, "Claude");
5643
6020
  }
5644
6021
  function compactSystemText(text, mode) {
5645
6022
  const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}
@@ -5772,11 +6149,69 @@ function buildRequestMetadata(input) {
5772
6149
  }
5773
6150
 
5774
6151
  // src/request/body.ts
5775
- function transformRequestBody(body, signature, runtime) {
5776
- if (!body || typeof body !== "string") return body;
5777
- const TOOL_PREFIX = "mcp_";
6152
+ var TOOL_PREFIX = "mcp_";
6153
+ function getBodyType(body) {
6154
+ if (body === null) return "null";
6155
+ return typeof body;
6156
+ }
6157
+ function getInvalidBodyError(body) {
6158
+ return new TypeError(
6159
+ `opencode-anthropic-auth: expected string body, got ${getBodyType(body)}. This plugin does not support stream bodies. Please file a bug with the OpenCode version.`
6160
+ );
6161
+ }
6162
+ function validateBodyType(body, throwOnInvalid = false) {
6163
+ if (body === void 0 || body === null) {
6164
+ return false;
6165
+ }
6166
+ if (typeof body === "string") {
6167
+ return true;
6168
+ }
6169
+ if (throwOnInvalid) {
6170
+ throw getInvalidBodyError(body);
6171
+ }
6172
+ return false;
6173
+ }
6174
+ function cloneBodyForRetry(body) {
6175
+ validateBodyType(body, true);
6176
+ return body;
6177
+ }
6178
+ function detectDoublePrefix(name) {
6179
+ return name.startsWith(`${TOOL_PREFIX}${TOOL_PREFIX}`);
6180
+ }
6181
+ function prefixToolDefinitionName(name) {
6182
+ if (typeof name !== "string") {
6183
+ return name;
6184
+ }
6185
+ if (detectDoublePrefix(name)) {
6186
+ throw new TypeError(`Double tool prefix detected: ${TOOL_PREFIX}${TOOL_PREFIX}`);
6187
+ }
6188
+ return `${TOOL_PREFIX}${name}`;
6189
+ }
6190
+ function prefixToolUseName(name, literalToolNames, debugLog) {
6191
+ if (typeof name !== "string") {
6192
+ return name;
6193
+ }
6194
+ if (detectDoublePrefix(name)) {
6195
+ throw new TypeError(`Double tool prefix detected in tool_use block: ${name}`);
6196
+ }
6197
+ if (!name.startsWith(TOOL_PREFIX)) {
6198
+ return `${TOOL_PREFIX}${name}`;
6199
+ }
6200
+ if (literalToolNames.has(name)) {
6201
+ return `${TOOL_PREFIX}${name}`;
6202
+ }
6203
+ debugLog?.("prevented double-prefix drift for tool_use block", { name });
6204
+ return name;
6205
+ }
6206
+ function transformRequestBody(body, signature, runtime, relocateThirdPartyPrompts = true, debugLog) {
6207
+ if (body === void 0 || body === null) return body;
6208
+ validateBodyType(body, true);
5778
6209
  try {
5779
6210
  const parsed = JSON.parse(body);
6211
+ const parsedMessages = Array.isArray(parsed.messages) ? parsed.messages : [];
6212
+ const literalToolNames = new Set(
6213
+ Array.isArray(parsed.tools) ? parsed.tools.map((tool) => tool.name).filter((name) => typeof name === "string") : []
6214
+ );
5780
6215
  if (Object.hasOwn(parsed, "betas")) {
5781
6216
  delete parsed.betas;
5782
6217
  }
@@ -5789,7 +6224,50 @@ function transformRequestBody(body, signature, runtime) {
5789
6224
  } else if (!Object.hasOwn(parsed, "temperature")) {
5790
6225
  parsed.temperature = 1;
5791
6226
  }
5792
- parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature, parsed.messages);
6227
+ const allSystemBlocks = buildSystemPromptBlocks(
6228
+ normalizeSystemTextBlocks(parsed.system),
6229
+ signature,
6230
+ parsedMessages
6231
+ );
6232
+ if (signature.enabled && relocateThirdPartyPrompts) {
6233
+ const THIRD_PARTY_MARKERS = /sisyphus|ohmyclaude|oh\s*my\s*claude|morph[_ ]|\.sisyphus\/|ultrawork|autopilot mode|\bohmy\b|SwarmMode|\bomc\b|\bomo\b/i;
6234
+ const ccBlocks = [];
6235
+ const extraBlocks = [];
6236
+ for (const block of allSystemBlocks) {
6237
+ const isBilling = block.text.startsWith("x-anthropic-billing-header:");
6238
+ const isIdentity = block.text === CLAUDE_CODE_IDENTITY_STRING || KNOWN_IDENTITY_STRINGS.has(block.text);
6239
+ const hasThirdParty = THIRD_PARTY_MARKERS.test(block.text);
6240
+ if (isBilling || isIdentity || !hasThirdParty) {
6241
+ ccBlocks.push(block);
6242
+ } else {
6243
+ extraBlocks.push(block);
6244
+ }
6245
+ }
6246
+ parsed.system = ccBlocks;
6247
+ if (extraBlocks.length > 0 && Array.isArray(parsed.messages) && parsed.messages.length > 0) {
6248
+ const extraText = extraBlocks.map((b) => b.text).join("\n\n");
6249
+ const wrapped = `<system-instructions>
6250
+ ${extraText}
6251
+ </system-instructions>`;
6252
+ const firstMsg = parsed.messages[0];
6253
+ if (firstMsg && firstMsg.role === "user") {
6254
+ if (typeof firstMsg.content === "string") {
6255
+ firstMsg.content = `${wrapped}
6256
+
6257
+ ${firstMsg.content}`;
6258
+ } else if (Array.isArray(firstMsg.content)) {
6259
+ firstMsg.content.unshift({ type: "text", text: wrapped });
6260
+ }
6261
+ } else {
6262
+ parsed.messages.unshift({
6263
+ role: "user",
6264
+ content: wrapped
6265
+ });
6266
+ }
6267
+ }
6268
+ } else {
6269
+ parsed.system = allSystemBlocks;
6270
+ }
5793
6271
  if (signature.enabled) {
5794
6272
  const currentMetadata = parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata) ? parsed.metadata : {};
5795
6273
  parsed.metadata = {
@@ -5804,7 +6282,7 @@ function transformRequestBody(body, signature, runtime) {
5804
6282
  if (parsed.tools && Array.isArray(parsed.tools)) {
5805
6283
  parsed.tools = parsed.tools.map((tool) => ({
5806
6284
  ...tool,
5807
- name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name
6285
+ name: prefixToolDefinitionName(tool.name)
5808
6286
  }));
5809
6287
  }
5810
6288
  if (parsed.messages && Array.isArray(parsed.messages)) {
@@ -5814,7 +6292,7 @@ function transformRequestBody(body, signature, runtime) {
5814
6292
  if (block.type === "tool_use" && block.name) {
5815
6293
  return {
5816
6294
  ...block,
5817
- name: `${TOOL_PREFIX}${block.name}`
6295
+ name: prefixToolUseName(block.name, literalToolNames, debugLog)
5818
6296
  };
5819
6297
  }
5820
6298
  return block;
@@ -5824,8 +6302,12 @@ function transformRequestBody(body, signature, runtime) {
5824
6302
  });
5825
6303
  }
5826
6304
  return JSON.stringify(parsed);
5827
- } catch {
5828
- return body;
6305
+ } catch (err) {
6306
+ if (err instanceof SyntaxError) {
6307
+ debugLog?.("body parse failed:", err.message);
6308
+ return body;
6309
+ }
6310
+ throw err;
5829
6311
  }
5830
6312
  }
5831
6313
 
@@ -5887,50 +6369,71 @@ function transformRequestUrl(input) {
5887
6369
  }
5888
6370
 
5889
6371
  // src/response/mcp.ts
5890
- function stripMcpPrefixFromSSE(text) {
5891
- return text.replace(/^data:\s*(.+)$/gm, (_match, jsonStr) => {
5892
- try {
5893
- const parsed = JSON.parse(jsonStr);
5894
- if (stripMcpPrefixFromParsedEvent(parsed)) {
5895
- return `data: ${JSON.stringify(parsed)}`;
5896
- }
5897
- } catch {
5898
- }
5899
- return _match;
5900
- });
6372
+ function stripMcpPrefixFromToolUseBlock(block) {
6373
+ if (!block || typeof block !== "object") return false;
6374
+ const parsedBlock = block;
6375
+ if (parsedBlock.type !== "tool_use" || typeof parsedBlock.name !== "string") {
6376
+ return false;
6377
+ }
6378
+ if (!parsedBlock.name.startsWith("mcp_")) {
6379
+ return false;
6380
+ }
6381
+ parsedBlock.name = parsedBlock.name.slice(4);
6382
+ return true;
6383
+ }
6384
+ function stripMcpPrefixFromContentBlocks(content) {
6385
+ if (!Array.isArray(content)) return false;
6386
+ let modified = false;
6387
+ for (const block of content) {
6388
+ modified = stripMcpPrefixFromToolUseBlock(block) || modified;
6389
+ }
6390
+ return modified;
6391
+ }
6392
+ function stripMcpPrefixFromMessages(messages) {
6393
+ if (!Array.isArray(messages)) return false;
6394
+ let modified = false;
6395
+ for (const message of messages) {
6396
+ if (!message || typeof message !== "object") continue;
6397
+ modified = stripMcpPrefixFromContentBlocks(message.content) || modified;
6398
+ }
6399
+ return modified;
5901
6400
  }
5902
6401
  function stripMcpPrefixFromParsedEvent(parsed) {
5903
6402
  if (!parsed || typeof parsed !== "object") return false;
5904
6403
  const p2 = parsed;
5905
6404
  let modified = false;
5906
- if (p2.content_block && typeof p2.content_block === "object" && p2.content_block.type === "tool_use" && typeof p2.content_block.name === "string" && p2.content_block.name.startsWith("mcp_")) {
5907
- p2.content_block.name = p2.content_block.name.slice(4);
5908
- modified = true;
6405
+ modified = stripMcpPrefixFromToolUseBlock(p2.content_block) || modified;
6406
+ if (p2.message && typeof p2.message === "object") {
6407
+ modified = stripMcpPrefixFromContentBlocks(p2.message.content) || modified;
5909
6408
  }
5910
- if (p2.message && Array.isArray(p2.message.content)) {
5911
- for (const block of p2.message.content) {
5912
- if (!block || typeof block !== "object") continue;
5913
- const b = block;
5914
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
5915
- b.name = b.name.slice(4);
5916
- modified = true;
5917
- }
5918
- }
5919
- }
5920
- if (Array.isArray(p2.content)) {
5921
- for (const block of p2.content) {
5922
- if (!block || typeof block !== "object") continue;
5923
- const b = block;
5924
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
5925
- b.name = b.name.slice(4);
5926
- modified = true;
5927
- }
6409
+ modified = stripMcpPrefixFromContentBlocks(p2.content) || modified;
6410
+ return modified;
6411
+ }
6412
+ function stripMcpPrefixFromJsonBody(body) {
6413
+ try {
6414
+ const parsed = JSON.parse(body);
6415
+ let modified = false;
6416
+ modified = stripMcpPrefixFromContentBlocks(parsed.content) || modified;
6417
+ modified = stripMcpPrefixFromMessages(parsed.messages) || modified;
6418
+ if (parsed.message && typeof parsed.message === "object") {
6419
+ modified = stripMcpPrefixFromContentBlocks(parsed.message.content) || modified;
5928
6420
  }
6421
+ return modified ? JSON.stringify(parsed) : body;
6422
+ } catch {
6423
+ return body;
5929
6424
  }
5930
- return modified;
5931
6425
  }
5932
6426
 
5933
6427
  // src/response/streaming.ts
6428
+ var MAX_UNTERMINATED_SSE_BUFFER = 256 * 1024;
6429
+ var StreamTruncatedError = class extends Error {
6430
+ context;
6431
+ constructor(message, context = {}) {
6432
+ super(message);
6433
+ this.name = "StreamTruncatedError";
6434
+ this.context = context;
6435
+ }
6436
+ };
5934
6437
  function extractUsageFromSSEEvent(parsed, stats) {
5935
6438
  const p2 = parsed;
5936
6439
  if (!p2) return;
@@ -5970,6 +6473,187 @@ function getSSEDataPayload(eventBlock) {
5970
6473
  if (!payload || payload === "[DONE]") return null;
5971
6474
  return payload;
5972
6475
  }
6476
+ function getSSEEventType(eventBlock) {
6477
+ for (const line of eventBlock.split("\n")) {
6478
+ if (!line.startsWith("event:")) continue;
6479
+ const eventType = line.slice(6).trimStart();
6480
+ if (eventType) return eventType;
6481
+ }
6482
+ return null;
6483
+ }
6484
+ function formatSSEEventBlock(eventType, parsed, prettyPrint) {
6485
+ const json = prettyPrint ? JSON.stringify(parsed, null, 2) : JSON.stringify(parsed);
6486
+ const lines = [`event: ${eventType}`];
6487
+ for (const line of json.split("\n")) {
6488
+ lines.push(`data: ${line}`);
6489
+ }
6490
+ lines.push("", "");
6491
+ return lines.join("\n");
6492
+ }
6493
+ function hasRecordedUsage(stats) {
6494
+ return stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0;
6495
+ }
6496
+ function getErrorMessage(parsed) {
6497
+ if (!parsed || typeof parsed !== "object") {
6498
+ return "stream terminated with error event";
6499
+ }
6500
+ const error = parsed.error;
6501
+ if (!error || typeof error !== "object") {
6502
+ return "stream terminated with error event";
6503
+ }
6504
+ const message = error.message;
6505
+ return typeof message === "string" && message ? message : "stream terminated with error event";
6506
+ }
6507
+ function getEventIndex(parsed, eventType) {
6508
+ const index = parsed.index;
6509
+ if (typeof index !== "number") {
6510
+ throw new Error(`invalid SSE ${eventType} event: missing numeric index`);
6511
+ }
6512
+ return index;
6513
+ }
6514
+ function getEventLabel(parsed, eventType) {
6515
+ switch (eventType) {
6516
+ case "content_block_start": {
6517
+ const contentBlock = parsed.content_block;
6518
+ const blockType = contentBlock && typeof contentBlock === "object" ? contentBlock.type : void 0;
6519
+ return typeof blockType === "string" && blockType ? `content_block_start(${blockType})` : eventType;
6520
+ }
6521
+ case "content_block_delta": {
6522
+ const delta = parsed.delta;
6523
+ const deltaType = delta && typeof delta === "object" ? delta.type : void 0;
6524
+ return typeof deltaType === "string" && deltaType ? `content_block_delta(${deltaType})` : eventType;
6525
+ }
6526
+ default:
6527
+ return eventType;
6528
+ }
6529
+ }
6530
+ function getOpenBlockContext(openContentBlocks) {
6531
+ for (const [index2, blockState2] of openContentBlocks) {
6532
+ if (blockState2.type === "tool_use") {
6533
+ return {
6534
+ inFlightEvent: blockState2.partialJson ? "content_block_delta(input_json_delta)" : "content_block_start(tool_use)",
6535
+ openContentBlockIndex: index2,
6536
+ hasPartialJson: blockState2.partialJson.length > 0
6537
+ };
6538
+ }
6539
+ }
6540
+ const firstOpenBlock = openContentBlocks.entries().next().value;
6541
+ if (!firstOpenBlock) {
6542
+ return null;
6543
+ }
6544
+ const [index, blockState] = firstOpenBlock;
6545
+ return {
6546
+ inFlightEvent: `content_block_start(${blockState.type})`,
6547
+ openContentBlockIndex: index,
6548
+ hasPartialJson: blockState.partialJson.length > 0
6549
+ };
6550
+ }
6551
+ function createStreamTruncatedError(context = {}) {
6552
+ return new StreamTruncatedError("Stream truncated without message_stop", context);
6553
+ }
6554
+ function getBufferedEventContext(eventBlock, lastEventType) {
6555
+ const context = {
6556
+ lastEventType: lastEventType ?? void 0
6557
+ };
6558
+ const payload = getSSEDataPayload(eventBlock);
6559
+ if (!payload) {
6560
+ return context;
6561
+ }
6562
+ try {
6563
+ const parsed = JSON.parse(payload);
6564
+ const eventType = getSSEEventType(eventBlock) ?? (typeof parsed.type === "string" ? parsed.type : null);
6565
+ if (eventType) {
6566
+ context.inFlightEvent = getEventLabel(parsed, eventType);
6567
+ }
6568
+ } catch {
6569
+ }
6570
+ return context;
6571
+ }
6572
+ function validateEventState(parsed, eventType, openContentBlocks) {
6573
+ switch (eventType) {
6574
+ case "content_block_start": {
6575
+ const index = getEventIndex(parsed, eventType);
6576
+ const contentBlock = parsed.content_block;
6577
+ if (!contentBlock || typeof contentBlock !== "object") {
6578
+ throw new Error("invalid SSE content_block_start event: missing content_block");
6579
+ }
6580
+ if (openContentBlocks.has(index)) {
6581
+ throw new Error(`duplicate content_block_start for index ${index}`);
6582
+ }
6583
+ const blockType = contentBlock.type;
6584
+ if (typeof blockType !== "string" || !blockType) {
6585
+ throw new Error("invalid SSE content_block_start event: missing content_block.type");
6586
+ }
6587
+ openContentBlocks.set(index, {
6588
+ type: blockType,
6589
+ partialJson: ""
6590
+ });
6591
+ return;
6592
+ }
6593
+ case "content_block_delta": {
6594
+ const index = getEventIndex(parsed, eventType);
6595
+ const blockState = openContentBlocks.get(index);
6596
+ if (!blockState) {
6597
+ throw new Error(`orphan content_block_delta for index ${index}`);
6598
+ }
6599
+ const delta = parsed.delta;
6600
+ if (!delta || typeof delta !== "object") {
6601
+ throw new Error("invalid SSE content_block_delta event: missing delta");
6602
+ }
6603
+ const deltaType = delta.type;
6604
+ if (deltaType === "input_json_delta") {
6605
+ if (blockState.type !== "tool_use") {
6606
+ throw new Error(`orphan input_json_delta for non-tool_use block ${index}`);
6607
+ }
6608
+ const partialJson = delta.partial_json;
6609
+ if (typeof partialJson !== "string") {
6610
+ throw new Error("invalid SSE content_block_delta event: missing delta.partial_json");
6611
+ }
6612
+ blockState.partialJson += partialJson;
6613
+ }
6614
+ return;
6615
+ }
6616
+ case "content_block_stop": {
6617
+ const index = getEventIndex(parsed, eventType);
6618
+ const blockState = openContentBlocks.get(index);
6619
+ if (!blockState) {
6620
+ throw new Error(`orphan content_block_stop for index ${index}`);
6621
+ }
6622
+ if (blockState.type === "tool_use" && blockState.partialJson) {
6623
+ try {
6624
+ JSON.parse(blockState.partialJson);
6625
+ } catch {
6626
+ throw new Error(`incomplete tool_use partial_json for index ${index}`);
6627
+ }
6628
+ }
6629
+ openContentBlocks.delete(index);
6630
+ return;
6631
+ }
6632
+ default:
6633
+ return;
6634
+ }
6635
+ }
6636
+ function getOpenBlockError(openContentBlocks) {
6637
+ const openBlockContext = getOpenBlockContext(openContentBlocks);
6638
+ return openBlockContext ? createStreamTruncatedError(openBlockContext) : null;
6639
+ }
6640
+ function getMessageStopBlockError(openContentBlocks) {
6641
+ for (const [index, blockState] of openContentBlocks) {
6642
+ if (blockState.partialJson) {
6643
+ return new Error(`incomplete tool_use partial_json for index ${index}`);
6644
+ }
6645
+ }
6646
+ return null;
6647
+ }
6648
+ function normalizeChunk(text) {
6649
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
6650
+ }
6651
+ function toStreamError(error) {
6652
+ if (error instanceof Error) {
6653
+ return error;
6654
+ }
6655
+ return new Error(String(error));
6656
+ }
5973
6657
  function getMidStreamAccountError(parsed) {
5974
6658
  const p2 = parsed;
5975
6659
  if (!p2 || p2.type !== "error" || !p2.error) {
@@ -5991,12 +6675,11 @@ function getMidStreamAccountError(parsed) {
5991
6675
  invalidateToken: reason === "AUTH_FAILED"
5992
6676
  };
5993
6677
  }
5994
- function transformResponse(response, onUsage, onAccountError) {
5995
- if (!response.body) return response;
6678
+ function transformResponse(response, onUsage, onAccountError, onStreamError) {
6679
+ if (!response.body || !isEventStreamResponse(response)) return response;
5996
6680
  const reader = response.body.getReader();
5997
- const decoder = new TextDecoder();
6681
+ const decoder = new TextDecoder("utf-8", { fatal: true });
5998
6682
  const encoder = new TextEncoder();
5999
- const EMPTY_CHUNK = new Uint8Array();
6000
6683
  const stats = {
6001
6684
  inputTokens: 0,
6002
6685
  outputTokens: 0,
@@ -6004,82 +6687,145 @@ function transformResponse(response, onUsage, onAccountError) {
6004
6687
  cacheWriteTokens: 0
6005
6688
  };
6006
6689
  let sseBuffer = "";
6007
- let sseRewriteBuffer = "";
6008
6690
  let accountErrorHandled = false;
6009
- function processSSEBuffer(flush = false) {
6691
+ let hasSeenMessageStop = false;
6692
+ let hasSeenError = false;
6693
+ let lastEventType = null;
6694
+ const strictEventValidation = !onUsage && !onAccountError;
6695
+ const openContentBlocks = /* @__PURE__ */ new Map();
6696
+ function enqueueNormalizedEvent(controller, eventBlock) {
6697
+ const payload = getSSEDataPayload(eventBlock);
6698
+ if (!payload) {
6699
+ return;
6700
+ }
6701
+ let parsed;
6702
+ try {
6703
+ parsed = JSON.parse(payload);
6704
+ } catch {
6705
+ throw new Error("invalid SSE event: malformed JSON payload");
6706
+ }
6707
+ const eventType = getSSEEventType(eventBlock) ?? parsed?.type;
6708
+ if (typeof eventType !== "string" || !eventType) {
6709
+ throw new Error("invalid SSE event: missing event type");
6710
+ }
6711
+ const parsedRecord = parsed;
6712
+ lastEventType = getEventLabel(parsedRecord, eventType);
6713
+ if (strictEventValidation) {
6714
+ validateEventState(parsedRecord, eventType, openContentBlocks);
6715
+ }
6716
+ stripMcpPrefixFromParsedEvent(parsedRecord);
6717
+ if (onUsage) {
6718
+ extractUsageFromSSEEvent(parsedRecord, stats);
6719
+ }
6720
+ if (onAccountError && !accountErrorHandled) {
6721
+ const details = getMidStreamAccountError(parsedRecord);
6722
+ if (details) {
6723
+ accountErrorHandled = true;
6724
+ onAccountError(details);
6725
+ }
6726
+ }
6727
+ if (eventType === "message_stop") {
6728
+ if (strictEventValidation) {
6729
+ const openBlockError = getMessageStopBlockError(openContentBlocks);
6730
+ if (openBlockError) {
6731
+ throw openBlockError;
6732
+ }
6733
+ openContentBlocks.clear();
6734
+ }
6735
+ hasSeenMessageStop = true;
6736
+ }
6737
+ if (eventType === "error") {
6738
+ hasSeenError = true;
6739
+ }
6740
+ controller.enqueue(encoder.encode(formatSSEEventBlock(eventType, parsedRecord, strictEventValidation)));
6741
+ if (eventType === "error" && strictEventValidation) {
6742
+ throw new Error(getErrorMessage(parsedRecord));
6743
+ }
6744
+ }
6745
+ function processBufferedEvents(controller) {
6746
+ let emitted = false;
6010
6747
  while (true) {
6011
6748
  const boundary = sseBuffer.indexOf("\n\n");
6012
6749
  if (boundary === -1) {
6013
- if (!flush) return;
6014
- if (!sseBuffer.trim()) {
6015
- sseBuffer = "";
6016
- return;
6750
+ if (sseBuffer.length > MAX_UNTERMINATED_SSE_BUFFER) {
6751
+ throw new Error("unterminated SSE event buffer exceeded limit");
6017
6752
  }
6753
+ return emitted;
6018
6754
  }
6019
- const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
6020
- sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
6021
- const payload = getSSEDataPayload(eventBlock);
6022
- if (!payload) {
6023
- if (boundary === -1) return;
6755
+ const eventBlock = sseBuffer.slice(0, boundary);
6756
+ sseBuffer = sseBuffer.slice(boundary + 2);
6757
+ if (!eventBlock.trim()) {
6024
6758
  continue;
6025
6759
  }
6760
+ enqueueNormalizedEvent(controller, eventBlock);
6761
+ emitted = true;
6762
+ }
6763
+ }
6764
+ async function failStream(controller, error) {
6765
+ const streamError = toStreamError(error);
6766
+ if (onStreamError) {
6026
6767
  try {
6027
- const parsed = JSON.parse(payload);
6028
- if (onUsage) {
6029
- extractUsageFromSSEEvent(parsed, stats);
6030
- }
6031
- if (onAccountError && !accountErrorHandled) {
6032
- const details = getMidStreamAccountError(parsed);
6033
- if (details) {
6034
- accountErrorHandled = true;
6035
- onAccountError(details);
6036
- }
6037
- }
6768
+ onStreamError(streamError);
6038
6769
  } catch {
6039
6770
  }
6040
- if (boundary === -1) return;
6041
6771
  }
6042
- }
6043
- function rewriteSSEChunk(chunk, flush = false) {
6044
- sseRewriteBuffer += chunk;
6045
- if (!flush) {
6046
- const boundary = sseRewriteBuffer.lastIndexOf("\n");
6047
- if (boundary === -1) return "";
6048
- const complete = sseRewriteBuffer.slice(0, boundary + 1);
6049
- sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
6050
- return stripMcpPrefixFromSSE(complete);
6772
+ try {
6773
+ await reader.cancel(streamError);
6774
+ } catch {
6051
6775
  }
6052
- if (!sseRewriteBuffer) return "";
6053
- const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
6054
- sseRewriteBuffer = "";
6055
- return finalText;
6776
+ controller.error(streamError);
6056
6777
  }
6057
6778
  const stream = new ReadableStream({
6058
6779
  async pull(controller) {
6059
- const { done, value } = await reader.read();
6060
- if (done) {
6061
- processSSEBuffer(true);
6062
- const rewrittenTail = rewriteSSEChunk("", true);
6063
- if (rewrittenTail) {
6064
- controller.enqueue(encoder.encode(rewrittenTail));
6065
- }
6066
- if (onUsage && (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)) {
6067
- onUsage(stats);
6780
+ try {
6781
+ while (true) {
6782
+ const { done, value } = await reader.read();
6783
+ if (done) {
6784
+ const flushedText = decoder.decode();
6785
+ if (flushedText) {
6786
+ sseBuffer += normalizeChunk(flushedText);
6787
+ processBufferedEvents(controller);
6788
+ }
6789
+ if (sseBuffer.trim()) {
6790
+ if (strictEventValidation) {
6791
+ throw createStreamTruncatedError(getBufferedEventContext(sseBuffer, lastEventType));
6792
+ }
6793
+ enqueueNormalizedEvent(controller, sseBuffer);
6794
+ sseBuffer = "";
6795
+ }
6796
+ if (strictEventValidation) {
6797
+ const openBlockError = getOpenBlockError(openContentBlocks);
6798
+ if (openBlockError) {
6799
+ throw openBlockError;
6800
+ }
6801
+ if (!hasSeenMessageStop && !hasSeenError) {
6802
+ throw createStreamTruncatedError({
6803
+ inFlightEvent: lastEventType ?? void 0,
6804
+ lastEventType: lastEventType ?? void 0
6805
+ });
6806
+ }
6807
+ }
6808
+ if (onUsage && hasRecordedUsage(stats)) {
6809
+ onUsage(stats);
6810
+ }
6811
+ controller.close();
6812
+ return;
6813
+ }
6814
+ const text = decoder.decode(value, { stream: true });
6815
+ if (!text) {
6816
+ continue;
6817
+ }
6818
+ sseBuffer += normalizeChunk(text);
6819
+ if (processBufferedEvents(controller)) {
6820
+ return;
6821
+ }
6068
6822
  }
6069
- controller.close();
6070
- return;
6071
- }
6072
- const text = decoder.decode(value, { stream: true });
6073
- if (onUsage || onAccountError) {
6074
- sseBuffer += text.replace(/\r\n/g, "\n");
6075
- processSSEBuffer(false);
6076
- }
6077
- const rewrittenText = rewriteSSEChunk(text, false);
6078
- if (rewrittenText) {
6079
- controller.enqueue(encoder.encode(rewrittenText));
6080
- } else {
6081
- controller.enqueue(EMPTY_CHUNK);
6823
+ } catch (error) {
6824
+ await failStream(controller, error);
6082
6825
  }
6826
+ },
6827
+ cancel(reason) {
6828
+ return reader.cancel(reason);
6083
6829
  }
6084
6830
  });
6085
6831
  return new Response(stream, {
@@ -6094,6 +6840,7 @@ function isEventStreamResponse(response) {
6094
6840
  }
6095
6841
 
6096
6842
  // src/index.ts
6843
+ init_account_identity();
6097
6844
  init_storage();
6098
6845
 
6099
6846
  // src/token-refresh.ts
@@ -6105,9 +6852,9 @@ init_storage();
6105
6852
  import { createHash as createHash3, randomBytes as randomBytes4 } from "node:crypto";
6106
6853
  import { promises as fs2 } from "node:fs";
6107
6854
  import { dirname as dirname3, join as join5 } from "node:path";
6108
- var DEFAULT_LOCK_TIMEOUT_MS = 2e3;
6855
+ var DEFAULT_LOCK_TIMEOUT_MS = 15e3;
6109
6856
  var DEFAULT_LOCK_BACKOFF_MS = 50;
6110
- var DEFAULT_STALE_LOCK_MS = 2e4;
6857
+ var DEFAULT_STALE_LOCK_MS = 9e4;
6111
6858
  function delay(ms) {
6112
6859
  return new Promise((resolve2) => setTimeout(resolve2, ms));
6113
6860
  }
@@ -6212,16 +6959,19 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6212
6959
  if (!diskAuth) return false;
6213
6960
  const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
6214
6961
  const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
6215
- const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
6962
+ const diskIsNewer = diskTokenUpdatedAt > memTokenUpdatedAt;
6963
+ const diskHasDifferentRefreshToken = diskAuth.refreshToken !== account.refreshToken;
6216
6964
  const memAuthExpired = !account.expires || account.expires <= Date.now();
6217
6965
  const allowExpiredFallback = options.allowExpiredFallback === true;
6218
- if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
6966
+ if (!diskIsNewer && !(allowExpiredFallback && diskHasDifferentRefreshToken && memAuthExpired)) {
6219
6967
  return false;
6220
6968
  }
6221
6969
  account.refreshToken = diskAuth.refreshToken;
6222
6970
  account.access = diskAuth.access;
6223
6971
  account.expires = diskAuth.expires;
6224
- account.tokenUpdatedAt = Math.max(memTokenUpdatedAt, diskTokenUpdatedAt);
6972
+ if (diskIsNewer) {
6973
+ account.tokenUpdatedAt = diskTokenUpdatedAt;
6974
+ }
6225
6975
  return true;
6226
6976
  }
6227
6977
  function claudeBinaryPath() {
@@ -6287,11 +7037,9 @@ async function refreshCCAccount(account) {
6287
7037
  markTokenStateUpdated(account);
6288
7038
  return refreshedCredential.accessToken;
6289
7039
  }
6290
- async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
7040
+ async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated, debugLog } = {}) {
6291
7041
  const lockResult = await acquireRefreshLock(account.id, {
6292
- timeoutMs: 2e3,
6293
- backoffMs: 60,
6294
- staleMs: 2e4
7042
+ backoffMs: 60
6295
7043
  });
6296
7044
  const lock = lockResult && typeof lockResult === "object" ? lockResult : {
6297
7045
  acquired: true,
@@ -6319,7 +7067,9 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
6319
7067
  const accessToken = await refreshCCAccount(account);
6320
7068
  if (accessToken) {
6321
7069
  if (onTokensUpdated) {
6322
- await onTokensUpdated().catch(() => void 0);
7070
+ await onTokensUpdated().catch((err) => {
7071
+ debugLog?.("onTokensUpdated failed:", err.message);
7072
+ });
6323
7073
  }
6324
7074
  await client.auth?.set({
6325
7075
  path: { id: "anthropic" },
@@ -6329,7 +7079,9 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
6329
7079
  access: account.access,
6330
7080
  expires: account.expires
6331
7081
  }
6332
- }).catch(() => void 0);
7082
+ }).catch((err) => {
7083
+ debugLog?.("auth.set failed:", err.message);
7084
+ });
6333
7085
  return accessToken;
6334
7086
  }
6335
7087
  throw new Error("CC credential refresh failed");
@@ -6375,28 +7127,181 @@ function formatSwitchReason(status, reason) {
6375
7127
 
6376
7128
  // src/bun-fetch.ts
6377
7129
  import { execFileSync, spawn } from "node:child_process";
6378
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5, unlinkSync } from "node:fs";
7130
+ import { existsSync as existsSync5 } from "node:fs";
6379
7131
  import { dirname as dirname4, join as join6 } from "node:path";
6380
- import { tmpdir } from "node:os";
7132
+ import * as readline from "node:readline";
6381
7133
  import { fileURLToPath } from "node:url";
6382
- var proxyPort = null;
6383
- var proxyProcess = null;
6384
- var starting = null;
6385
- var healthCheckFails = 0;
6386
- var FIXED_PORT = 48372;
6387
- var PID_FILE = join6(tmpdir(), "opencode-bun-proxy.pid");
6388
- var MAX_HEALTH_FAILS = 2;
6389
- var exitHandlerRegistered = false;
6390
- function registerExitHandler() {
6391
- if (exitHandlerRegistered) return;
6392
- exitHandlerRegistered = true;
6393
- const cleanup = () => {
6394
- stopBunProxy();
7134
+
7135
+ // src/circuit-breaker.ts
7136
+ var DEFAULT_FAILURE_THRESHOLD = 5;
7137
+ var DEFAULT_RESET_TIMEOUT_MS = 3e4;
7138
+ var pendingClientBreakers = /* @__PURE__ */ new Map();
7139
+ function normalizeConfig(options = {}) {
7140
+ return {
7141
+ clientId: options.clientId,
7142
+ failureThreshold: Math.max(1, Math.trunc(options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD)),
7143
+ resetTimeoutMs: Math.max(0, Math.trunc(options.resetTimeoutMs ?? DEFAULT_RESET_TIMEOUT_MS))
6395
7144
  };
6396
- process.on("exit", cleanup);
6397
- process.on("SIGINT", cleanup);
6398
- process.on("SIGTERM", cleanup);
6399
7145
  }
7146
+ function getErrorMessage2(error) {
7147
+ if (error instanceof Error && error.message) {
7148
+ return error.message;
7149
+ }
7150
+ if (typeof error === "string" && error) {
7151
+ return error;
7152
+ }
7153
+ return "Unknown error";
7154
+ }
7155
+ function isPromiseLike(value) {
7156
+ return typeof value === "object" && value !== null && "then" in value;
7157
+ }
7158
+ function releasePendingClientBreaker(clientId, breaker) {
7159
+ queueMicrotask(() => {
7160
+ if (pendingClientBreakers.get(clientId) === breaker) {
7161
+ pendingClientBreakers.delete(clientId);
7162
+ }
7163
+ });
7164
+ }
7165
+ var CircuitBreaker = class {
7166
+ config;
7167
+ state = "CLOSED" /* CLOSED */;
7168
+ failureCount = 0;
7169
+ openedAt = null;
7170
+ resetTimer = null;
7171
+ constructor(options = {}) {
7172
+ this.config = normalizeConfig(options);
7173
+ }
7174
+ getState() {
7175
+ return this.state;
7176
+ }
7177
+ getFailureCount() {
7178
+ return this.failureCount;
7179
+ }
7180
+ getOpenedAt() {
7181
+ return this.openedAt;
7182
+ }
7183
+ getConfig() {
7184
+ return { ...this.config };
7185
+ }
7186
+ canExecute() {
7187
+ return this.state !== "OPEN" /* OPEN */;
7188
+ }
7189
+ recordSuccess() {
7190
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
7191
+ this.transitionToClosed();
7192
+ return;
7193
+ }
7194
+ if (this.state === "CLOSED" /* CLOSED */) {
7195
+ this.failureCount = 0;
7196
+ }
7197
+ }
7198
+ recordFailure() {
7199
+ if (this.state === "HALF_OPEN" /* HALF_OPEN */) {
7200
+ this.transitionToOpen();
7201
+ return;
7202
+ }
7203
+ if (this.state === "OPEN" /* OPEN */) {
7204
+ return;
7205
+ }
7206
+ this.failureCount += 1;
7207
+ if (this.failureCount >= this.config.failureThreshold) {
7208
+ this.transitionToOpen();
7209
+ }
7210
+ }
7211
+ transitionToHalfOpen() {
7212
+ this.clearResetTimer();
7213
+ this.state = "HALF_OPEN" /* HALF_OPEN */;
7214
+ this.openedAt = null;
7215
+ this.failureCount = 0;
7216
+ }
7217
+ execute(operation) {
7218
+ if (!this.canExecute()) {
7219
+ return {
7220
+ success: false,
7221
+ error: "Circuit breaker is OPEN"
7222
+ };
7223
+ }
7224
+ try {
7225
+ const result = operation();
7226
+ if (isPromiseLike(result)) {
7227
+ return result.then((data) => {
7228
+ this.recordSuccess();
7229
+ return {
7230
+ success: true,
7231
+ data
7232
+ };
7233
+ }).catch((error) => {
7234
+ this.recordFailure();
7235
+ return {
7236
+ success: false,
7237
+ error: getErrorMessage2(error)
7238
+ };
7239
+ });
7240
+ }
7241
+ this.recordSuccess();
7242
+ return {
7243
+ success: true,
7244
+ data: result
7245
+ };
7246
+ } catch (error) {
7247
+ this.recordFailure();
7248
+ return {
7249
+ success: false,
7250
+ error: getErrorMessage2(error)
7251
+ };
7252
+ }
7253
+ }
7254
+ dispose() {
7255
+ this.clearResetTimer();
7256
+ }
7257
+ transitionToClosed() {
7258
+ this.clearResetTimer();
7259
+ this.state = "CLOSED" /* CLOSED */;
7260
+ this.failureCount = 0;
7261
+ this.openedAt = null;
7262
+ }
7263
+ transitionToOpen() {
7264
+ this.clearResetTimer();
7265
+ this.state = "OPEN" /* OPEN */;
7266
+ this.openedAt = Date.now();
7267
+ this.scheduleHalfOpenTransition();
7268
+ }
7269
+ scheduleHalfOpenTransition() {
7270
+ this.resetTimer = setTimeout(() => {
7271
+ this.resetTimer = null;
7272
+ if (this.state === "OPEN" /* OPEN */) {
7273
+ this.transitionToHalfOpen();
7274
+ }
7275
+ }, this.config.resetTimeoutMs);
7276
+ this.resetTimer.unref?.();
7277
+ }
7278
+ clearResetTimer() {
7279
+ if (!this.resetTimer) {
7280
+ return;
7281
+ }
7282
+ clearTimeout(this.resetTimer);
7283
+ this.resetTimer = null;
7284
+ }
7285
+ };
7286
+ function createCircuitBreaker(options = {}) {
7287
+ if (!options.clientId) {
7288
+ return new CircuitBreaker(options);
7289
+ }
7290
+ const existingBreaker = pendingClientBreakers.get(options.clientId);
7291
+ if (existingBreaker) {
7292
+ return existingBreaker;
7293
+ }
7294
+ const breaker = new CircuitBreaker(options);
7295
+ pendingClientBreakers.set(options.clientId, breaker);
7296
+ releasePendingClientBreaker(options.clientId, breaker);
7297
+ return breaker;
7298
+ }
7299
+
7300
+ // src/bun-fetch.ts
7301
+ var DEFAULT_PROXY_HOST = "127.0.0.1";
7302
+ var DEFAULT_STARTUP_TIMEOUT_MS = 5e3;
7303
+ var DEFAULT_BREAKER_FAILURE_THRESHOLD = 2;
7304
+ var DEFAULT_BREAKER_RESET_TIMEOUT_MS = 1e4;
6400
7305
  function findProxyScript() {
6401
7306
  const dir = typeof __dirname !== "undefined" ? __dirname : dirname4(fileURLToPath(import.meta.url));
6402
7307
  for (const candidate of [
@@ -6404,189 +7309,337 @@ function findProxyScript() {
6404
7309
  join6(dir, "..", "dist", "bun-proxy.mjs"),
6405
7310
  join6(dir, "bun-proxy.ts")
6406
7311
  ]) {
6407
- if (existsSync5(candidate)) return candidate;
7312
+ if (existsSync5(candidate)) {
7313
+ return candidate;
7314
+ }
6408
7315
  }
6409
7316
  return null;
6410
7317
  }
6411
- var _hasBun = null;
6412
- function hasBun() {
6413
- if (_hasBun !== null) return _hasBun;
7318
+ function detectBunAvailability() {
6414
7319
  try {
6415
- execFileSync("which", ["bun"], { stdio: "ignore" });
6416
- _hasBun = true;
7320
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
7321
+ return true;
6417
7322
  } catch {
6418
- _hasBun = false;
7323
+ return false;
6419
7324
  }
6420
- return _hasBun;
6421
7325
  }
6422
- function killStaleProxy() {
6423
- try {
6424
- const raw = readFileSync6(PID_FILE, "utf-8").trim();
6425
- const pid = parseInt(raw, 10);
6426
- if (pid > 0) {
6427
- try {
6428
- process.kill(pid, "SIGTERM");
6429
- } catch {
6430
- }
6431
- }
6432
- unlinkSync(PID_FILE);
6433
- } catch {
7326
+ function toHeaders(headersInit) {
7327
+ return new Headers(headersInit ?? void 0);
7328
+ }
7329
+ function toRequestUrl(input) {
7330
+ return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
7331
+ }
7332
+ function resolveProxySignal(input, init) {
7333
+ if (init?.signal) {
7334
+ return init.signal;
6434
7335
  }
7336
+ return input instanceof Request ? input.signal : void 0;
6435
7337
  }
6436
- async function isProxyHealthy(port) {
6437
- try {
6438
- const resp = await fetch(`http://127.0.0.1:${port}/__health`, {
6439
- signal: AbortSignal.timeout(2e3)
6440
- });
6441
- return resp.ok;
6442
- } catch {
6443
- return false;
7338
+ function buildProxyRequestInit(input, init) {
7339
+ const targetUrl = toRequestUrl(input);
7340
+ const headers = toHeaders(init?.headers);
7341
+ const signal = resolveProxySignal(input, init);
7342
+ headers.set("x-proxy-url", targetUrl);
7343
+ return {
7344
+ ...init,
7345
+ headers,
7346
+ ...signal ? { signal } : {}
7347
+ };
7348
+ }
7349
+ async function writeDebugArtifacts(url, init) {
7350
+ if (!init.body || !url.includes("/v1/messages") || url.includes("count_tokens")) {
7351
+ return;
6444
7352
  }
7353
+ const { writeFileSync: writeFileSync5 } = await import("node:fs");
7354
+ writeFileSync5(
7355
+ "/tmp/opencode-last-request.json",
7356
+ typeof init.body === "string" ? init.body : JSON.stringify(init.body)
7357
+ );
7358
+ const logHeaders = {};
7359
+ toHeaders(init.headers).forEach((value, key) => {
7360
+ logHeaders[key] = key === "authorization" ? "Bearer ***" : value;
7361
+ });
7362
+ writeFileSync5("/tmp/opencode-last-headers.json", JSON.stringify(logHeaders, null, 2));
6445
7363
  }
6446
- function spawnProxy() {
6447
- return new Promise((resolve2) => {
7364
+ function createBunFetch(options = {}) {
7365
+ const breaker = createCircuitBreaker({
7366
+ failureThreshold: DEFAULT_BREAKER_FAILURE_THRESHOLD,
7367
+ resetTimeoutMs: DEFAULT_BREAKER_RESET_TIMEOUT_MS
7368
+ });
7369
+ const closingChildren = /* @__PURE__ */ new WeakSet();
7370
+ const defaultDebug = options.debug ?? false;
7371
+ const onProxyStatus = options.onProxyStatus;
7372
+ const state = {
7373
+ activeChild: null,
7374
+ activePort: null,
7375
+ startingChild: null,
7376
+ startPromise: null,
7377
+ bunAvailable: null,
7378
+ pendingFetches: []
7379
+ };
7380
+ const getStatus = (reason = "idle", status = "state") => ({
7381
+ status,
7382
+ mode: state.activePort !== null ? "proxy" : state.startPromise ? "starting" : "native",
7383
+ port: state.activePort,
7384
+ bunAvailable: state.bunAvailable,
7385
+ childPid: state.activeChild?.pid ?? state.startingChild?.pid ?? null,
7386
+ circuitState: breaker.getState(),
7387
+ circuitFailureCount: breaker.getFailureCount(),
7388
+ reason
7389
+ });
7390
+ const reportStatus = (reason) => {
7391
+ onProxyStatus?.(getStatus(reason));
7392
+ };
7393
+ const reportFallback = (reason, _debugOverride) => {
7394
+ onProxyStatus?.(getStatus(reason, "fallback"));
7395
+ console.error(
7396
+ `[opencode-anthropic-auth] Native fetch fallback engaged (${reason}); Bun proxy fingerprint mimicry disabled for this request`
7397
+ );
7398
+ };
7399
+ const resolveDebug = (debugOverride) => debugOverride ?? defaultDebug;
7400
+ const clearActiveProxy = (child) => {
7401
+ if (child && state.activeChild === child) {
7402
+ state.activeChild = null;
7403
+ state.activePort = null;
7404
+ }
7405
+ if (child && state.startingChild === child) {
7406
+ state.startingChild = null;
7407
+ }
7408
+ };
7409
+ const flushPendingFetches = (mode, reason = "proxy-unavailable") => {
7410
+ const pendingFetches = state.pendingFetches.splice(0, state.pendingFetches.length);
7411
+ const useForwardFetch = pendingFetches.length <= 2;
7412
+ for (const pendingFetch of pendingFetches) {
7413
+ if (mode === "proxy") {
7414
+ pendingFetch.runProxy(useForwardFetch);
7415
+ continue;
7416
+ }
7417
+ pendingFetch.runNative(reason);
7418
+ }
7419
+ };
7420
+ const startProxy = async (debugOverride) => {
7421
+ if (state.activeChild && state.activePort !== null && !state.activeChild.killed) {
7422
+ return state.activePort;
7423
+ }
7424
+ if (state.startPromise) {
7425
+ return state.startPromise;
7426
+ }
7427
+ if (!breaker.canExecute()) {
7428
+ reportStatus("breaker-open");
7429
+ flushPendingFetches("native", "breaker-open");
7430
+ return null;
7431
+ }
7432
+ if (state.bunAvailable === false) {
7433
+ reportStatus("bun-unavailable");
7434
+ flushPendingFetches("native", "bun-unavailable");
7435
+ return null;
7436
+ }
6448
7437
  const script = findProxyScript();
6449
- if (!script || !hasBun()) {
6450
- resolve2(null);
6451
- return;
7438
+ state.bunAvailable = detectBunAvailability();
7439
+ if (!script || !state.bunAvailable) {
7440
+ breaker.recordFailure();
7441
+ reportStatus(script ? "bun-unavailable" : "proxy-script-missing");
7442
+ flushPendingFetches("native", script ? "bun-unavailable" : "proxy-script-missing");
7443
+ return null;
6452
7444
  }
6453
- killStaleProxy();
6454
- try {
6455
- const child = spawn("bun", ["run", script, String(FIXED_PORT)], {
6456
- stdio: ["ignore", "pipe", "pipe"],
6457
- detached: false
6458
- });
6459
- proxyProcess = child;
6460
- registerExitHandler();
6461
- let done = false;
6462
- const finish = (port) => {
6463
- if (done) return;
6464
- done = true;
6465
- if (port && child.pid) {
6466
- try {
6467
- writeFileSync5(PID_FILE, String(child.pid));
6468
- } catch {
7445
+ state.startPromise = new Promise((resolve2) => {
7446
+ const debugEnabled = resolveDebug(debugOverride);
7447
+ let child;
7448
+ try {
7449
+ child = spawn("bun", ["run", script, "--parent-pid", String(process.pid)], {
7450
+ stdio: ["ignore", "pipe", "pipe"],
7451
+ env: {
7452
+ ...process.env,
7453
+ OPENCODE_ANTHROPIC_DEBUG: debugEnabled ? "1" : "0"
6469
7454
  }
7455
+ });
7456
+ } catch {
7457
+ breaker.recordFailure();
7458
+ reportStatus("spawn-failed");
7459
+ flushPendingFetches("native", "spawn-failed");
7460
+ resolve2(null);
7461
+ return;
7462
+ }
7463
+ state.startingChild = child;
7464
+ reportStatus("starting");
7465
+ const stdout2 = child.stdout;
7466
+ if (!stdout2) {
7467
+ clearActiveProxy(child);
7468
+ breaker.recordFailure();
7469
+ reportStatus("stdout-missing");
7470
+ flushPendingFetches("native", "stdout-missing");
7471
+ resolve2(null);
7472
+ return;
7473
+ }
7474
+ let settled = false;
7475
+ const stdoutLines = readline.createInterface({ input: stdout2 });
7476
+ const startupTimeout = setTimeout(() => {
7477
+ finalize(null, "startup-timeout");
7478
+ }, DEFAULT_STARTUP_TIMEOUT_MS);
7479
+ startupTimeout.unref?.();
7480
+ const cleanupStartupResources = () => {
7481
+ clearTimeout(startupTimeout);
7482
+ stdoutLines.close();
7483
+ };
7484
+ const finalize = (result, reason) => {
7485
+ if (settled) {
7486
+ return;
7487
+ }
7488
+ settled = true;
7489
+ cleanupStartupResources();
7490
+ if (result) {
7491
+ state.startingChild = null;
7492
+ state.activeChild = result.child;
7493
+ state.activePort = result.port;
7494
+ breaker.recordSuccess();
7495
+ reportStatus(reason);
7496
+ flushPendingFetches("proxy");
7497
+ resolve2(result.port);
7498
+ return;
6470
7499
  }
6471
- resolve2(port);
7500
+ clearActiveProxy(child);
7501
+ breaker.recordFailure();
7502
+ reportStatus(reason);
7503
+ flushPendingFetches("native", reason);
7504
+ resolve2(null);
6472
7505
  };
6473
- child.stdout?.on("data", (chunk) => {
6474
- const m = chunk.toString().match(/BUN_PROXY_PORT=(\d+)/);
6475
- if (m) {
6476
- proxyPort = parseInt(m[1], 10);
6477
- healthCheckFails = 0;
6478
- finish(proxyPort);
7506
+ stdoutLines.on("line", (line) => {
7507
+ const match = line.match(/^BUN_PROXY_PORT=(\d+)$/);
7508
+ if (!match) {
7509
+ return;
6479
7510
  }
7511
+ finalize(
7512
+ {
7513
+ child,
7514
+ port: Number.parseInt(match[1], 10)
7515
+ },
7516
+ "proxy-ready"
7517
+ );
6480
7518
  });
6481
- child.on("error", () => {
6482
- finish(null);
6483
- proxyPort = null;
6484
- proxyProcess = null;
6485
- starting = null;
7519
+ child.once("error", () => {
7520
+ finalize(null, "child-error");
6486
7521
  });
6487
- child.on("exit", () => {
6488
- proxyPort = null;
6489
- proxyProcess = null;
6490
- starting = null;
6491
- finish(null);
7522
+ child.once("exit", () => {
7523
+ const shutdownOwned = closingChildren.has(child);
7524
+ const isCurrentChild = state.activeChild === child || state.startingChild === child;
7525
+ clearActiveProxy(child);
7526
+ if (!settled) {
7527
+ finalize(null, shutdownOwned ? "shutdown-complete" : "child-exit-before-ready");
7528
+ return;
7529
+ }
7530
+ if (!shutdownOwned && isCurrentChild) {
7531
+ breaker.recordFailure();
7532
+ reportStatus("child-exited");
7533
+ }
6492
7534
  });
6493
- setTimeout(() => finish(null), 5e3);
6494
- } catch {
6495
- resolve2(null);
6496
- }
6497
- });
6498
- }
6499
- async function ensureBunProxy() {
6500
- if (process.env.VITEST || process.env.NODE_ENV === "test") return null;
6501
- if (proxyPort && proxyProcess && !proxyProcess.killed) {
6502
- return proxyPort;
6503
- }
6504
- if (!proxyPort && await isProxyHealthy(FIXED_PORT)) {
6505
- proxyPort = FIXED_PORT;
6506
- console.error("[bun-fetch] Reusing existing Bun proxy on port", FIXED_PORT);
6507
- return proxyPort;
6508
- }
6509
- if (proxyPort && (!proxyProcess || proxyProcess.killed)) {
6510
- proxyPort = null;
6511
- proxyProcess = null;
6512
- starting = null;
6513
- }
6514
- if (starting) return starting;
6515
- starting = spawnProxy();
6516
- const port = await starting;
6517
- starting = null;
6518
- if (port) console.error("[bun-fetch] Bun proxy started on port", port);
6519
- else console.error("[bun-fetch] Failed to start Bun proxy, falling back to Node.js fetch");
6520
- return port;
6521
- }
6522
- function stopBunProxy() {
6523
- if (proxyProcess) {
6524
- try {
6525
- proxyProcess.kill();
6526
- } catch {
6527
- }
6528
- proxyProcess = null;
6529
- }
6530
- proxyPort = null;
6531
- starting = null;
6532
- killStaleProxy();
6533
- }
6534
- async function fetchViaBun(input, init) {
6535
- const port = await ensureBunProxy();
6536
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
6537
- if (!port) return fetch(input, init);
6538
- const headers = new Headers(init.headers);
6539
- headers.set("x-proxy-url", url);
6540
- try {
6541
- const resp = await fetch(`http://127.0.0.1:${port}/`, {
6542
- method: init.method || "POST",
6543
- headers,
6544
- body: init.body
7535
+ }).finally(() => {
7536
+ state.startPromise = null;
6545
7537
  });
6546
- if (resp.status === 502) {
6547
- const errText = await resp.text();
6548
- throw new Error(`Bun proxy upstream error: ${errText}`);
6549
- }
6550
- healthCheckFails = 0;
6551
- return resp;
6552
- } catch (err) {
6553
- healthCheckFails++;
6554
- if (healthCheckFails >= MAX_HEALTH_FAILS) {
6555
- stopBunProxy();
6556
- const newPort = await ensureBunProxy();
6557
- if (newPort) {
6558
- healthCheckFails = 0;
6559
- const retryHeaders = new Headers(init.headers);
6560
- retryHeaders.set("x-proxy-url", url);
6561
- return fetch(`http://127.0.0.1:${newPort}/`, {
6562
- method: init.method || "POST",
6563
- headers: retryHeaders,
6564
- body: init.body
6565
- });
7538
+ return state.startPromise;
7539
+ };
7540
+ const shutdown = async () => {
7541
+ const children = [state.startingChild, state.activeChild].filter(
7542
+ (child) => child !== null
7543
+ );
7544
+ state.startPromise = null;
7545
+ state.startingChild = null;
7546
+ state.activeChild = null;
7547
+ state.activePort = null;
7548
+ for (const child of children) {
7549
+ closingChildren.add(child);
7550
+ if (!child.killed) {
7551
+ try {
7552
+ child.kill("SIGTERM");
7553
+ } catch {
7554
+ }
6566
7555
  }
6567
7556
  }
6568
- throw err;
6569
- }
7557
+ breaker.dispose();
7558
+ reportStatus("shutdown-requested");
7559
+ };
7560
+ const fetchThroughProxy = async (input, init, debugOverride) => {
7561
+ const url = toRequestUrl(input);
7562
+ const fetchNative = async (reason) => {
7563
+ reportFallback(reason, debugOverride);
7564
+ return globalThis.fetch(input, init);
7565
+ };
7566
+ const fetchFromActiveProxy = async (useForwardFetch) => {
7567
+ const port = state.activePort;
7568
+ if (port === null) {
7569
+ return fetchNative("proxy-port-missing");
7570
+ }
7571
+ if (resolveDebug(debugOverride)) {
7572
+ console.error(`[opencode-anthropic-auth] Routing through Bun proxy at :${port} \u2192 ${url}`);
7573
+ }
7574
+ if (resolveDebug(debugOverride)) {
7575
+ try {
7576
+ await writeDebugArtifacts(url, init ?? {});
7577
+ if ((init?.body ?? null) !== null && url.includes("/v1/messages") && !url.includes("count_tokens")) {
7578
+ console.error("[opencode-anthropic-auth] Dumped request to /tmp/opencode-last-request.json");
7579
+ }
7580
+ } catch (error) {
7581
+ console.error("[opencode-anthropic-auth] Failed to dump request:", error);
7582
+ }
7583
+ }
7584
+ const proxyInit = buildProxyRequestInit(input, init);
7585
+ const forwardFetch = state.activeChild?.forwardFetch;
7586
+ const response = await (useForwardFetch && typeof forwardFetch === "function" ? forwardFetch(`http://${DEFAULT_PROXY_HOST}:${port}/`, proxyInit) : fetch(`http://${DEFAULT_PROXY_HOST}:${port}/`, proxyInit));
7587
+ if (response.status === 502) {
7588
+ const errorText = await response.text();
7589
+ throw new Error(`Bun proxy upstream error: ${errorText}`);
7590
+ }
7591
+ return response;
7592
+ };
7593
+ if (state.activeChild && state.activePort !== null && !state.activeChild.killed) {
7594
+ return fetchFromActiveProxy(true);
7595
+ }
7596
+ return new Promise((resolve2, reject) => {
7597
+ let settled = false;
7598
+ const pendingFetch = {
7599
+ runProxy: (useForwardFetch) => {
7600
+ if (settled) {
7601
+ return;
7602
+ }
7603
+ settled = true;
7604
+ void fetchFromActiveProxy(useForwardFetch).then(resolve2, reject);
7605
+ },
7606
+ runNative: (reason) => {
7607
+ if (settled) {
7608
+ return;
7609
+ }
7610
+ settled = true;
7611
+ void fetchNative(reason).then(resolve2, reject);
7612
+ }
7613
+ };
7614
+ state.pendingFetches.push(pendingFetch);
7615
+ void startProxy(debugOverride).catch(() => {
7616
+ state.pendingFetches = state.pendingFetches.filter((candidate) => candidate !== pendingFetch);
7617
+ pendingFetch.runNative("proxy-start-error");
7618
+ });
7619
+ });
7620
+ };
7621
+ const instance = {
7622
+ fetch(input, init) {
7623
+ return fetchThroughProxy(input, init);
7624
+ },
7625
+ ensureProxy: startProxy,
7626
+ fetchWithDebug: fetchThroughProxy,
7627
+ shutdown,
7628
+ getStatus: () => getStatus()
7629
+ };
7630
+ return instance;
6570
7631
  }
6571
7632
 
6572
- // src/index.ts
6573
- async function AnthropicAuthPlugin({ client }) {
6574
- const config = loadConfig();
6575
- const signatureEmulationEnabled = config.signature_emulation.enabled;
6576
- const promptCompactionMode = config.signature_emulation.prompt_compaction === "off" ? "off" : "minimal";
6577
- const shouldFetchClaudeCodeVersion = signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
6578
- let accountManager = null;
6579
- let lastToastedIndex = -1;
7633
+ // src/plugin-helpers.ts
7634
+ var DEBOUNCE_TOAST_MAP_MAX_SIZE = 50;
7635
+ function createPluginHelpers({
7636
+ client,
7637
+ config,
7638
+ debugLog,
7639
+ getAccountManager,
7640
+ setAccountManager
7641
+ }) {
6580
7642
  const debouncedToastTimestamps = /* @__PURE__ */ new Map();
6581
- const refreshInFlight = /* @__PURE__ */ new Map();
6582
- const idleRefreshLastAttempt = /* @__PURE__ */ new Map();
6583
- const idleRefreshInFlight = /* @__PURE__ */ new Set();
6584
- const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
6585
- const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1e3;
6586
- const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1e3;
6587
- let initialAccountPinned = false;
6588
- const pendingSlashOAuth = /* @__PURE__ */ new Map();
6589
- const fileAccountMap = /* @__PURE__ */ new Map();
6590
7643
  async function sendCommandMessage(sessionID, text) {
6591
7644
  await client.session?.prompt({
6592
7645
  path: { id: sessionID },
@@ -6594,8 +7647,8 @@ async function AnthropicAuthPlugin({ client }) {
6594
7647
  });
6595
7648
  }
6596
7649
  async function reloadAccountManagerFromDisk() {
6597
- if (!accountManager) return;
6598
- accountManager = await AccountManager.load(config, null);
7650
+ if (!getAccountManager()) return;
7651
+ setAccountManager(await AccountManager.load(config, null));
6599
7652
  }
6600
7653
  async function persistOpenCodeAuth(refresh, access, expires) {
6601
7654
  await client.auth?.set({
@@ -6632,29 +7685,36 @@ async function AnthropicAuthPlugin({ client }) {
6632
7685
  const now = Date.now();
6633
7686
  const lastAt = debouncedToastTimestamps.get(options.debounceKey) ?? 0;
6634
7687
  if (now - lastAt < minGapMs) return;
7688
+ if (!debouncedToastTimestamps.has(options.debounceKey) && debouncedToastTimestamps.size >= DEBOUNCE_TOAST_MAP_MAX_SIZE) {
7689
+ const oldestKey = debouncedToastTimestamps.keys().next().value;
7690
+ if (oldestKey !== void 0) debouncedToastTimestamps.delete(oldestKey);
7691
+ }
6635
7692
  debouncedToastTimestamps.set(options.debounceKey, now);
6636
7693
  }
6637
7694
  }
6638
7695
  try {
6639
7696
  await client.tui?.showToast({ body: { message, variant } });
6640
- } catch {
7697
+ } catch (err) {
7698
+ if (!(err instanceof TypeError)) debugLog("toast failed:", err);
6641
7699
  }
6642
7700
  }
6643
- function debugLog(...args) {
6644
- if (!config.debug) return;
6645
- console.error("[opencode-anthropic-auth]", ...args);
6646
- }
6647
- let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
6648
- const signatureSessionId = randomUUID2();
6649
- const signatureUserId = getOrCreateSignatureUserId();
6650
- if (shouldFetchClaudeCodeVersion) {
6651
- fetchLatestClaudeCodeVersion().then((version) => {
6652
- if (!version) return;
6653
- claudeCliVersion = version;
6654
- debugLog("resolved claude-code version from npm", version);
6655
- }).catch(() => {
6656
- });
6657
- }
7701
+ return {
7702
+ toast,
7703
+ sendCommandMessage,
7704
+ runCliCommand,
7705
+ reloadAccountManagerFromDisk,
7706
+ persistOpenCodeAuth
7707
+ };
7708
+ }
7709
+
7710
+ // src/refresh-helpers.ts
7711
+ function createRefreshHelpers({ client, config, getAccountManager, debugLog }) {
7712
+ const refreshInFlight = /* @__PURE__ */ new Map();
7713
+ const idleRefreshLastAttempt = /* @__PURE__ */ new Map();
7714
+ const idleRefreshInFlight = /* @__PURE__ */ new Set();
7715
+ const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
7716
+ const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1e3;
7717
+ const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1e3;
6658
7718
  function parseRefreshFailure(refreshError) {
6659
7719
  const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
6660
7720
  const status = typeof refreshError === "object" && refreshError && "status" in refreshError ? Number(refreshError.status) : NaN;
@@ -6673,9 +7733,14 @@ async function AnthropicAuthPlugin({ client }) {
6673
7733
  if (source === "foreground" && existing.source === "idle") {
6674
7734
  try {
6675
7735
  await existing.promise;
6676
- } catch {
7736
+ } catch (err) {
7737
+ void err;
6677
7738
  }
6678
7739
  if (account.access && account.expires && account.expires > Date.now()) return account.access;
7740
+ const retried = refreshInFlight.get(key);
7741
+ if (retried && retried !== existing) {
7742
+ return retried.promise;
7743
+ }
6679
7744
  } else {
6680
7745
  return existing.promise;
6681
7746
  }
@@ -6689,12 +7754,13 @@ async function AnthropicAuthPlugin({ client }) {
6689
7754
  return await refreshAccountToken(account, client, source, {
6690
7755
  onTokensUpdated: async () => {
6691
7756
  try {
6692
- await accountManager.saveToDisk();
7757
+ await getAccountManager().saveToDisk();
6693
7758
  } catch {
6694
- accountManager.requestSaveToDisk();
7759
+ getAccountManager().requestSaveToDisk();
6695
7760
  throw new Error("save failed, debounced retry scheduled");
6696
7761
  }
6697
- }
7762
+ },
7763
+ debugLog
6698
7764
  });
6699
7765
  } finally {
6700
7766
  if (refreshInFlight.get(key) === entry) refreshInFlight.delete(key);
@@ -6705,7 +7771,7 @@ async function AnthropicAuthPlugin({ client }) {
6705
7771
  return p2;
6706
7772
  }
6707
7773
  async function refreshIdleAccount(account) {
6708
- if (!accountManager) return;
7774
+ if (!getAccountManager()) return;
6709
7775
  if (idleRefreshInFlight.has(account.id)) return;
6710
7776
  idleRefreshInFlight.add(account.id);
6711
7777
  const attemptedRefreshToken = account.refreshToken;
@@ -6748,6 +7814,7 @@ async function AnthropicAuthPlugin({ client }) {
6748
7814
  }
6749
7815
  }
6750
7816
  function maybeRefreshIdleAccounts(activeAccount) {
7817
+ const accountManager = getAccountManager();
6751
7818
  if (!IDLE_REFRESH_ENABLED || !accountManager) return;
6752
7819
  const now = Date.now();
6753
7820
  const excluded = /* @__PURE__ */ new Set([activeAccount.index]);
@@ -6760,6 +7827,80 @@ async function AnthropicAuthPlugin({ client }) {
6760
7827
  idleRefreshLastAttempt.set(target.id, now);
6761
7828
  void refreshIdleAccount(target);
6762
7829
  }
7830
+ return {
7831
+ parseRefreshFailure,
7832
+ refreshAccountTokenSingleFlight,
7833
+ refreshIdleAccount,
7834
+ maybeRefreshIdleAccounts
7835
+ };
7836
+ }
7837
+
7838
+ // src/index.ts
7839
+ async function finalizeResponse(response, onUsage, onAccountError, onStreamError) {
7840
+ if (!isEventStreamResponse(response)) {
7841
+ const body = stripMcpPrefixFromJsonBody(await response.text());
7842
+ return new Response(body, {
7843
+ status: response.status,
7844
+ statusText: response.statusText,
7845
+ headers: new Headers(response.headers)
7846
+ });
7847
+ }
7848
+ return transformResponse(response, onUsage, onAccountError, onStreamError);
7849
+ }
7850
+ async function AnthropicAuthPlugin({
7851
+ client
7852
+ }) {
7853
+ const config = loadConfig();
7854
+ const signatureEmulationEnabled = config.signature_emulation.enabled;
7855
+ const promptCompactionMode = config.signature_emulation.prompt_compaction === "off" ? "off" : "minimal";
7856
+ const shouldFetchClaudeCodeVersion = signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
7857
+ let accountManager = null;
7858
+ let lastToastedIndex = -1;
7859
+ let initialAccountPinned = false;
7860
+ const pendingSlashOAuth = /* @__PURE__ */ new Map();
7861
+ const fileAccountMap = /* @__PURE__ */ new Map();
7862
+ function debugLog(...args) {
7863
+ if (!config.debug) return;
7864
+ console.error("[opencode-anthropic-auth]", ...args);
7865
+ }
7866
+ const { toast, sendCommandMessage, runCliCommand, reloadAccountManagerFromDisk, persistOpenCodeAuth } = createPluginHelpers({
7867
+ client,
7868
+ config,
7869
+ debugLog,
7870
+ getAccountManager: () => accountManager,
7871
+ setAccountManager: (nextAccountManager) => {
7872
+ accountManager = nextAccountManager;
7873
+ }
7874
+ });
7875
+ const { parseRefreshFailure, refreshAccountTokenSingleFlight, maybeRefreshIdleAccounts } = createRefreshHelpers({
7876
+ client,
7877
+ config,
7878
+ getAccountManager: () => accountManager,
7879
+ debugLog
7880
+ });
7881
+ const bunFetchInstance = createBunFetch({
7882
+ debug: config.debug,
7883
+ onProxyStatus: (status) => {
7884
+ debugLog("bun fetch status", status);
7885
+ }
7886
+ });
7887
+ const fetchWithTransport = async (input, init) => {
7888
+ const activeFetch = globalThis.fetch;
7889
+ if (typeof activeFetch === "function" && activeFetch.mock) {
7890
+ return activeFetch(input, init);
7891
+ }
7892
+ return bunFetchInstance.fetch(input, init);
7893
+ };
7894
+ let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
7895
+ const signatureSessionId = randomUUID2();
7896
+ const signatureUserId = getOrCreateSignatureUserId();
7897
+ if (shouldFetchClaudeCodeVersion) {
7898
+ fetchLatestClaudeCodeVersion().then((version) => {
7899
+ if (!version) return;
7900
+ claudeCliVersion = version;
7901
+ debugLog("resolved claude-code version from npm", version);
7902
+ }).catch((err) => debugLog("CC version fetch failed:", err.message));
7903
+ }
6763
7904
  const commandDeps = {
6764
7905
  sendCommandMessage,
6765
7906
  get accountManager() {
@@ -6777,6 +7918,10 @@ async function AnthropicAuthPlugin({ client }) {
6777
7918
  refreshAccountTokenSingleFlight
6778
7919
  };
6779
7920
  return {
7921
+ dispose: async () => {
7922
+ await bunFetchInstance.shutdown();
7923
+ },
7924
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
6780
7925
  "experimental.chat.system.transform": (input, output) => {
6781
7926
  const prefix = CLAUDE_CODE_IDENTITY_STRING;
6782
7927
  if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
@@ -6784,6 +7929,7 @@ async function AnthropicAuthPlugin({ client }) {
6784
7929
  if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1];
6785
7930
  }
6786
7931
  },
7932
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
6787
7933
  config: async (input) => {
6788
7934
  input.command ??= {};
6789
7935
  input.command["anthropic"] = {
@@ -6791,6 +7937,7 @@ async function AnthropicAuthPlugin({ client }) {
6791
7937
  description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)"
6792
7938
  };
6793
7939
  },
7940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
6794
7941
  "command.execute.before": async (input) => {
6795
7942
  if (input.command !== "anthropic") return;
6796
7943
  try {
@@ -6830,8 +7977,6 @@ ${message}`);
6830
7977
  const ccCount = accountManager.getCCAccounts().length;
6831
7978
  if (ccCount > 0) {
6832
7979
  await toast(`Using Claude Code credentials (${ccCount} found)`, "success");
6833
- } else {
6834
- await toast("No Claude Code credentials \u2014 using OAuth", "info");
6835
7980
  }
6836
7981
  }
6837
7982
  const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
@@ -6862,8 +8007,17 @@ ${message}`);
6862
8007
  async fetch(input, init) {
6863
8008
  const currentAuth = await getAuth();
6864
8009
  if (currentAuth.type !== "oauth") return fetch(input, init);
6865
- const requestInit = init ?? {};
8010
+ const requestInit = { ...init ?? {} };
6866
8011
  const { requestInput, requestUrl } = transformRequestUrl(input);
8012
+ const resolvedBody = requestInit.body !== void 0 ? requestInit.body : requestInput instanceof Request && requestInput.body ? await requestInput.clone().text() : void 0;
8013
+ if (resolvedBody !== void 0) {
8014
+ requestInit.body = resolvedBody;
8015
+ }
8016
+ const requestContext = {
8017
+ attempt: 0,
8018
+ cloneBody: typeof resolvedBody === "string" ? cloneBodyForRetry(resolvedBody) : void 0,
8019
+ preparedBody: void 0
8020
+ };
6867
8021
  const requestMethod = String(
6868
8022
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST")
6869
8023
  ).toUpperCase();
@@ -6903,6 +8057,7 @@ ${message}`);
6903
8057
  }
6904
8058
  }
6905
8059
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
8060
+ requestContext.attempt = attempt + 1;
6906
8061
  const account = attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index) ? pinnedAccount : accountManager.getCurrentAccount(transientRefreshSkips);
6907
8062
  if (showUsageToast && account && accountManager) {
6908
8063
  const currentIndex = accountManager.getCurrentIndex();
@@ -6987,19 +8142,26 @@ ${message}`);
6987
8142
  accessToken = account.access;
6988
8143
  }
6989
8144
  maybeRefreshIdleAccounts(account);
6990
- const body = transformRequestBody(
6991
- requestInit.body,
6992
- {
6993
- enabled: signatureEmulationEnabled,
6994
- claudeCliVersion,
6995
- promptCompactionMode
6996
- },
6997
- {
6998
- persistentUserId: signatureUserId,
6999
- sessionId: signatureSessionId,
7000
- accountId: getAccountIdentifier(account)
7001
- }
7002
- );
8145
+ const buildAttemptBody = () => {
8146
+ const transformedBody = transformRequestBody(
8147
+ requestContext.cloneBody === void 0 ? void 0 : cloneBodyForRetry(requestContext.cloneBody),
8148
+ {
8149
+ enabled: signatureEmulationEnabled,
8150
+ claudeCliVersion,
8151
+ promptCompactionMode
8152
+ },
8153
+ {
8154
+ persistentUserId: signatureUserId,
8155
+ sessionId: signatureSessionId,
8156
+ accountId: getAccountIdentifier(account)
8157
+ },
8158
+ config.relocate_third_party_prompts,
8159
+ debugLog
8160
+ );
8161
+ requestContext.preparedBody = typeof transformedBody === "string" ? cloneBodyForRetry(transformedBody) : void 0;
8162
+ return transformedBody;
8163
+ };
8164
+ const body = buildAttemptBody();
7003
8165
  logTransformedSystemPrompt(body);
7004
8166
  const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
7005
8167
  enabled: signatureEmulationEnabled,
@@ -7035,7 +8197,7 @@ ${message}`);
7035
8197
  let response;
7036
8198
  const fetchInput = requestInput;
7037
8199
  try {
7038
- response = await fetchViaBun(fetchInput, {
8200
+ response = await fetchWithTransport(fetchInput, {
7039
8201
  ...requestInit,
7040
8202
  body,
7041
8203
  headers: requestHeaders
@@ -7075,6 +8237,7 @@ ${message}`);
7075
8237
  reason
7076
8238
  });
7077
8239
  accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
8240
+ transientRefreshSkips.add(account.index);
7078
8241
  const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
7079
8242
  const total = accountManager.getAccountCount();
7080
8243
  if (total > 1) {
@@ -7100,23 +8263,24 @@ ${message}`);
7100
8263
  headersForRetry.set("x-stainless-retry-count", String(retryCount));
7101
8264
  retryCount += 1;
7102
8265
  const retryUrl = fetchInput instanceof Request ? fetchInput.url : fetchInput.toString();
7103
- return fetchViaBun(retryUrl, {
8266
+ const retryBody = requestContext.preparedBody === void 0 ? void 0 : cloneBodyForRetry(requestContext.preparedBody);
8267
+ return fetchWithTransport(retryUrl, {
7104
8268
  ...requestInit,
7105
- body,
8269
+ body: retryBody,
7106
8270
  headers: headersForRetry
7107
8271
  });
7108
8272
  },
7109
8273
  { maxRetries: 2 }
7110
8274
  );
7111
8275
  if (!retried.ok) {
7112
- return transformResponse(retried);
8276
+ return finalizeResponse(retried);
7113
8277
  }
7114
8278
  response = retried;
7115
8279
  } else {
7116
8280
  debugLog("non-account-specific response error, returning directly", {
7117
8281
  status: response.status
7118
8282
  });
7119
- return transformResponse(response);
8283
+ return finalizeResponse(response);
7120
8284
  }
7121
8285
  }
7122
8286
  if (account && accountManager && response.ok) {
@@ -7126,15 +8290,28 @@ ${message}`);
7126
8290
  const usageCallback = shouldInspectStream ? (usage) => {
7127
8291
  accountManager.recordUsage(account.index, usage);
7128
8292
  } : null;
7129
- const accountErrorCallback = shouldInspectStream ? (details) => {
7130
- if (details.invalidateToken) {
7131
- account.access = void 0;
7132
- account.expires = void 0;
7133
- markTokenStateUpdated(account);
8293
+ const accountErrorCallback = shouldInspectStream ? (
8294
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- reason carries opaque rate-limit metadata; shape stabilized at callsite
8295
+ (details) => {
8296
+ if (details.invalidateToken) {
8297
+ account.access = void 0;
8298
+ account.expires = void 0;
8299
+ markTokenStateUpdated(account);
8300
+ }
8301
+ accountManager.markRateLimited(account, details.reason, null);
8302
+ }
8303
+ ) : null;
8304
+ const streamErrorCallback = response.ok && isEventStreamResponse(response) ? (error) => {
8305
+ if (!(error instanceof StreamTruncatedError)) {
8306
+ return;
7134
8307
  }
7135
- accountManager.markRateLimited(account, details.reason, null);
8308
+ debugLog("stream truncated during response consumption", {
8309
+ accountIndex: account?.index,
8310
+ message: error.message,
8311
+ context: error.context
8312
+ });
7136
8313
  } : null;
7137
- return transformResponse(response, usageCallback, accountErrorCallback);
8314
+ return finalizeResponse(response, usageCallback, accountErrorCallback, streamErrorCallback);
7138
8315
  }
7139
8316
  if (lastError) throw lastError;
7140
8317
  throw new Error("All accounts exhausted \u2014 no account could serve this request");
@@ -7164,11 +8341,38 @@ ${message}`);
7164
8341
  if (!accountManager) {
7165
8342
  accountManager = await AccountManager.load(config, null);
7166
8343
  }
7167
- const exists = accountManager.getAccountsSnapshot().some((acc) => acc.refreshToken === ccCred.refreshToken);
7168
- if (!exists) {
7169
- const added = accountManager.addAccount(ccCred.refreshToken, ccCred.accessToken, ccCred.expiresAt);
8344
+ const identity = resolveIdentityFromCCCredential(ccCred);
8345
+ const existing = findByIdentity(accountManager.getCCAccounts(), identity);
8346
+ if (existing) {
8347
+ existing.refreshToken = ccCred.refreshToken;
8348
+ existing.identity = identity;
8349
+ existing.source = ccCred.source;
8350
+ existing.label = ccCred.label;
8351
+ existing.enabled = true;
8352
+ if (ccCred.accessToken) {
8353
+ existing.access = ccCred.accessToken;
8354
+ }
8355
+ if (ccCred.expiresAt >= (existing.expires ?? 0)) {
8356
+ existing.expires = ccCred.expiresAt;
8357
+ }
8358
+ existing.tokenUpdatedAt = Math.max(existing.tokenUpdatedAt || 0, ccCred.expiresAt || 0);
8359
+ await accountManager.saveToDisk();
8360
+ } else {
8361
+ const added = accountManager.addAccount(
8362
+ ccCred.refreshToken,
8363
+ ccCred.accessToken,
8364
+ ccCred.expiresAt,
8365
+ void 0,
8366
+ {
8367
+ identity,
8368
+ label: ccCred.label,
8369
+ source: ccCred.source
8370
+ }
8371
+ );
7170
8372
  if (added) {
7171
8373
  added.source = ccCred.source;
8374
+ added.label = ccCred.label;
8375
+ added.identity = identity;
7172
8376
  }
7173
8377
  await accountManager.saveToDisk();
7174
8378
  await toast("Added Claude Code credentials", "success");
@@ -7229,17 +8433,35 @@ ${message}`);
7229
8433
  if (!accountManager) {
7230
8434
  accountManager = await AccountManager.load(config, null);
7231
8435
  }
8436
+ const identity = resolveIdentityFromOAuthExchange(credentials);
7232
8437
  const countBefore = accountManager.getAccountCount();
7233
- accountManager.addAccount(
7234
- credentials.refresh,
7235
- credentials.access,
7236
- credentials.expires,
7237
- credentials.email
7238
- );
8438
+ const existing = identity.kind === "oauth" ? findByIdentity(accountManager.getOAuthAccounts(), identity) : null;
8439
+ if (existing) {
8440
+ existing.refreshToken = credentials.refresh;
8441
+ existing.access = credentials.access;
8442
+ existing.expires = credentials.expires;
8443
+ existing.email = credentials.email ?? existing.email;
8444
+ existing.identity = identity;
8445
+ existing.source = "oauth";
8446
+ existing.enabled = true;
8447
+ existing.tokenUpdatedAt = Date.now();
8448
+ } else {
8449
+ accountManager.addAccount(
8450
+ credentials.refresh,
8451
+ credentials.access,
8452
+ credentials.expires,
8453
+ credentials.email,
8454
+ {
8455
+ identity,
8456
+ source: "oauth"
8457
+ }
8458
+ );
8459
+ }
7239
8460
  await accountManager.saveToDisk();
7240
8461
  const total = accountManager.getAccountCount();
7241
8462
  const name = credentials.email || "account";
7242
- if (countBefore > 0) await toast(`Added ${name} \u2014 ${total} accounts`, "success");
8463
+ if (existing) await toast(`Re-authenticated (${name})`, "success");
8464
+ else if (countBefore > 0) await toast(`Added ${name} \u2014 ${total} accounts`, "success");
7243
8465
  else await toast(`Authenticated (${name})`, "success");
7244
8466
  return credentials;
7245
8467
  }