@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
@@ -86,6 +86,72 @@ import { AsyncLocalStorage } from "node:async_hooks";
86
86
  import { exec as exec2 } from "node:child_process";
87
87
  import { pathToFileURL } from "node:url";
88
88
 
89
+ // src/account-identity.ts
90
+ function isCCAccountSource(source) {
91
+ return source === "cc-keychain" || source === "cc-file";
92
+ }
93
+ function isAccountIdentity(value) {
94
+ if (!value || typeof value !== "object") return false;
95
+ const candidate = value;
96
+ switch (candidate.kind) {
97
+ case "oauth":
98
+ return typeof candidate.email === "string" && candidate.email.length > 0;
99
+ case "cc":
100
+ return isCCAccountSource(candidate.source) && typeof candidate.label === "string";
101
+ case "legacy":
102
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
103
+ default:
104
+ return false;
105
+ }
106
+ }
107
+ function resolveIdentity(account) {
108
+ if (isAccountIdentity(account.identity)) {
109
+ return account.identity;
110
+ }
111
+ if (account.source === "oauth" && account.email) {
112
+ return { kind: "oauth", email: account.email };
113
+ }
114
+ if (isCCAccountSource(account.source) && account.label) {
115
+ return { kind: "cc", source: account.source, label: account.label };
116
+ }
117
+ return { kind: "legacy", refreshToken: account.refreshToken };
118
+ }
119
+ function resolveIdentityFromOAuthExchange(result) {
120
+ if (result.email) {
121
+ return {
122
+ kind: "oauth",
123
+ email: result.email
124
+ };
125
+ }
126
+ return {
127
+ kind: "legacy",
128
+ refreshToken: result.refresh
129
+ };
130
+ }
131
+ function identitiesMatch(a, b) {
132
+ if (a.kind !== b.kind) return false;
133
+ switch (a.kind) {
134
+ case "oauth": {
135
+ return a.email === b.email;
136
+ }
137
+ case "cc": {
138
+ const ccIdentity = b;
139
+ return a.source === ccIdentity.source && a.label === ccIdentity.label;
140
+ }
141
+ case "legacy": {
142
+ return a.refreshToken === b.refreshToken;
143
+ }
144
+ }
145
+ }
146
+ function findByIdentity(accounts, id) {
147
+ for (const account of accounts) {
148
+ if (identitiesMatch(resolveIdentity(account), id)) {
149
+ return account;
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
89
155
  // src/config.ts
90
156
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
91
157
  import { homedir } from "node:os";
@@ -585,6 +651,8 @@ function validateAccount(raw, now) {
585
651
  return {
586
652
  id,
587
653
  email: typeof acc.email === "string" ? acc.email : void 0,
654
+ identity: isAccountIdentity2(acc.identity) ? acc.identity : void 0,
655
+ label: typeof acc.label === "string" ? acc.label : void 0,
588
656
  refreshToken: acc.refreshToken,
589
657
  access: typeof acc.access === "string" ? acc.access : void 0,
590
658
  expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : void 0,
@@ -600,6 +668,106 @@ function validateAccount(raw, now) {
600
668
  source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : void 0
601
669
  };
602
670
  }
671
+ function isAccountIdentity2(value) {
672
+ if (!value || typeof value !== "object") return false;
673
+ const candidate = value;
674
+ switch (candidate.kind) {
675
+ case "oauth":
676
+ return typeof candidate.email === "string" && candidate.email.length > 0;
677
+ case "cc":
678
+ return (candidate.source === "cc-keychain" || candidate.source === "cc-file") && typeof candidate.label === "string";
679
+ case "legacy":
680
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
681
+ default:
682
+ return false;
683
+ }
684
+ }
685
+ function resolveStoredIdentity(candidate) {
686
+ if (isAccountIdentity2(candidate.identity)) {
687
+ return candidate.identity;
688
+ }
689
+ if (candidate.source === "oauth" && candidate.email) {
690
+ return { kind: "oauth", email: candidate.email };
691
+ }
692
+ if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
693
+ return {
694
+ kind: "cc",
695
+ source: candidate.source,
696
+ label: candidate.label
697
+ };
698
+ }
699
+ return { kind: "legacy", refreshToken: candidate.refreshToken };
700
+ }
701
+ function resolveTokenUpdatedAt(account) {
702
+ return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at) ? account.token_updated_at : account.addedAt;
703
+ }
704
+ function clampActiveIndex(accounts, activeIndex) {
705
+ if (accounts.length === 0) {
706
+ return 0;
707
+ }
708
+ return Math.max(0, Math.min(activeIndex, accounts.length - 1));
709
+ }
710
+ function findStoredAccountMatch(accounts, candidate) {
711
+ const byId = accounts.find((account) => account.id === candidate.id);
712
+ if (byId) {
713
+ return byId;
714
+ }
715
+ const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
716
+ if (byIdentity) {
717
+ return byIdentity;
718
+ }
719
+ const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
720
+ if (byAddedAt.length === 1) {
721
+ return byAddedAt[0];
722
+ }
723
+ const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
724
+ if (byRefreshToken) {
725
+ return byRefreshToken;
726
+ }
727
+ return byAddedAt[0] ?? null;
728
+ }
729
+ function mergeAccountWithFresherAuth(account, diskMatch) {
730
+ const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
731
+ const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
732
+ if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
733
+ return {
734
+ ...account,
735
+ token_updated_at: memoryTokenUpdatedAt
736
+ };
737
+ }
738
+ return {
739
+ ...account,
740
+ refreshToken: diskMatch.refreshToken,
741
+ access: diskMatch.access,
742
+ expires: diskMatch.expires,
743
+ token_updated_at: diskTokenUpdatedAt
744
+ };
745
+ }
746
+ function unionAccountsWithDisk(storage, disk) {
747
+ if (!disk || storage.accounts.length === 0) {
748
+ return {
749
+ ...storage,
750
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex)
751
+ };
752
+ }
753
+ const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
754
+ const matchedDiskAccounts = /* @__PURE__ */ new Set();
755
+ const mergedAccounts = storage.accounts.map((account) => {
756
+ const diskMatch = findStoredAccountMatch(disk.accounts, account);
757
+ if (diskMatch) {
758
+ matchedDiskAccounts.add(diskMatch);
759
+ }
760
+ return mergeAccountWithFresherAuth(account, diskMatch);
761
+ });
762
+ const diskOnlyAccounts = disk.accounts.filter((account) => !matchedDiskAccounts.has(account));
763
+ const accounts = [...mergedAccounts, ...diskOnlyAccounts];
764
+ const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
765
+ return {
766
+ ...storage,
767
+ accounts,
768
+ activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex)
769
+ };
770
+ }
603
771
  async function loadAccounts() {
604
772
  const storagePath = getStoragePath();
605
773
  try {
@@ -609,7 +777,9 @@ async function loadAccounts() {
609
777
  return null;
610
778
  }
611
779
  if (data.version !== CURRENT_VERSION) {
612
- return null;
780
+ console.warn(
781
+ `Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`
782
+ );
613
783
  }
614
784
  const now = Date.now();
615
785
  const accounts = data.accounts.map((raw) => validateAccount(raw, now)).filter((acc) => acc !== null);
@@ -636,53 +806,13 @@ async function saveAccounts(storage) {
636
806
  const configDir = dirname2(storagePath);
637
807
  await fs.mkdir(configDir, { recursive: true });
638
808
  ensureGitignore(configDir);
639
- let storageToWrite = storage;
809
+ let storageToWrite = {
810
+ ...storage,
811
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex)
812
+ };
640
813
  try {
641
814
  const disk = await loadAccounts();
642
- if (disk && storage.accounts.length > 0) {
643
- const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
644
- const diskByAddedAt = /* @__PURE__ */ new Map();
645
- const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
646
- for (const d3 of disk.accounts) {
647
- const bucket = diskByAddedAt.get(d3.addedAt) || [];
648
- bucket.push(d3);
649
- diskByAddedAt.set(d3.addedAt, bucket);
650
- }
651
- const findDiskMatch = (acc) => {
652
- const byId = diskById.get(acc.id);
653
- if (byId) return byId;
654
- const byAddedAt = diskByAddedAt.get(acc.addedAt);
655
- if (byAddedAt?.length === 1) return byAddedAt[0];
656
- const byToken = diskByToken.get(acc.refreshToken);
657
- if (byToken) return byToken;
658
- if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0];
659
- return null;
660
- };
661
- const mergedAccounts = storage.accounts.map((acc) => {
662
- const diskAcc = findDiskMatch(acc);
663
- const memTs = typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at) ? acc.token_updated_at : acc.addedAt;
664
- const diskTs = diskAcc?.token_updated_at || 0;
665
- const useDiskAuth = !!diskAcc && diskTs > memTs;
666
- return {
667
- ...acc,
668
- refreshToken: useDiskAuth ? diskAcc.refreshToken : acc.refreshToken,
669
- access: useDiskAuth ? diskAcc.access : acc.access,
670
- expires: useDiskAuth ? diskAcc.expires : acc.expires,
671
- token_updated_at: useDiskAuth ? diskTs : memTs
672
- };
673
- });
674
- let activeIndex = storage.activeIndex;
675
- if (mergedAccounts.length > 0) {
676
- activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
677
- } else {
678
- activeIndex = 0;
679
- }
680
- storageToWrite = {
681
- ...storage,
682
- accounts: mergedAccounts,
683
- activeIndex
684
- };
685
- }
815
+ storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
686
816
  } catch {
687
817
  }
688
818
  const tempPath = `${storagePath}.${randomBytes2(6).toString("hex")}.tmp`;
@@ -1837,14 +1967,20 @@ async function cmdLogin() {
1837
1967
  const credentials = await runOAuthFlow();
1838
1968
  if (!credentials) return 1;
1839
1969
  const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
1840
- const existingIdx = storage.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
1841
- if (existingIdx >= 0) {
1842
- storage.accounts[existingIdx].access = credentials.access;
1843
- storage.accounts[existingIdx].expires = credentials.expires;
1844
- if (credentials.email) storage.accounts[existingIdx].email = credentials.email;
1845
- storage.accounts[existingIdx].enabled = true;
1970
+ const identity = resolveIdentityFromOAuthExchange(credentials);
1971
+ const existing = findByIdentity(storage.accounts, identity) || storage.accounts.find((acc) => acc.refreshToken === credentials.refresh);
1972
+ if (existing) {
1973
+ const existingIdx = storage.accounts.indexOf(existing);
1974
+ existing.refreshToken = credentials.refresh;
1975
+ existing.access = credentials.access;
1976
+ existing.expires = credentials.expires;
1977
+ existing.token_updated_at = Date.now();
1978
+ if (credentials.email) existing.email = credentials.email;
1979
+ existing.identity = identity;
1980
+ existing.source = existing.source ?? "oauth";
1981
+ existing.enabled = true;
1846
1982
  await saveAccounts(storage);
1847
- const label2 = credentials.email || `Account ${existingIdx + 1}`;
1983
+ const label2 = credentials.email || existing.email || `Account ${existingIdx + 1}`;
1848
1984
  O2.success(`Updated existing account #${existingIdx + 1} (${label2}).`);
1849
1985
  return 0;
1850
1986
  }
@@ -1856,6 +1992,7 @@ async function cmdLogin() {
1856
1992
  storage.accounts.push({
1857
1993
  id: `${now}:${credentials.refresh.slice(0, 12)}`,
1858
1994
  email: credentials.email,
1995
+ identity,
1859
1996
  refreshToken: credentials.refresh,
1860
1997
  access: credentials.access,
1861
1998
  expires: credentials.expires,
@@ -1866,7 +2003,8 @@ async function cmdLogin() {
1866
2003
  rateLimitResetTimes: {},
1867
2004
  consecutiveFailures: 0,
1868
2005
  lastFailureTime: null,
1869
- stats: createDefaultStats(now)
2006
+ stats: createDefaultStats(now),
2007
+ source: "oauth"
1870
2008
  });
1871
2009
  await saveAccounts(storage);
1872
2010
  const label = credentials.email || `Account ${storage.accounts.length}`;
@@ -2064,7 +2202,8 @@ async function cmdList() {
2064
2202
  }
2065
2203
  }
2066
2204
  if (anyRefreshed) {
2067
- await saveAccounts(stored).catch(() => {
2205
+ await saveAccounts(stored).catch((err) => {
2206
+ console.error("[opencode-anthropic-auth] failed to persist refreshed tokens:", err);
2068
2207
  });
2069
2208
  }
2070
2209
  O2.message(c2.bold("Anthropic Multi-Account Status"));