@vacbo/opencode-anthropic-fix 0.0.45 → 0.1.2

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 +1801 -613
  5. package/package.json +4 -4
  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 -191
  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 +11 -5
  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
@@ -4,16 +4,10 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
8
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
9
- }) : x)(function(x) {
10
- if (typeof require !== "undefined") return require.apply(this, arguments);
11
- throw Error('Dynamic require of "' + x + '" is not supported');
12
- });
13
7
  var __esm = (fn, res) => function __init() {
14
8
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
15
9
  };
16
- var __commonJS = (cb, mod) => function __require2() {
10
+ var __commonJS = (cb, mod) => function __require() {
17
11
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
12
  };
19
13
  var __export = (target, all) => {
@@ -37,6 +31,84 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
31
  mod
38
32
  ));
39
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
+
40
112
  // src/config.ts
41
113
  import { existsSync, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
42
114
  import { homedir as homedir2 } from "node:os";
@@ -410,6 +482,8 @@ function validateAccount(raw, now) {
410
482
  return {
411
483
  id,
412
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,
413
487
  refreshToken: acc.refreshToken,
414
488
  access: typeof acc.access === "string" ? acc.access : void 0,
415
489
  expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : void 0,
@@ -425,6 +499,106 @@ function validateAccount(raw, now) {
425
499
  source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : void 0
426
500
  };
427
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
+ }
428
602
  async function loadAccounts() {
429
603
  const storagePath = getStoragePath();
430
604
  try {
@@ -434,7 +608,9 @@ async function loadAccounts() {
434
608
  return null;
435
609
  }
436
610
  if (data.version !== CURRENT_VERSION) {
437
- return null;
611
+ console.warn(
612
+ `Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`
613
+ );
438
614
  }
439
615
  const now = Date.now();
440
616
  const accounts = data.accounts.map((raw) => validateAccount(raw, now)).filter((acc) => acc !== null);
@@ -461,53 +637,13 @@ async function saveAccounts(storage) {
461
637
  const configDir = dirname2(storagePath);
462
638
  await fs.mkdir(configDir, { recursive: true });
463
639
  ensureGitignore(configDir);
464
- let storageToWrite = storage;
640
+ let storageToWrite = {
641
+ ...storage,
642
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex)
643
+ };
465
644
  try {
466
645
  const disk = await loadAccounts();
467
- if (disk && storage.accounts.length > 0) {
468
- const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
469
- const diskByAddedAt = /* @__PURE__ */ new Map();
470
- const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
471
- for (const d3 of disk.accounts) {
472
- const bucket = diskByAddedAt.get(d3.addedAt) || [];
473
- bucket.push(d3);
474
- diskByAddedAt.set(d3.addedAt, bucket);
475
- }
476
- const findDiskMatch = (acc) => {
477
- const byId = diskById.get(acc.id);
478
- if (byId) return byId;
479
- const byAddedAt = diskByAddedAt.get(acc.addedAt);
480
- if (byAddedAt?.length === 1) return byAddedAt[0];
481
- const byToken = diskByToken.get(acc.refreshToken);
482
- if (byToken) return byToken;
483
- if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
484
- return null;
485
- };
486
- const mergedAccounts = storage.accounts.map((acc) => {
487
- const diskAcc = findDiskMatch(acc);
488
- const memTs = typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at) ? acc.token_updated_at : acc.addedAt;
489
- const diskTs = diskAcc?.token_updated_at || 0;
490
- const useDiskAuth = !!diskAcc && diskTs > memTs;
491
- return {
492
- ...acc,
493
- refreshToken: useDiskAuth ? diskAcc.refreshToken : acc.refreshToken,
494
- access: useDiskAuth ? diskAcc.access : acc.access,
495
- expires: useDiskAuth ? diskAcc.expires : acc.expires,
496
- token_updated_at: useDiskAuth ? diskTs : memTs
497
- };
498
- });
499
- let activeIndex = storage.activeIndex;
500
- if (mergedAccounts.length > 0) {
501
- activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
502
- } else {
503
- activeIndex = 0;
504
- }
505
- storageToWrite = {
506
- ...storage,
507
- accounts: mergedAccounts,
508
- activeIndex
509
- };
510
- }
646
+ storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
511
647
  } catch {
512
648
  }
513
649
  const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
@@ -536,6 +672,7 @@ var CURRENT_VERSION, GITIGNORE_ENTRIES;
536
672
  var init_storage = __esm({
537
673
  "src/storage.ts"() {
538
674
  "use strict";
675
+ init_account_identity();
539
676
  init_config();
540
677
  CURRENT_VERSION = 1;
541
678
  GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-accounts.json.*.tmp"];
@@ -1950,14 +2087,20 @@ async function cmdLogin() {
1950
2087
  const credentials = await runOAuthFlow();
1951
2088
  if (!credentials) return 1;
1952
2089
  const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
1953
- const existingIdx = storage.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
1954
- if (existingIdx >= 0) {
1955
- storage.accounts[existingIdx].access = credentials.access;
1956
- storage.accounts[existingIdx].expires = credentials.expires;
1957
- if (credentials.email) storage.accounts[existingIdx].email = credentials.email;
1958
- 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;
1959
2102
  await saveAccounts(storage);
1960
- const label2 = credentials.email || `Account ${existingIdx + 1}`;
2103
+ const label2 = credentials.email || existing.email || `Account ${existingIdx + 1}`;
1961
2104
  O2.success(`Updated existing account #${existingIdx + 1} (${label2}).`);
1962
2105
  return 0;
1963
2106
  }
@@ -1969,6 +2112,7 @@ async function cmdLogin() {
1969
2112
  storage.accounts.push({
1970
2113
  id: `${now}:${credentials.refresh.slice(0, 12)}`,
1971
2114
  email: credentials.email,
2115
+ identity,
1972
2116
  refreshToken: credentials.refresh,
1973
2117
  access: credentials.access,
1974
2118
  expires: credentials.expires,
@@ -1979,7 +2123,8 @@ async function cmdLogin() {
1979
2123
  rateLimitResetTimes: {},
1980
2124
  consecutiveFailures: 0,
1981
2125
  lastFailureTime: null,
1982
- stats: createDefaultStats(now)
2126
+ stats: createDefaultStats(now),
2127
+ source: "oauth"
1983
2128
  });
1984
2129
  await saveAccounts(storage);
1985
2130
  const label = credentials.email || `Account ${storage.accounts.length}`;
@@ -2177,7 +2322,8 @@ async function cmdList() {
2177
2322
  }
2178
2323
  }
2179
2324
  if (anyRefreshed) {
2180
- await saveAccounts(stored).catch(() => {
2325
+ await saveAccounts(stored).catch((err) => {
2326
+ console.error("[opencode-anthropic-auth] failed to persist refreshed tokens:", err);
2181
2327
  });
2182
2328
  }
2183
2329
  O2.message(c2.bold("Anthropic Multi-Account Status"));
@@ -3173,6 +3319,7 @@ var USE_COLOR, ansi, c2, QUOTA_BUCKETS, USAGE_INDENT, USAGE_LABEL_WIDTH, ioConte
3173
3319
  var init_cli = __esm({
3174
3320
  async "src/cli.ts"() {
3175
3321
  "use strict";
3322
+ init_account_identity();
3176
3323
  init_config();
3177
3324
  init_oauth();
3178
3325
  init_storage();
@@ -3458,6 +3605,9 @@ function readCCCredentials() {
3458
3605
  return credentials;
3459
3606
  }
3460
3607
 
3608
+ // src/accounts.ts
3609
+ init_account_identity();
3610
+
3461
3611
  // src/rotation.ts
3462
3612
  init_config();
3463
3613
  var HealthScoreTracker = class {
@@ -3643,6 +3793,111 @@ function selectAccount(candidates, strategy, currentIndex, healthTracker, tokenT
3643
3793
  init_storage();
3644
3794
  var MAX_ACCOUNTS = 10;
3645
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
+ }
3646
3901
  var AccountManager = class _AccountManager {
3647
3902
  #accounts = [];
3648
3903
  #cursor = 0;
@@ -3652,11 +3907,22 @@ var AccountManager = class _AccountManager {
3652
3907
  #config;
3653
3908
  #saveTimeout = null;
3654
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;
3655
3917
  constructor(config) {
3656
3918
  this.#config = config;
3657
3919
  this.#healthTracker = new HealthScoreTracker(config.health_score);
3658
3920
  this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
3659
3921
  }
3922
+ #rebuildTrackers() {
3923
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
3924
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
3925
+ }
3660
3926
  /**
3661
3927
  * Load accounts from disk, optionally merging with an OpenCode auth fallback.
3662
3928
  */
@@ -3664,27 +3930,38 @@ var AccountManager = class _AccountManager {
3664
3930
  const manager = new _AccountManager(config);
3665
3931
  const stored = await loadAccounts();
3666
3932
  if (stored) {
3667
- manager.#accounts = stored.accounts.map((acc, index) => ({
3668
- id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
3669
- index,
3670
- email: acc.email,
3671
- refreshToken: acc.refreshToken,
3672
- access: acc.access,
3673
- expires: acc.expires,
3674
- tokenUpdatedAt: acc.token_updated_at,
3675
- addedAt: acc.addedAt,
3676
- lastUsed: acc.lastUsed,
3677
- enabled: acc.enabled,
3678
- rateLimitResetTimes: acc.rateLimitResetTimes,
3679
- consecutiveFailures: acc.consecutiveFailures,
3680
- lastFailureTime: acc.lastFailureTime,
3681
- lastSwitchReason: acc.lastSwitchReason,
3682
- stats: acc.stats ?? createDefaultStats(acc.addedAt),
3683
- source: acc.source || "oauth"
3684
- }));
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
+ );
3685
3955
  manager.#currentIndex = manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
3686
3956
  if (authFallback && manager.#accounts.length > 0) {
3687
- 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
+ });
3688
3965
  if (match) {
3689
3966
  const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
3690
3967
  const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
@@ -3701,23 +3978,17 @@ var AccountManager = class _AccountManager {
3701
3978
  } else if (authFallback && authFallback.refresh) {
3702
3979
  const now = Date.now();
3703
3980
  manager.#accounts = [
3704
- {
3981
+ createManagedAccount({
3705
3982
  id: `${now}:${authFallback.refresh.slice(0, 12)}`,
3706
3983
  index: 0,
3707
- email: void 0,
3708
3984
  refreshToken: authFallback.refresh,
3709
3985
  access: authFallback.access,
3710
3986
  expires: authFallback.expires,
3711
3987
  tokenUpdatedAt: now,
3712
3988
  addedAt: now,
3713
- lastUsed: 0,
3714
- enabled: true,
3715
- rateLimitResetTimes: {},
3716
- consecutiveFailures: 0,
3717
- lastFailureTime: null,
3718
3989
  lastSwitchReason: "initial",
3719
- stats: createDefaultStats(now)
3720
- }
3990
+ source: "oauth"
3991
+ })
3721
3992
  ];
3722
3993
  manager.#currentIndex = 0;
3723
3994
  }
@@ -3731,49 +4002,60 @@ var AccountManager = class _AccountManager {
3731
4002
  }
3732
4003
  })();
3733
4004
  for (const ccCredential of ccCredentials) {
3734
- const existingMatch = manager.#accounts.find(
3735
- (account) => account.refreshToken === ccCredential.refreshToken
3736
- );
3737
- if (existingMatch) {
3738
- if (!existingMatch.source || existingMatch.source === "oauth") {
3739
- 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];
3740
4016
  }
3741
- 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) {
3742
4025
  existingMatch.access = ccCredential.accessToken;
4026
+ }
4027
+ if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
3743
4028
  existingMatch.expires = ccCredential.expiresAt;
3744
4029
  }
4030
+ existingMatch.tokenUpdatedAt = Math.max(existingMatch.tokenUpdatedAt || 0, ccCredential.expiresAt || 0);
4031
+ continue;
4032
+ }
4033
+ if (manager.#accounts.length >= MAX_ACCOUNTS) {
3745
4034
  continue;
3746
4035
  }
3747
4036
  const emailCollision = manager.getOAuthAccounts().find((account) => account.email && ccCredential.label.includes(account.email));
3748
4037
  if (emailCollision?.email) {
3749
4038
  }
3750
4039
  const now = Date.now();
3751
- const ccAccount = {
4040
+ const ccAccount = createManagedAccount({
3752
4041
  id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
3753
4042
  index: manager.#accounts.length,
3754
- email: void 0,
3755
4043
  refreshToken: ccCredential.refreshToken,
3756
4044
  access: ccCredential.accessToken,
3757
4045
  expires: ccCredential.expiresAt,
3758
4046
  tokenUpdatedAt: now,
3759
4047
  addedAt: now,
3760
- lastUsed: 0,
3761
- enabled: true,
3762
- rateLimitResetTimes: {},
3763
- consecutiveFailures: 0,
3764
- lastFailureTime: null,
4048
+ identity: ccIdentity,
4049
+ label: ccCredential.label,
3765
4050
  lastSwitchReason: "cc-auto-detected",
3766
- stats: createDefaultStats(now),
3767
4051
  source: ccCredential.source
3768
- };
4052
+ });
3769
4053
  manager.#accounts.push(ccAccount);
3770
4054
  }
3771
4055
  if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
3772
4056
  manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
3773
4057
  }
3774
- manager.#accounts.forEach((account, index) => {
3775
- account.index = index;
3776
- });
4058
+ reindexAccounts(manager.#accounts);
3777
4059
  if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
3778
4060
  manager.#currentIndex = 0;
3779
4061
  } else if (currentAccountId) {
@@ -3913,35 +4195,47 @@ var AccountManager = class _AccountManager {
3913
4195
  * Add a new account to the pool.
3914
4196
  * @returns The new account, or null if at capacity
3915
4197
  */
3916
- addAccount(refreshToken2, accessToken, expires, email) {
3917
- if (this.#accounts.length >= MAX_ACCOUNTS) return null;
3918
- 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
+ });
3919
4210
  if (existing) {
4211
+ existing.refreshToken = refreshToken2;
3920
4212
  existing.access = accessToken;
3921
4213
  existing.expires = expires;
3922
4214
  existing.tokenUpdatedAt = Date.now();
3923
- 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";
3924
4219
  existing.enabled = true;
4220
+ this.requestSaveToDisk();
3925
4221
  return existing;
3926
4222
  }
4223
+ if (this.#accounts.length >= MAX_ACCOUNTS) return null;
3927
4224
  const now = Date.now();
3928
- const account = {
4225
+ const account = createManagedAccount({
3929
4226
  id: `${now}:${refreshToken2.slice(0, 12)}`,
3930
4227
  index: this.#accounts.length,
3931
- email,
3932
4228
  refreshToken: refreshToken2,
3933
4229
  access: accessToken,
3934
4230
  expires,
3935
4231
  tokenUpdatedAt: now,
3936
4232
  addedAt: now,
3937
- lastUsed: 0,
3938
- enabled: true,
3939
- rateLimitResetTimes: {},
3940
- consecutiveFailures: 0,
3941
- lastFailureTime: null,
3942
4233
  lastSwitchReason: "initial",
3943
- stats: createDefaultStats(now)
3944
- };
4234
+ email,
4235
+ identity,
4236
+ label: options?.label,
4237
+ source: options?.source ?? "oauth"
4238
+ });
3945
4239
  this.#accounts.push(account);
3946
4240
  if (this.#accounts.length === 1) {
3947
4241
  this.#currentIndex = 0;
@@ -3955,9 +4249,7 @@ var AccountManager = class _AccountManager {
3955
4249
  removeAccount(index) {
3956
4250
  if (index < 0 || index >= this.#accounts.length) return false;
3957
4251
  this.#accounts.splice(index, 1);
3958
- this.#accounts.forEach((acc, i) => {
3959
- acc.index = i;
3960
- });
4252
+ reindexAccounts(this.#accounts);
3961
4253
  if (this.#accounts.length === 0) {
3962
4254
  this.#currentIndex = -1;
3963
4255
  this.#cursor = 0;
@@ -3969,9 +4261,7 @@ var AccountManager = class _AccountManager {
3969
4261
  this.#cursor = Math.min(this.#cursor, this.#accounts.length);
3970
4262
  }
3971
4263
  }
3972
- for (let i = 0; i < this.#accounts.length; i++) {
3973
- this.#healthTracker.reset(i);
3974
- }
4264
+ this.#rebuildTrackers();
3975
4265
  this.requestSaveToDisk();
3976
4266
  return true;
3977
4267
  }
@@ -4001,7 +4291,10 @@ var AccountManager = class _AccountManager {
4001
4291
  if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
4002
4292
  this.#saveTimeout = setTimeout(() => {
4003
4293
  this.#saveTimeout = null;
4004
- this.saveToDisk().catch(() => {
4294
+ this.saveToDisk().catch((err) => {
4295
+ if (this.#config.debug) {
4296
+ console.error("[opencode-anthropic-auth] saveToDisk failed:", err.message);
4297
+ }
4005
4298
  });
4006
4299
  }, 1e3);
4007
4300
  }
@@ -4014,9 +4307,11 @@ var AccountManager = class _AccountManager {
4014
4307
  let diskAccountsById = null;
4015
4308
  let diskAccountsByAddedAt = null;
4016
4309
  let diskAccountsByRefreshToken = null;
4310
+ let diskAccounts = [];
4017
4311
  try {
4018
4312
  const diskData = await loadAccounts();
4019
4313
  if (diskData) {
4314
+ diskAccounts = diskData.accounts;
4020
4315
  diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
4021
4316
  diskAccountsByAddedAt = /* @__PURE__ */ new Map();
4022
4317
  diskAccountsByRefreshToken = /* @__PURE__ */ new Map();
@@ -4032,6 +4327,8 @@ var AccountManager = class _AccountManager {
4032
4327
  const findDiskAccount = (account) => {
4033
4328
  const byId = diskAccountsById?.get(account.id);
4034
4329
  if (byId) return byId;
4330
+ const byIdentity = findByIdentity(diskAccounts, resolveIdentity(account));
4331
+ if (byIdentity) return byIdentity;
4035
4332
  const byAddedAt = diskAccountsByAddedAt?.get(account.addedAt);
4036
4333
  if (byAddedAt?.length === 1) return byAddedAt[0];
4037
4334
  const byToken = diskAccountsByRefreshToken?.get(account.refreshToken);
@@ -4039,70 +4336,82 @@ var AccountManager = class _AccountManager {
4039
4336
  if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
4040
4337
  return null;
4041
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;
4042
4411
  const storage = {
4043
4412
  version: 1,
4044
- accounts: this.#accounts.map((acc) => {
4045
- const delta = this.#statsDeltas.get(acc.id);
4046
- let mergedStats = acc.stats;
4047
- const diskAcc = findDiskAccount(acc);
4048
- if (delta) {
4049
- const diskStats = diskAcc?.stats;
4050
- if (delta.isReset) {
4051
- mergedStats = {
4052
- requests: delta.requests,
4053
- inputTokens: delta.inputTokens,
4054
- outputTokens: delta.outputTokens,
4055
- cacheReadTokens: delta.cacheReadTokens,
4056
- cacheWriteTokens: delta.cacheWriteTokens,
4057
- lastReset: delta.resetTimestamp ?? acc.stats.lastReset
4058
- };
4059
- } else if (diskStats) {
4060
- mergedStats = {
4061
- requests: diskStats.requests + delta.requests,
4062
- inputTokens: diskStats.inputTokens + delta.inputTokens,
4063
- outputTokens: diskStats.outputTokens + delta.outputTokens,
4064
- cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
4065
- cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
4066
- lastReset: diskStats.lastReset
4067
- };
4068
- }
4069
- }
4070
- const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
4071
- const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
4072
- const freshestAuth = diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt ? {
4073
- refreshToken: diskAcc.refreshToken,
4074
- access: diskAcc.access,
4075
- expires: diskAcc.expires,
4076
- tokenUpdatedAt: diskTokenUpdatedAt
4077
- } : {
4078
- refreshToken: acc.refreshToken,
4079
- access: acc.access,
4080
- expires: acc.expires,
4081
- tokenUpdatedAt: memTokenUpdatedAt
4082
- };
4083
- acc.refreshToken = freshestAuth.refreshToken;
4084
- acc.access = freshestAuth.access;
4085
- acc.expires = freshestAuth.expires;
4086
- acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
4087
- return {
4088
- id: acc.id,
4089
- email: acc.email,
4090
- refreshToken: freshestAuth.refreshToken,
4091
- access: freshestAuth.access,
4092
- expires: freshestAuth.expires,
4093
- token_updated_at: freshestAuth.tokenUpdatedAt,
4094
- addedAt: acc.addedAt,
4095
- lastUsed: acc.lastUsed,
4096
- enabled: acc.enabled,
4097
- rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
4098
- consecutiveFailures: acc.consecutiveFailures,
4099
- lastFailureTime: acc.lastFailureTime,
4100
- lastSwitchReason: acc.lastSwitchReason,
4101
- stats: mergedStats,
4102
- source: acc.source
4103
- };
4104
- }),
4105
- 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
4106
4415
  };
4107
4416
  await saveAccounts(storage);
4108
4417
  this.#statsDeltas.clear();
@@ -4119,57 +4428,92 @@ var AccountManager = class _AccountManager {
4119
4428
  async syncActiveIndexFromDisk() {
4120
4429
  const stored = await loadAccounts();
4121
4430
  if (!stored) return;
4122
- const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
4123
- const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
4124
- const diskSnapshot = stored.accounts.map((acc) => {
4125
- const resolvedId = acc.id || existingByTokenForSnapshot.get(acc.refreshToken)?.id || acc.refreshToken;
4126
- return `${resolvedId}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`;
4127
- }).join("|");
4128
- if (diskSnapshot !== memSnapshot) {
4129
- const existingById = new Map(this.#accounts.map((acc) => [acc.id, acc]));
4130
- const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
4131
- this.#accounts = stored.accounts.map((acc, index) => {
4132
- const existing = acc.id && existingById.get(acc.id) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
4133
- return {
4134
- id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
4135
- index,
4136
- email: acc.email ?? existing?.email,
4137
- refreshToken: acc.refreshToken,
4138
- access: acc.access ?? existing?.access,
4139
- expires: acc.expires ?? existing?.expires,
4140
- tokenUpdatedAt: acc.token_updated_at ?? existing?.tokenUpdatedAt ?? acc.addedAt,
4141
- addedAt: acc.addedAt,
4142
- lastUsed: acc.lastUsed,
4143
- enabled: acc.enabled,
4144
- rateLimitResetTimes: acc.rateLimitResetTimes,
4145
- consecutiveFailures: acc.consecutiveFailures,
4146
- lastFailureTime: acc.lastFailureTime,
4147
- lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
4148
- stats: acc.stats ?? existing?.stats ?? createDefaultStats()
4149
- };
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
4150
4439
  });
4151
- this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
4152
- this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
4153
- const currentIds = new Set(this.#accounts.map((a) => a.id));
4154
- for (const id of this.#statsDeltas.keys()) {
4155
- if (!currentIds.has(id)) this.#statsDeltas.delete(id);
4156
- }
4157
- if (this.#accounts.length === 0) {
4158
- this.#currentIndex = -1;
4159
- this.#cursor = 0;
4160
- 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;
4161
4473
  }
4474
+ if (account.enabled) {
4475
+ account.enabled = false;
4476
+ structuralChange = true;
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;
4162
4497
  }
4163
- const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
4164
- if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
4165
- const diskAccount = stored.accounts[diskIndex];
4166
- if (!diskAccount || !diskAccount.enabled) return;
4167
- const account = this.#accounts[diskIndex];
4168
- if (account && account.enabled) {
4169
- this.#currentIndex = diskIndex;
4170
- this.#cursor = diskIndex;
4171
- 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;
4172
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);
4173
4517
  }
4174
4518
  }
4175
4519
  /**
@@ -4195,6 +4539,13 @@ var AccountManager = class _AccountManager {
4195
4539
  delta.cacheReadTokens += crTok;
4196
4540
  delta.cacheWriteTokens += cwTok;
4197
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
+ }
4198
4549
  this.#statsDeltas.set(account.id, {
4199
4550
  requests: 1,
4200
4551
  inputTokens: inTok,
@@ -4727,6 +5078,14 @@ async function completeSlashOAuth(sessionID, code, deps) {
4727
5078
 
4728
5079
  // src/commands/router.ts
4729
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
+ }
4730
5089
  function stripAnsi(value) {
4731
5090
  return value.replace(/\x1b\[[0-9;]*m/g, "");
4732
5091
  }
@@ -5075,7 +5434,7 @@ HTTP ${res.status}: ${errBody}`
5075
5434
  }
5076
5435
  const data = await res.json();
5077
5436
  const files = data.data || [];
5078
- for (const f of files) fileAccountMap.set(f.id, account2.index);
5437
+ for (const f of files) capFileAccountMap(fileAccountMap, f.id, account2.index);
5079
5438
  if (files.length === 0) {
5080
5439
  await sendCommandMessage(input.sessionID, `\u25A3 Anthropic Files [${label2}]
5081
5440
 
@@ -5105,7 +5464,7 @@ No files uploaded.`);
5105
5464
  }
5106
5465
  const data = await res.json();
5107
5466
  const files = data.data || [];
5108
- for (const f of files) fileAccountMap.set(f.id, acct.index);
5467
+ for (const f of files) capFileAccountMap(fileAccountMap, f.id, acct.index);
5109
5468
  totalFiles += files.length;
5110
5469
  if (files.length === 0) {
5111
5470
  allLines.push(`[${label2}] No files`);
@@ -5185,7 +5544,7 @@ Upload failed (HTTP ${res.status}): ${errBody}`
5185
5544
  }
5186
5545
  const file = await res.json();
5187
5546
  const sizeKB = ((file.size || 0) / 1024).toFixed(1);
5188
- fileAccountMap.set(file.id, account.index);
5547
+ capFileAccountMap(fileAccountMap, file.id, account.index);
5189
5548
  await sendCommandMessage(
5190
5549
  input.sessionID,
5191
5550
  `\u25A3 Anthropic Files [${label}]
@@ -5217,7 +5576,7 @@ HTTP ${res.status}: ${errBody}`
5217
5576
  return;
5218
5577
  }
5219
5578
  const file = await res.json();
5220
- fileAccountMap.set(file.id, account.index);
5579
+ capFileAccountMap(fileAccountMap, file.id, account.index);
5221
5580
  const lines = [
5222
5581
  `\u25A3 Anthropic Files [${label}]`,
5223
5582
  "",
@@ -5585,7 +5944,7 @@ function buildAnthropicBillingHeader(claudeCliVersion, messages) {
5585
5944
  }
5586
5945
  }
5587
5946
  }
5588
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
5947
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
5589
5948
  let cchValue;
5590
5949
  if (Array.isArray(messages) && messages.length > 0) {
5591
5950
  const bodyHint = JSON.stringify(messages).slice(0, 512);
@@ -5655,8 +6014,9 @@ function normalizeSystemTextBlocks(system) {
5655
6014
  }
5656
6015
 
5657
6016
  // src/system-prompt/sanitize.ts
5658
- function sanitizeSystemText(text) {
5659
- 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");
5660
6020
  }
5661
6021
  function compactSystemText(text, mode) {
5662
6022
  const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}
@@ -5789,11 +6149,69 @@ function buildRequestMetadata(input) {
5789
6149
  }
5790
6150
 
5791
6151
  // src/request/body.ts
5792
- function transformRequestBody(body, signature, runtime) {
5793
- if (!body || typeof body !== "string") return body;
5794
- 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);
5795
6209
  try {
5796
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
+ );
5797
6215
  if (Object.hasOwn(parsed, "betas")) {
5798
6216
  delete parsed.betas;
5799
6217
  }
@@ -5806,8 +6224,51 @@ function transformRequestBody(body, signature, runtime) {
5806
6224
  } else if (!Object.hasOwn(parsed, "temperature")) {
5807
6225
  parsed.temperature = 1;
5808
6226
  }
5809
- parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signature, parsed.messages);
5810
- if (signature.enabled) {
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
+ }
6271
+ if (signature.enabled) {
5811
6272
  const currentMetadata = parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata) ? parsed.metadata : {};
5812
6273
  parsed.metadata = {
5813
6274
  ...currentMetadata,
@@ -5821,7 +6282,7 @@ function transformRequestBody(body, signature, runtime) {
5821
6282
  if (parsed.tools && Array.isArray(parsed.tools)) {
5822
6283
  parsed.tools = parsed.tools.map((tool) => ({
5823
6284
  ...tool,
5824
- name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name
6285
+ name: prefixToolDefinitionName(tool.name)
5825
6286
  }));
5826
6287
  }
5827
6288
  if (parsed.messages && Array.isArray(parsed.messages)) {
@@ -5831,7 +6292,7 @@ function transformRequestBody(body, signature, runtime) {
5831
6292
  if (block.type === "tool_use" && block.name) {
5832
6293
  return {
5833
6294
  ...block,
5834
- name: `${TOOL_PREFIX}${block.name}`
6295
+ name: prefixToolUseName(block.name, literalToolNames, debugLog)
5835
6296
  };
5836
6297
  }
5837
6298
  return block;
@@ -5841,8 +6302,12 @@ function transformRequestBody(body, signature, runtime) {
5841
6302
  });
5842
6303
  }
5843
6304
  return JSON.stringify(parsed);
5844
- } catch {
5845
- return body;
6305
+ } catch (err) {
6306
+ if (err instanceof SyntaxError) {
6307
+ debugLog?.("body parse failed:", err.message);
6308
+ return body;
6309
+ }
6310
+ throw err;
5846
6311
  }
5847
6312
  }
5848
6313
 
@@ -5904,50 +6369,71 @@ function transformRequestUrl(input) {
5904
6369
  }
5905
6370
 
5906
6371
  // src/response/mcp.ts
5907
- function stripMcpPrefixFromSSE(text) {
5908
- return text.replace(/^data:\s*(.+)$/gm, (_match, jsonStr) => {
5909
- try {
5910
- const parsed = JSON.parse(jsonStr);
5911
- if (stripMcpPrefixFromParsedEvent(parsed)) {
5912
- return `data: ${JSON.stringify(parsed)}`;
5913
- }
5914
- } catch {
5915
- }
5916
- return _match;
5917
- });
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;
5918
6400
  }
5919
6401
  function stripMcpPrefixFromParsedEvent(parsed) {
5920
6402
  if (!parsed || typeof parsed !== "object") return false;
5921
6403
  const p2 = parsed;
5922
6404
  let modified = false;
5923
- 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_")) {
5924
- p2.content_block.name = p2.content_block.name.slice(4);
5925
- modified = true;
6405
+ modified = stripMcpPrefixFromToolUseBlock(p2.content_block) || modified;
6406
+ if (p2.message && typeof p2.message === "object") {
6407
+ modified = stripMcpPrefixFromContentBlocks(p2.message.content) || modified;
5926
6408
  }
5927
- if (p2.message && Array.isArray(p2.message.content)) {
5928
- for (const block of p2.message.content) {
5929
- if (!block || typeof block !== "object") continue;
5930
- const b = block;
5931
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
5932
- b.name = b.name.slice(4);
5933
- modified = true;
5934
- }
5935
- }
5936
- }
5937
- if (Array.isArray(p2.content)) {
5938
- for (const block of p2.content) {
5939
- if (!block || typeof block !== "object") continue;
5940
- const b = block;
5941
- if (b.type === "tool_use" && typeof b.name === "string" && b.name.startsWith("mcp_")) {
5942
- b.name = b.name.slice(4);
5943
- modified = true;
5944
- }
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;
5945
6420
  }
6421
+ return modified ? JSON.stringify(parsed) : body;
6422
+ } catch {
6423
+ return body;
5946
6424
  }
5947
- return modified;
5948
6425
  }
5949
6426
 
5950
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
+ };
5951
6437
  function extractUsageFromSSEEvent(parsed, stats) {
5952
6438
  const p2 = parsed;
5953
6439
  if (!p2) return;
@@ -5987,6 +6473,187 @@ function getSSEDataPayload(eventBlock) {
5987
6473
  if (!payload || payload === "[DONE]") return null;
5988
6474
  return payload;
5989
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
+ }
5990
6657
  function getMidStreamAccountError(parsed) {
5991
6658
  const p2 = parsed;
5992
6659
  if (!p2 || p2.type !== "error" || !p2.error) {
@@ -6008,12 +6675,11 @@ function getMidStreamAccountError(parsed) {
6008
6675
  invalidateToken: reason === "AUTH_FAILED"
6009
6676
  };
6010
6677
  }
6011
- function transformResponse(response, onUsage, onAccountError) {
6012
- if (!response.body) return response;
6678
+ function transformResponse(response, onUsage, onAccountError, onStreamError) {
6679
+ if (!response.body || !isEventStreamResponse(response)) return response;
6013
6680
  const reader = response.body.getReader();
6014
- const decoder = new TextDecoder();
6681
+ const decoder = new TextDecoder("utf-8", { fatal: true });
6015
6682
  const encoder = new TextEncoder();
6016
- const EMPTY_CHUNK = new Uint8Array();
6017
6683
  const stats = {
6018
6684
  inputTokens: 0,
6019
6685
  outputTokens: 0,
@@ -6021,82 +6687,145 @@ function transformResponse(response, onUsage, onAccountError) {
6021
6687
  cacheWriteTokens: 0
6022
6688
  };
6023
6689
  let sseBuffer = "";
6024
- let sseRewriteBuffer = "";
6025
6690
  let accountErrorHandled = false;
6026
- 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;
6027
6747
  while (true) {
6028
6748
  const boundary = sseBuffer.indexOf("\n\n");
6029
6749
  if (boundary === -1) {
6030
- if (!flush) return;
6031
- if (!sseBuffer.trim()) {
6032
- sseBuffer = "";
6033
- return;
6750
+ if (sseBuffer.length > MAX_UNTERMINATED_SSE_BUFFER) {
6751
+ throw new Error("unterminated SSE event buffer exceeded limit");
6034
6752
  }
6753
+ return emitted;
6035
6754
  }
6036
- const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
6037
- sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
6038
- const payload = getSSEDataPayload(eventBlock);
6039
- if (!payload) {
6040
- if (boundary === -1) return;
6755
+ const eventBlock = sseBuffer.slice(0, boundary);
6756
+ sseBuffer = sseBuffer.slice(boundary + 2);
6757
+ if (!eventBlock.trim()) {
6041
6758
  continue;
6042
6759
  }
6760
+ enqueueNormalizedEvent(controller, eventBlock);
6761
+ emitted = true;
6762
+ }
6763
+ }
6764
+ async function failStream(controller, error) {
6765
+ const streamError = toStreamError(error);
6766
+ if (onStreamError) {
6043
6767
  try {
6044
- const parsed = JSON.parse(payload);
6045
- if (onUsage) {
6046
- extractUsageFromSSEEvent(parsed, stats);
6047
- }
6048
- if (onAccountError && !accountErrorHandled) {
6049
- const details = getMidStreamAccountError(parsed);
6050
- if (details) {
6051
- accountErrorHandled = true;
6052
- onAccountError(details);
6053
- }
6054
- }
6768
+ onStreamError(streamError);
6055
6769
  } catch {
6056
6770
  }
6057
- if (boundary === -1) return;
6058
6771
  }
6059
- }
6060
- function rewriteSSEChunk(chunk, flush = false) {
6061
- sseRewriteBuffer += chunk;
6062
- if (!flush) {
6063
- const boundary = sseRewriteBuffer.lastIndexOf("\n");
6064
- if (boundary === -1) return "";
6065
- const complete = sseRewriteBuffer.slice(0, boundary + 1);
6066
- sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
6067
- return stripMcpPrefixFromSSE(complete);
6772
+ try {
6773
+ await reader.cancel(streamError);
6774
+ } catch {
6068
6775
  }
6069
- if (!sseRewriteBuffer) return "";
6070
- const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
6071
- sseRewriteBuffer = "";
6072
- return finalText;
6776
+ controller.error(streamError);
6073
6777
  }
6074
6778
  const stream = new ReadableStream({
6075
6779
  async pull(controller) {
6076
- const { done, value } = await reader.read();
6077
- if (done) {
6078
- processSSEBuffer(true);
6079
- const rewrittenTail = rewriteSSEChunk("", true);
6080
- if (rewrittenTail) {
6081
- controller.enqueue(encoder.encode(rewrittenTail));
6082
- }
6083
- if (onUsage && (stats.inputTokens > 0 || stats.outputTokens > 0 || stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0)) {
6084
- 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
+ }
6085
6822
  }
6086
- controller.close();
6087
- return;
6088
- }
6089
- const text = decoder.decode(value, { stream: true });
6090
- if (onUsage || onAccountError) {
6091
- sseBuffer += text.replace(/\r\n/g, "\n");
6092
- processSSEBuffer(false);
6093
- }
6094
- const rewrittenText = rewriteSSEChunk(text, false);
6095
- if (rewrittenText) {
6096
- controller.enqueue(encoder.encode(rewrittenText));
6097
- } else {
6098
- controller.enqueue(EMPTY_CHUNK);
6823
+ } catch (error) {
6824
+ await failStream(controller, error);
6099
6825
  }
6826
+ },
6827
+ cancel(reason) {
6828
+ return reader.cancel(reason);
6100
6829
  }
6101
6830
  });
6102
6831
  return new Response(stream, {
@@ -6111,6 +6840,7 @@ function isEventStreamResponse(response) {
6111
6840
  }
6112
6841
 
6113
6842
  // src/index.ts
6843
+ init_account_identity();
6114
6844
  init_storage();
6115
6845
 
6116
6846
  // src/token-refresh.ts
@@ -6122,9 +6852,9 @@ init_storage();
6122
6852
  import { createHash as createHash3, randomBytes as randomBytes4 } from "node:crypto";
6123
6853
  import { promises as fs2 } from "node:fs";
6124
6854
  import { dirname as dirname3, join as join5 } from "node:path";
6125
- var DEFAULT_LOCK_TIMEOUT_MS = 2e3;
6855
+ var DEFAULT_LOCK_TIMEOUT_MS = 15e3;
6126
6856
  var DEFAULT_LOCK_BACKOFF_MS = 50;
6127
- var DEFAULT_STALE_LOCK_MS = 2e4;
6857
+ var DEFAULT_STALE_LOCK_MS = 9e4;
6128
6858
  function delay(ms) {
6129
6859
  return new Promise((resolve2) => setTimeout(resolve2, ms));
6130
6860
  }
@@ -6229,16 +6959,19 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6229
6959
  if (!diskAuth) return false;
6230
6960
  const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
6231
6961
  const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
6232
- const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
6962
+ const diskIsNewer = diskTokenUpdatedAt > memTokenUpdatedAt;
6963
+ const diskHasDifferentRefreshToken = diskAuth.refreshToken !== account.refreshToken;
6233
6964
  const memAuthExpired = !account.expires || account.expires <= Date.now();
6234
6965
  const allowExpiredFallback = options.allowExpiredFallback === true;
6235
- if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
6966
+ if (!diskIsNewer && !(allowExpiredFallback && diskHasDifferentRefreshToken && memAuthExpired)) {
6236
6967
  return false;
6237
6968
  }
6238
6969
  account.refreshToken = diskAuth.refreshToken;
6239
6970
  account.access = diskAuth.access;
6240
6971
  account.expires = diskAuth.expires;
6241
- account.tokenUpdatedAt = Math.max(memTokenUpdatedAt, diskTokenUpdatedAt);
6972
+ if (diskIsNewer) {
6973
+ account.tokenUpdatedAt = diskTokenUpdatedAt;
6974
+ }
6242
6975
  return true;
6243
6976
  }
6244
6977
  function claudeBinaryPath() {
@@ -6304,11 +7037,9 @@ async function refreshCCAccount(account) {
6304
7037
  markTokenStateUpdated(account);
6305
7038
  return refreshedCredential.accessToken;
6306
7039
  }
6307
- async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
7040
+ async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated, debugLog } = {}) {
6308
7041
  const lockResult = await acquireRefreshLock(account.id, {
6309
- timeoutMs: 2e3,
6310
- backoffMs: 60,
6311
- staleMs: 2e4
7042
+ backoffMs: 60
6312
7043
  });
6313
7044
  const lock = lockResult && typeof lockResult === "object" ? lockResult : {
6314
7045
  acquired: true,
@@ -6336,7 +7067,9 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
6336
7067
  const accessToken = await refreshCCAccount(account);
6337
7068
  if (accessToken) {
6338
7069
  if (onTokensUpdated) {
6339
- await onTokensUpdated().catch(() => void 0);
7070
+ await onTokensUpdated().catch((err) => {
7071
+ debugLog?.("onTokensUpdated failed:", err.message);
7072
+ });
6340
7073
  }
6341
7074
  await client.auth?.set({
6342
7075
  path: { id: "anthropic" },
@@ -6346,7 +7079,9 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
6346
7079
  access: account.access,
6347
7080
  expires: account.expires
6348
7081
  }
6349
- }).catch(() => void 0);
7082
+ }).catch((err) => {
7083
+ debugLog?.("auth.set failed:", err.message);
7084
+ });
6350
7085
  return accessToken;
6351
7086
  }
6352
7087
  throw new Error("CC credential refresh failed");
@@ -6392,28 +7127,181 @@ function formatSwitchReason(status, reason) {
6392
7127
 
6393
7128
  // src/bun-fetch.ts
6394
7129
  import { execFileSync, spawn } from "node:child_process";
6395
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5, unlinkSync } from "node:fs";
7130
+ import { existsSync as existsSync5 } from "node:fs";
6396
7131
  import { dirname as dirname4, join as join6 } from "node:path";
6397
- import { tmpdir } from "node:os";
7132
+ import * as readline from "node:readline";
6398
7133
  import { fileURLToPath } from "node:url";
6399
- var proxyPort = null;
6400
- var proxyProcess = null;
6401
- var starting = null;
6402
- var healthCheckFails = 0;
6403
- var FIXED_PORT = 48372;
6404
- var PID_FILE = join6(tmpdir(), "opencode-bun-proxy.pid");
6405
- var MAX_HEALTH_FAILS = 2;
6406
- var exitHandlerRegistered = false;
6407
- function registerExitHandler() {
6408
- if (exitHandlerRegistered) return;
6409
- exitHandlerRegistered = true;
6410
- const cleanup = () => {
6411
- 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))
6412
7144
  };
6413
- process.on("exit", cleanup);
6414
- process.on("SIGINT", cleanup);
6415
- process.on("SIGTERM", cleanup);
6416
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;
6417
7305
  function findProxyScript() {
6418
7306
  const dir = typeof __dirname !== "undefined" ? __dirname : dirname4(fileURLToPath(import.meta.url));
6419
7307
  for (const candidate of [
@@ -6421,206 +7309,337 @@ function findProxyScript() {
6421
7309
  join6(dir, "..", "dist", "bun-proxy.mjs"),
6422
7310
  join6(dir, "bun-proxy.ts")
6423
7311
  ]) {
6424
- if (existsSync5(candidate)) return candidate;
7312
+ if (existsSync5(candidate)) {
7313
+ return candidate;
7314
+ }
6425
7315
  }
6426
7316
  return null;
6427
7317
  }
6428
- var _hasBun = null;
6429
- function hasBun() {
6430
- if (_hasBun !== null) return _hasBun;
7318
+ function detectBunAvailability() {
6431
7319
  try {
6432
- execFileSync("which", ["bun"], { stdio: "ignore" });
6433
- _hasBun = true;
7320
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
7321
+ return true;
6434
7322
  } catch {
6435
- _hasBun = false;
7323
+ return false;
6436
7324
  }
6437
- return _hasBun;
6438
7325
  }
6439
- function killStaleProxy() {
6440
- try {
6441
- const raw = readFileSync6(PID_FILE, "utf-8").trim();
6442
- const pid = parseInt(raw, 10);
6443
- if (pid > 0) {
6444
- try {
6445
- process.kill(pid, "SIGTERM");
6446
- } catch {
6447
- }
6448
- }
6449
- unlinkSync(PID_FILE);
6450
- } 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;
6451
7335
  }
7336
+ return input instanceof Request ? input.signal : void 0;
6452
7337
  }
6453
- async function isProxyHealthy(port) {
6454
- try {
6455
- const resp = await fetch(`http://127.0.0.1:${port}/__health`, {
6456
- signal: AbortSignal.timeout(2e3)
6457
- });
6458
- return resp.ok;
6459
- } catch {
6460
- 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;
6461
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));
6462
7363
  }
6463
- function spawnProxy() {
6464
- 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
+ }
6465
7437
  const script = findProxyScript();
6466
- if (!script || !hasBun()) {
6467
- resolve2(null);
6468
- 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;
6469
7444
  }
6470
- killStaleProxy();
6471
- try {
6472
- const child = spawn("bun", ["run", script, String(FIXED_PORT)], {
6473
- stdio: ["ignore", "pipe", "pipe"],
6474
- detached: false
6475
- });
6476
- proxyProcess = child;
6477
- registerExitHandler();
6478
- let done = false;
6479
- const finish = (port) => {
6480
- if (done) return;
6481
- done = true;
6482
- if (port && child.pid) {
6483
- try {
6484
- writeFileSync5(PID_FILE, String(child.pid));
6485
- } 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"
6486
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;
6487
7487
  }
6488
- resolve2(port);
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;
7499
+ }
7500
+ clearActiveProxy(child);
7501
+ breaker.recordFailure();
7502
+ reportStatus(reason);
7503
+ flushPendingFetches("native", reason);
7504
+ resolve2(null);
6489
7505
  };
6490
- child.stdout?.on("data", (chunk) => {
6491
- const m = chunk.toString().match(/BUN_PROXY_PORT=(\d+)/);
6492
- if (m) {
6493
- proxyPort = parseInt(m[1], 10);
6494
- healthCheckFails = 0;
6495
- finish(proxyPort);
7506
+ stdoutLines.on("line", (line) => {
7507
+ const match = line.match(/^BUN_PROXY_PORT=(\d+)$/);
7508
+ if (!match) {
7509
+ return;
6496
7510
  }
7511
+ finalize(
7512
+ {
7513
+ child,
7514
+ port: Number.parseInt(match[1], 10)
7515
+ },
7516
+ "proxy-ready"
7517
+ );
6497
7518
  });
6498
- child.on("error", () => {
6499
- finish(null);
6500
- proxyPort = null;
6501
- proxyProcess = null;
6502
- starting = null;
6503
- });
6504
- child.on("exit", () => {
6505
- proxyPort = null;
6506
- proxyProcess = null;
6507
- starting = null;
6508
- finish(null);
7519
+ child.once("error", () => {
7520
+ finalize(null, "child-error");
6509
7521
  });
6510
- setTimeout(() => finish(null), 5e3);
6511
- } catch {
6512
- resolve2(null);
6513
- }
6514
- });
6515
- }
6516
- async function ensureBunProxy() {
6517
- if (process.env.VITEST || process.env.NODE_ENV === "test") return null;
6518
- if (proxyPort && proxyProcess && !proxyProcess.killed) {
6519
- return proxyPort;
6520
- }
6521
- if (!proxyPort && await isProxyHealthy(FIXED_PORT)) {
6522
- proxyPort = FIXED_PORT;
6523
- console.error("[bun-fetch] Reusing existing Bun proxy on port", FIXED_PORT);
6524
- return proxyPort;
6525
- }
6526
- if (proxyPort && (!proxyProcess || proxyProcess.killed)) {
6527
- proxyPort = null;
6528
- proxyProcess = null;
6529
- starting = null;
6530
- }
6531
- if (starting) return starting;
6532
- starting = spawnProxy();
6533
- const port = await starting;
6534
- starting = null;
6535
- if (port) console.error("[bun-fetch] Bun proxy started on port", port);
6536
- else console.error("[bun-fetch] Failed to start Bun proxy, falling back to Node.js fetch");
6537
- return port;
6538
- }
6539
- function stopBunProxy() {
6540
- if (proxyProcess) {
6541
- try {
6542
- proxyProcess.kill();
6543
- } catch {
6544
- }
6545
- proxyProcess = null;
6546
- }
6547
- proxyPort = null;
6548
- starting = null;
6549
- killStaleProxy();
6550
- }
6551
- async function fetchViaBun(input, init) {
6552
- const port = await ensureBunProxy();
6553
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
6554
- if (!port) {
6555
- console.error("[bun-fetch] WARNING: No proxy available, using Node.js fetch (will route to extra usage!)");
6556
- return fetch(input, init);
6557
- }
6558
- console.error(`[bun-fetch] Routing through Bun proxy at :${port} \u2192 ${url}`);
6559
- if (init.body && url.includes("/v1/messages") && !url.includes("count_tokens")) {
6560
- try {
6561
- const { writeFileSync: writeFileSync6 } = __require("node:fs");
6562
- writeFileSync6("/tmp/opencode-last-request.json", typeof init.body === "string" ? init.body : JSON.stringify(init.body));
6563
- const hdrs = {};
6564
- init.headers.forEach((v, k) => {
6565
- hdrs[k] = k === "authorization" ? "Bearer ***" : v;
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
+ }
6566
7534
  });
6567
- writeFileSync6("/tmp/opencode-last-headers.json", JSON.stringify(hdrs, null, 2));
6568
- console.error("[bun-fetch] Dumped request to /tmp/opencode-last-request.json");
6569
- } catch {
6570
- }
6571
- }
6572
- const headers = new Headers(init.headers);
6573
- headers.set("x-proxy-url", url);
6574
- try {
6575
- const resp = await fetch(`http://127.0.0.1:${port}/`, {
6576
- method: init.method || "POST",
6577
- headers,
6578
- body: init.body
7535
+ }).finally(() => {
7536
+ state.startPromise = null;
6579
7537
  });
6580
- if (resp.status === 502) {
6581
- const errText = await resp.text();
6582
- throw new Error(`Bun proxy upstream error: ${errText}`);
6583
- }
6584
- healthCheckFails = 0;
6585
- return resp;
6586
- } catch (err) {
6587
- healthCheckFails++;
6588
- if (healthCheckFails >= MAX_HEALTH_FAILS) {
6589
- stopBunProxy();
6590
- const newPort = await ensureBunProxy();
6591
- if (newPort) {
6592
- healthCheckFails = 0;
6593
- const retryHeaders = new Headers(init.headers);
6594
- retryHeaders.set("x-proxy-url", url);
6595
- return fetch(`http://127.0.0.1:${newPort}/`, {
6596
- method: init.method || "POST",
6597
- headers: retryHeaders,
6598
- body: init.body
6599
- });
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
+ }
6600
7555
  }
6601
7556
  }
6602
- throw err;
6603
- }
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;
6604
7631
  }
6605
7632
 
6606
- // src/index.ts
6607
- async function AnthropicAuthPlugin({ client }) {
6608
- const config = loadConfig();
6609
- const signatureEmulationEnabled = config.signature_emulation.enabled;
6610
- const promptCompactionMode = config.signature_emulation.prompt_compaction === "off" ? "off" : "minimal";
6611
- const shouldFetchClaudeCodeVersion = signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
6612
- let accountManager = null;
6613
- 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
+ }) {
6614
7642
  const debouncedToastTimestamps = /* @__PURE__ */ new Map();
6615
- const refreshInFlight = /* @__PURE__ */ new Map();
6616
- const idleRefreshLastAttempt = /* @__PURE__ */ new Map();
6617
- const idleRefreshInFlight = /* @__PURE__ */ new Set();
6618
- const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
6619
- const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1e3;
6620
- const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1e3;
6621
- let initialAccountPinned = false;
6622
- const pendingSlashOAuth = /* @__PURE__ */ new Map();
6623
- const fileAccountMap = /* @__PURE__ */ new Map();
6624
7643
  async function sendCommandMessage(sessionID, text) {
6625
7644
  await client.session?.prompt({
6626
7645
  path: { id: sessionID },
@@ -6628,8 +7647,8 @@ async function AnthropicAuthPlugin({ client }) {
6628
7647
  });
6629
7648
  }
6630
7649
  async function reloadAccountManagerFromDisk() {
6631
- if (!accountManager) return;
6632
- accountManager = await AccountManager.load(config, null);
7650
+ if (!getAccountManager()) return;
7651
+ setAccountManager(await AccountManager.load(config, null));
6633
7652
  }
6634
7653
  async function persistOpenCodeAuth(refresh, access, expires) {
6635
7654
  await client.auth?.set({
@@ -6666,29 +7685,36 @@ async function AnthropicAuthPlugin({ client }) {
6666
7685
  const now = Date.now();
6667
7686
  const lastAt = debouncedToastTimestamps.get(options.debounceKey) ?? 0;
6668
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
+ }
6669
7692
  debouncedToastTimestamps.set(options.debounceKey, now);
6670
7693
  }
6671
7694
  }
6672
7695
  try {
6673
7696
  await client.tui?.showToast({ body: { message, variant } });
6674
- } catch {
7697
+ } catch (err) {
7698
+ if (!(err instanceof TypeError)) debugLog("toast failed:", err);
6675
7699
  }
6676
7700
  }
6677
- function debugLog(...args) {
6678
- if (!config.debug) return;
6679
- console.error("[opencode-anthropic-auth]", ...args);
6680
- }
6681
- let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
6682
- const signatureSessionId = randomUUID2();
6683
- const signatureUserId = getOrCreateSignatureUserId();
6684
- if (shouldFetchClaudeCodeVersion) {
6685
- fetchLatestClaudeCodeVersion().then((version) => {
6686
- if (!version) return;
6687
- claudeCliVersion = version;
6688
- debugLog("resolved claude-code version from npm", version);
6689
- }).catch(() => {
6690
- });
6691
- }
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;
6692
7718
  function parseRefreshFailure(refreshError) {
6693
7719
  const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
6694
7720
  const status = typeof refreshError === "object" && refreshError && "status" in refreshError ? Number(refreshError.status) : NaN;
@@ -6707,9 +7733,14 @@ async function AnthropicAuthPlugin({ client }) {
6707
7733
  if (source === "foreground" && existing.source === "idle") {
6708
7734
  try {
6709
7735
  await existing.promise;
6710
- } catch {
7736
+ } catch (err) {
7737
+ void err;
6711
7738
  }
6712
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
+ }
6713
7744
  } else {
6714
7745
  return existing.promise;
6715
7746
  }
@@ -6723,12 +7754,13 @@ async function AnthropicAuthPlugin({ client }) {
6723
7754
  return await refreshAccountToken(account, client, source, {
6724
7755
  onTokensUpdated: async () => {
6725
7756
  try {
6726
- await accountManager.saveToDisk();
7757
+ await getAccountManager().saveToDisk();
6727
7758
  } catch {
6728
- accountManager.requestSaveToDisk();
7759
+ getAccountManager().requestSaveToDisk();
6729
7760
  throw new Error("save failed, debounced retry scheduled");
6730
7761
  }
6731
- }
7762
+ },
7763
+ debugLog
6732
7764
  });
6733
7765
  } finally {
6734
7766
  if (refreshInFlight.get(key) === entry) refreshInFlight.delete(key);
@@ -6739,7 +7771,7 @@ async function AnthropicAuthPlugin({ client }) {
6739
7771
  return p2;
6740
7772
  }
6741
7773
  async function refreshIdleAccount(account) {
6742
- if (!accountManager) return;
7774
+ if (!getAccountManager()) return;
6743
7775
  if (idleRefreshInFlight.has(account.id)) return;
6744
7776
  idleRefreshInFlight.add(account.id);
6745
7777
  const attemptedRefreshToken = account.refreshToken;
@@ -6782,6 +7814,7 @@ async function AnthropicAuthPlugin({ client }) {
6782
7814
  }
6783
7815
  }
6784
7816
  function maybeRefreshIdleAccounts(activeAccount) {
7817
+ const accountManager = getAccountManager();
6785
7818
  if (!IDLE_REFRESH_ENABLED || !accountManager) return;
6786
7819
  const now = Date.now();
6787
7820
  const excluded = /* @__PURE__ */ new Set([activeAccount.index]);
@@ -6794,6 +7827,80 @@ async function AnthropicAuthPlugin({ client }) {
6794
7827
  idleRefreshLastAttempt.set(target.id, now);
6795
7828
  void refreshIdleAccount(target);
6796
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
+ }
6797
7904
  const commandDeps = {
6798
7905
  sendCommandMessage,
6799
7906
  get accountManager() {
@@ -6811,6 +7918,10 @@ async function AnthropicAuthPlugin({ client }) {
6811
7918
  refreshAccountTokenSingleFlight
6812
7919
  };
6813
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
6814
7925
  "experimental.chat.system.transform": (input, output) => {
6815
7926
  const prefix = CLAUDE_CODE_IDENTITY_STRING;
6816
7927
  if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
@@ -6818,6 +7929,7 @@ async function AnthropicAuthPlugin({ client }) {
6818
7929
  if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1];
6819
7930
  }
6820
7931
  },
7932
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
6821
7933
  config: async (input) => {
6822
7934
  input.command ??= {};
6823
7935
  input.command["anthropic"] = {
@@ -6825,6 +7937,7 @@ async function AnthropicAuthPlugin({ client }) {
6825
7937
  description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)"
6826
7938
  };
6827
7939
  },
7940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
6828
7941
  "command.execute.before": async (input) => {
6829
7942
  if (input.command !== "anthropic") return;
6830
7943
  try {
@@ -6864,8 +7977,6 @@ ${message}`);
6864
7977
  const ccCount = accountManager.getCCAccounts().length;
6865
7978
  if (ccCount > 0) {
6866
7979
  await toast(`Using Claude Code credentials (${ccCount} found)`, "success");
6867
- } else {
6868
- await toast("No Claude Code credentials \u2014 using OAuth", "info");
6869
7980
  }
6870
7981
  }
6871
7982
  const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
@@ -6896,8 +8007,17 @@ ${message}`);
6896
8007
  async fetch(input, init) {
6897
8008
  const currentAuth = await getAuth();
6898
8009
  if (currentAuth.type !== "oauth") return fetch(input, init);
6899
- const requestInit = init ?? {};
8010
+ const requestInit = { ...init ?? {} };
6900
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
+ };
6901
8021
  const requestMethod = String(
6902
8022
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST")
6903
8023
  ).toUpperCase();
@@ -6937,6 +8057,7 @@ ${message}`);
6937
8057
  }
6938
8058
  }
6939
8059
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
8060
+ requestContext.attempt = attempt + 1;
6940
8061
  const account = attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index) ? pinnedAccount : accountManager.getCurrentAccount(transientRefreshSkips);
6941
8062
  if (showUsageToast && account && accountManager) {
6942
8063
  const currentIndex = accountManager.getCurrentIndex();
@@ -7021,19 +8142,26 @@ ${message}`);
7021
8142
  accessToken = account.access;
7022
8143
  }
7023
8144
  maybeRefreshIdleAccounts(account);
7024
- const body = transformRequestBody(
7025
- requestInit.body,
7026
- {
7027
- enabled: signatureEmulationEnabled,
7028
- claudeCliVersion,
7029
- promptCompactionMode
7030
- },
7031
- {
7032
- persistentUserId: signatureUserId,
7033
- sessionId: signatureSessionId,
7034
- accountId: getAccountIdentifier(account)
7035
- }
7036
- );
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();
7037
8165
  logTransformedSystemPrompt(body);
7038
8166
  const requestHeaders = buildRequestHeaders(input, requestInit, accessToken, body, requestUrl, {
7039
8167
  enabled: signatureEmulationEnabled,
@@ -7069,7 +8197,7 @@ ${message}`);
7069
8197
  let response;
7070
8198
  const fetchInput = requestInput;
7071
8199
  try {
7072
- response = await fetchViaBun(fetchInput, {
8200
+ response = await fetchWithTransport(fetchInput, {
7073
8201
  ...requestInit,
7074
8202
  body,
7075
8203
  headers: requestHeaders
@@ -7109,6 +8237,7 @@ ${message}`);
7109
8237
  reason
7110
8238
  });
7111
8239
  accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
8240
+ transientRefreshSkips.add(account.index);
7112
8241
  const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
7113
8242
  const total = accountManager.getAccountCount();
7114
8243
  if (total > 1) {
@@ -7134,23 +8263,24 @@ ${message}`);
7134
8263
  headersForRetry.set("x-stainless-retry-count", String(retryCount));
7135
8264
  retryCount += 1;
7136
8265
  const retryUrl = fetchInput instanceof Request ? fetchInput.url : fetchInput.toString();
7137
- return fetchViaBun(retryUrl, {
8266
+ const retryBody = requestContext.preparedBody === void 0 ? void 0 : cloneBodyForRetry(requestContext.preparedBody);
8267
+ return fetchWithTransport(retryUrl, {
7138
8268
  ...requestInit,
7139
- body,
8269
+ body: retryBody,
7140
8270
  headers: headersForRetry
7141
8271
  });
7142
8272
  },
7143
8273
  { maxRetries: 2 }
7144
8274
  );
7145
8275
  if (!retried.ok) {
7146
- return transformResponse(retried);
8276
+ return finalizeResponse(retried);
7147
8277
  }
7148
8278
  response = retried;
7149
8279
  } else {
7150
8280
  debugLog("non-account-specific response error, returning directly", {
7151
8281
  status: response.status
7152
8282
  });
7153
- return transformResponse(response);
8283
+ return finalizeResponse(response);
7154
8284
  }
7155
8285
  }
7156
8286
  if (account && accountManager && response.ok) {
@@ -7160,15 +8290,28 @@ ${message}`);
7160
8290
  const usageCallback = shouldInspectStream ? (usage) => {
7161
8291
  accountManager.recordUsage(account.index, usage);
7162
8292
  } : null;
7163
- const accountErrorCallback = shouldInspectStream ? (details) => {
7164
- if (details.invalidateToken) {
7165
- account.access = void 0;
7166
- account.expires = void 0;
7167
- 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;
7168
8307
  }
7169
- 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
+ });
7170
8313
  } : null;
7171
- return transformResponse(response, usageCallback, accountErrorCallback);
8314
+ return finalizeResponse(response, usageCallback, accountErrorCallback, streamErrorCallback);
7172
8315
  }
7173
8316
  if (lastError) throw lastError;
7174
8317
  throw new Error("All accounts exhausted \u2014 no account could serve this request");
@@ -7198,11 +8341,38 @@ ${message}`);
7198
8341
  if (!accountManager) {
7199
8342
  accountManager = await AccountManager.load(config, null);
7200
8343
  }
7201
- const exists = accountManager.getAccountsSnapshot().some((acc) => acc.refreshToken === ccCred.refreshToken);
7202
- if (!exists) {
7203
- 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
+ );
7204
8372
  if (added) {
7205
8373
  added.source = ccCred.source;
8374
+ added.label = ccCred.label;
8375
+ added.identity = identity;
7206
8376
  }
7207
8377
  await accountManager.saveToDisk();
7208
8378
  await toast("Added Claude Code credentials", "success");
@@ -7263,17 +8433,35 @@ ${message}`);
7263
8433
  if (!accountManager) {
7264
8434
  accountManager = await AccountManager.load(config, null);
7265
8435
  }
8436
+ const identity = resolveIdentityFromOAuthExchange(credentials);
7266
8437
  const countBefore = accountManager.getAccountCount();
7267
- accountManager.addAccount(
7268
- credentials.refresh,
7269
- credentials.access,
7270
- credentials.expires,
7271
- credentials.email
7272
- );
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
+ }
7273
8460
  await accountManager.saveToDisk();
7274
8461
  const total = accountManager.getAccountCount();
7275
8462
  const name = credentials.email || "account";
7276
- 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");
7277
8465
  else await toast(`Authenticated (${name})`, "success");
7278
8466
  return credentials;
7279
8467
  }