opencode-anthropic-fix 0.0.42 → 0.0.44

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.
@@ -15,6 +15,9 @@ export function resetAccountTracking(account) {
15
15
 
16
16
  /**
17
17
  * Normalize active index after removing one account.
18
+ * NOTE: This operates on the disk-level AccountStorage shape (activeIndex >= 0).
19
+ * AccountManager.removeAccount has its own inline variant using #currentIndex = -1
20
+ * for empty accounts (runtime sentinel). The two are intentionally different.
18
21
  * @param {AccountStorage} storage
19
22
  * @param {number} removedIndex
20
23
  */
package/lib/accounts.mjs CHANGED
@@ -154,7 +154,7 @@ export class AccountManager {
154
154
  const now = Date.now();
155
155
  manager.#accounts = [
156
156
  {
157
- id: `${now}:${authFallback.refresh.slice(0, 12)}`,
157
+ id: `${now}:${hashTokenFragment(authFallback.refresh)}`,
158
158
  index: 0,
159
159
  email: undefined,
160
160
  refreshToken: authFallback.refresh,
@@ -433,6 +433,20 @@ export class AccountManager {
433
433
  return backoffMs;
434
434
  }
435
435
 
436
+ /**
437
+ * Mark an account for preemptive switching (quota burn rate high, but request succeeded).
438
+ * Unlike markRateLimited, this does NOT increment consecutiveFailures or record a health penalty.
439
+ * It only applies a short cooldown to encourage the rotation strategy to pick a different account.
440
+ * @param {ManagedAccount} account
441
+ * @param {number|null} [cooldownMs] - Optional cooldown in ms; defaults to 60s
442
+ */
443
+ markPreemptiveSwitch(account, cooldownMs = 60_000) {
444
+ const now = Date.now();
445
+ account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + (cooldownMs ?? 60_000);
446
+ // Do NOT increment consecutiveFailures or record health penalty — the request succeeded.
447
+ this.requestSaveToDisk();
448
+ }
449
+
436
450
  /**
437
451
  * Mark a successful request for an account.
438
452
  * @param {ManagedAccount} account
@@ -559,7 +573,8 @@ export class AccountManager {
559
573
  this.#currentIndex = this.#accounts.length - 1;
560
574
  }
561
575
  if (this.#cursor > 0) {
562
- this.#cursor = Math.min(this.#cursor, this.#accounts.length);
576
+ // QA fix L-cursor: clamp to valid range instead of allowing cursor === length
577
+ this.#cursor = this.#accounts.length > 0 ? this.#cursor % this.#accounts.length : 0;
563
578
  }
564
579
  }
565
580
 
@@ -744,9 +759,11 @@ export class AccountManager {
744
759
  const capturedDeltaKeys = new Set(this.#statsDeltas.keys());
745
760
 
746
761
  // QA fix M15: pass pre-loaded disk data to saveAccounts to avoid TOCTOU double-read
747
- const preloadedDiskData = diskAccountsById
748
- ? { version: 1, accounts: [...diskAccountsById.values()], activeIndex: Math.max(0, this.#currentIndex) }
749
- : null;
762
+ // QA fix L-preload: omit activeIndex from preloaded disk data since saveAccounts
763
+ // only uses disk.accounts for auth freshness merge (not disk.activeIndex).
764
+ // The in-memory currentIndex was misleading here — the real disk activeIndex is
765
+ // carried by the first arg (storage.activeIndex).
766
+ const preloadedDiskData = diskAccountsById ? { version: 1, accounts: [...diskAccountsById.values()] } : null;
750
767
  await saveAccounts(storage, preloadedDiskData);
751
768
 
752
769
  // Clear only the captured deltas; new ones added during await are preserved
package/lib/backoff.mjs CHANGED
@@ -138,10 +138,13 @@ function bodyHasAccountError(body) {
138
138
  "not authorized",
139
139
  ];
140
140
 
141
+ // QA fix: only match signals against error type and parsed message fields,
142
+ // not the full JSON body text — common words like "billing", "permission" in
143
+ // unrelated JSON fields (e.g. resource URLs) cause false account-specific
144
+ // classification, triggering unnecessary account rotation.
141
145
  return (
142
146
  typeSignals.some((signal) => errorType.includes(signal)) ||
143
- messageSignals.some((signal) => message.includes(signal)) ||
144
- messageSignals.some((signal) => text.includes(signal))
147
+ messageSignals.some((signal) => message.includes(signal))
145
148
  );
146
149
  }
147
150
 
@@ -18,24 +18,63 @@ describe("isAccountSpecificError", () => {
18
18
  expect(isAccountSpecificError(401, null)).toBe(true);
19
19
  });
20
20
 
21
- it("returns true for 400 with rate limit language in body", () => {
22
- expect(isAccountSpecificError(400, "This request would exceed your account's rate limit")).toBe(true);
21
+ it("returns true for 400 with rate limit language in JSON error message", () => {
22
+ expect(
23
+ isAccountSpecificError(
24
+ 400,
25
+ JSON.stringify({
26
+ error: { type: "invalid_request_error", message: "This request would exceed your account's rate limit" },
27
+ }),
28
+ ),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it("returns true for 400 with quota language in JSON error message", () => {
33
+ expect(
34
+ isAccountSpecificError(
35
+ 400,
36
+ JSON.stringify({ error: { type: "invalid_request_error", message: "Your quota has been exhausted" } }),
37
+ ),
38
+ ).toBe(true);
23
39
  });
24
40
 
25
- it("returns true for 400 with quota language in body", () => {
26
- expect(isAccountSpecificError(400, "Your quota has been exhausted")).toBe(true);
41
+ it("returns true for 400 with credit balance language in JSON error message", () => {
42
+ expect(
43
+ isAccountSpecificError(
44
+ 400,
45
+ JSON.stringify({
46
+ error: { type: "invalid_request_error", message: "Your credit balance is too low to complete this request" },
47
+ }),
48
+ ),
49
+ ).toBe(true);
27
50
  });
28
51
 
29
- it("returns true for 400 with credit balance language in body", () => {
30
- expect(isAccountSpecificError(400, "Your credit balance is too low to complete this request")).toBe(true);
52
+ it("returns true for 400 with billing language in JSON error message", () => {
53
+ expect(
54
+ isAccountSpecificError(
55
+ 400,
56
+ JSON.stringify({ error: { type: "billing_error", message: "Billing issue on your account" } }),
57
+ ),
58
+ ).toBe(true);
31
59
  });
32
60
 
33
- it("returns true for 400 with billing language in body", () => {
34
- expect(isAccountSpecificError(400, "Billing issue on your account")).toBe(true);
61
+ it("returns true for 403 with permission language in JSON error message", () => {
62
+ expect(
63
+ isAccountSpecificError(
64
+ 403,
65
+ JSON.stringify({
66
+ error: { type: "permission_error", message: "You do not have permission to access this model" },
67
+ }),
68
+ ),
69
+ ).toBe(true);
35
70
  });
36
71
 
37
- it("returns true for 403 with permission language in body", () => {
38
- expect(isAccountSpecificError(403, "You do not have permission to access this model")).toBe(true);
72
+ it("returns false for plain text body without JSON structure (no false positives)", () => {
73
+ // Plain text bodies that happen to contain keywords should NOT match
74
+ // — only structured error.type and error.message fields are checked
75
+ expect(isAccountSpecificError(400, "This request would exceed your account's rate limit")).toBe(false);
76
+ expect(isAccountSpecificError(400, "Billing issue on your account")).toBe(false);
77
+ expect(isAccountSpecificError(403, "You do not have permission to access this model")).toBe(false);
39
78
  });
40
79
 
41
80
  it("returns true for 403 permission_error type in JSON body", () => {
@@ -86,9 +125,19 @@ describe("isAccountSpecificError", () => {
86
125
  expect(isAccountSpecificError(200, null)).toBe(false);
87
126
  });
88
127
 
89
- it("is case-insensitive for body matching", () => {
90
- expect(isAccountSpecificError(400, "RATE LIMIT exceeded")).toBe(true);
91
- expect(isAccountSpecificError(400, "QUOTA Exhausted")).toBe(true);
128
+ it("is case-insensitive for JSON error message matching", () => {
129
+ expect(
130
+ isAccountSpecificError(
131
+ 400,
132
+ JSON.stringify({ error: { type: "invalid_request_error", message: "RATE LIMIT exceeded" } }),
133
+ ),
134
+ ).toBe(true);
135
+ expect(
136
+ isAccountSpecificError(
137
+ 400,
138
+ JSON.stringify({ error: { type: "invalid_request_error", message: "QUOTA Exhausted" } }),
139
+ ),
140
+ ).toBe(true);
92
141
  });
93
142
  });
94
143
 
@@ -226,6 +226,10 @@ export function parseCCCredentialData(jsonStr, source, label) {
226
226
  * @returns {CCCredential | null}
227
227
  */
228
228
  function shapeToCCCredential(data, source, label) {
229
+ // QA fix: filter MCP-scoped credentials — they're not valid for API requests.
230
+ // The file reader already checks this, but keychain entries bypass that check.
231
+ if (data.scope === "mcp") return null;
232
+
229
233
  const accessToken = typeof data.accessToken === "string" ? data.accessToken : null;
230
234
  const refreshToken = typeof data.refreshToken === "string" ? data.refreshToken : null;
231
235
 
package/lib/config.mjs CHANGED
@@ -67,6 +67,14 @@ import { randomBytes } from "node:crypto";
67
67
  * @property {boolean} prefer_over_oauth - When true, CC credentials are tried before plugin-managed OAuth accounts. Default: false.
68
68
  */
69
69
 
70
+ /**
71
+ * @typedef {object} HeaderConfig
72
+ * @property {string} emulation_profile - Claude Code version to emulate in headers
73
+ * @property {Record<string, string>} overrides - Custom header overrides
74
+ * @property {string[]} disable - Headers to disable/remove
75
+ * @property {boolean} billing_header - Whether to include billing header in system prompt
76
+ */
77
+
70
78
  /**
71
79
  * @typedef {object} AnthropicAuthConfig
72
80
  * @property {AccountSelectionStrategy} account_selection_strategy
@@ -515,6 +523,158 @@ function validateConfig(raw) {
515
523
  }
516
524
  }
517
525
 
526
+ // Willow mode sub-config
527
+ if (raw.willow_mode && typeof raw.willow_mode === "object") {
528
+ const wm = /** @type {Record<string, unknown>} */ (raw.willow_mode);
529
+ config.willow_mode = {
530
+ enabled: typeof wm.enabled === "boolean" ? wm.enabled : DEFAULT_CONFIG.willow_mode.enabled,
531
+ idle_threshold_minutes: clampNumber(
532
+ wm.idle_threshold_minutes,
533
+ 1,
534
+ 24 * 60,
535
+ DEFAULT_CONFIG.willow_mode.idle_threshold_minutes,
536
+ ),
537
+ cooldown_minutes: clampNumber(wm.cooldown_minutes, 1, 24 * 60, DEFAULT_CONFIG.willow_mode.cooldown_minutes),
538
+ min_turns_before_suggest: clampNumber(
539
+ wm.min_turns_before_suggest,
540
+ 0,
541
+ 50,
542
+ DEFAULT_CONFIG.willow_mode.min_turns_before_suggest,
543
+ ),
544
+ };
545
+ }
546
+
547
+ // Token economy sub-config
548
+ if (raw.token_economy && typeof raw.token_economy === "object") {
549
+ const te = /** @type {Record<string, unknown>} */ (raw.token_economy);
550
+ config.token_economy = {
551
+ token_efficient_tools:
552
+ typeof te.token_efficient_tools === "boolean"
553
+ ? te.token_efficient_tools
554
+ : DEFAULT_CONFIG.token_economy.token_efficient_tools,
555
+ redact_thinking:
556
+ typeof te.redact_thinking === "boolean" ? te.redact_thinking : DEFAULT_CONFIG.token_economy.redact_thinking,
557
+ connector_text_summarization:
558
+ typeof te.connector_text_summarization === "boolean"
559
+ ? te.connector_text_summarization
560
+ : DEFAULT_CONFIG.token_economy.connector_text_summarization,
561
+ };
562
+ }
563
+
564
+ // Output cap sub-config
565
+ if (raw.output_cap && typeof raw.output_cap === "object") {
566
+ const oc = /** @type {Record<string, unknown>} */ (raw.output_cap);
567
+ config.output_cap = {
568
+ enabled: typeof oc.enabled === "boolean" ? oc.enabled : DEFAULT_CONFIG.output_cap.enabled,
569
+ default_max_tokens: clampNumber(
570
+ oc.default_max_tokens,
571
+ 256,
572
+ 200_000,
573
+ DEFAULT_CONFIG.output_cap.default_max_tokens,
574
+ ),
575
+ escalated_max_tokens: clampNumber(
576
+ oc.escalated_max_tokens,
577
+ 1_000,
578
+ 200_000,
579
+ DEFAULT_CONFIG.output_cap.escalated_max_tokens,
580
+ ),
581
+ };
582
+ }
583
+
584
+ // Preconnect sub-config
585
+ if (raw.preconnect && typeof raw.preconnect === "object") {
586
+ const pc = /** @type {Record<string, unknown>} */ (raw.preconnect);
587
+ config.preconnect = {
588
+ enabled: typeof pc.enabled === "boolean" ? pc.enabled : DEFAULT_CONFIG.preconnect.enabled,
589
+ timeout_ms: clampNumber(pc.timeout_ms, 100, 60_000, DEFAULT_CONFIG.preconnect.timeout_ms),
590
+ };
591
+ }
592
+
593
+ // Overflow recovery sub-config
594
+ if (raw.overflow_recovery && typeof raw.overflow_recovery === "object") {
595
+ const ovf = /** @type {Record<string, unknown>} */ (raw.overflow_recovery);
596
+ config.overflow_recovery = {
597
+ enabled: typeof ovf.enabled === "boolean" ? ovf.enabled : DEFAULT_CONFIG.overflow_recovery.enabled,
598
+ safety_margin: clampNumber(ovf.safety_margin, 0, 10_000, DEFAULT_CONFIG.overflow_recovery.safety_margin),
599
+ };
600
+ }
601
+
602
+ // Cache break detection sub-config
603
+ if (raw.cache_break_detection && typeof raw.cache_break_detection === "object") {
604
+ const cbd = /** @type {Record<string, unknown>} */ (raw.cache_break_detection);
605
+ config.cache_break_detection = {
606
+ enabled: typeof cbd.enabled === "boolean" ? cbd.enabled : DEFAULT_CONFIG.cache_break_detection.enabled,
607
+ alert_threshold: clampNumber(
608
+ cbd.alert_threshold,
609
+ 0,
610
+ 1_000_000,
611
+ DEFAULT_CONFIG.cache_break_detection.alert_threshold,
612
+ ),
613
+ };
614
+ }
615
+
616
+ // Request classification sub-config
617
+ if (raw.request_classification && typeof raw.request_classification === "object") {
618
+ const rc = /** @type {Record<string, unknown>} */ (raw.request_classification);
619
+ config.request_classification = {
620
+ enabled: typeof rc.enabled === "boolean" ? rc.enabled : DEFAULT_CONFIG.request_classification.enabled,
621
+ background_max_service_retries: clampNumber(
622
+ rc.background_max_service_retries,
623
+ 0,
624
+ 10,
625
+ DEFAULT_CONFIG.request_classification.background_max_service_retries,
626
+ ),
627
+ background_max_should_retries: clampNumber(
628
+ rc.background_max_should_retries,
629
+ 0,
630
+ 10,
631
+ DEFAULT_CONFIG.request_classification.background_max_should_retries,
632
+ ),
633
+ };
634
+ }
635
+
636
+ // Token budget sub-config
637
+ if (raw.token_budget && typeof raw.token_budget === "object") {
638
+ const tbg = /** @type {Record<string, unknown>} */ (raw.token_budget);
639
+ config.token_budget = {
640
+ enabled: typeof tbg.enabled === "boolean" ? tbg.enabled : DEFAULT_CONFIG.token_budget.enabled,
641
+ default: clampNumber(tbg.default, 0, 1_000_000, DEFAULT_CONFIG.token_budget.default),
642
+ completion_threshold: clampNumber(
643
+ tbg.completion_threshold,
644
+ 0,
645
+ 1,
646
+ DEFAULT_CONFIG.token_budget.completion_threshold,
647
+ ),
648
+ };
649
+ }
650
+
651
+ // Microcompact sub-config
652
+ if (raw.microcompact && typeof raw.microcompact === "object") {
653
+ const mc = /** @type {Record<string, unknown>} */ (raw.microcompact);
654
+ config.microcompact = {
655
+ enabled: typeof mc.enabled === "boolean" ? mc.enabled : DEFAULT_CONFIG.microcompact.enabled,
656
+ threshold_percent: clampNumber(mc.threshold_percent, 0, 100, DEFAULT_CONFIG.microcompact.threshold_percent),
657
+ };
658
+ }
659
+
660
+ // Overload recovery sub-config
661
+ if (raw.overload_recovery && typeof raw.overload_recovery === "object") {
662
+ const ovl = /** @type {Record<string, unknown>} */ (raw.overload_recovery);
663
+ config.overload_recovery = {
664
+ enabled: typeof ovl.enabled === "boolean" ? ovl.enabled : DEFAULT_CONFIG.overload_recovery.enabled,
665
+ default_cooldown_ms: clampNumber(
666
+ ovl.default_cooldown_ms,
667
+ 1_000,
668
+ 3_600_000,
669
+ DEFAULT_CONFIG.overload_recovery.default_cooldown_ms,
670
+ ),
671
+ poll_quota_on_overload:
672
+ typeof ovl.poll_quota_on_overload === "boolean"
673
+ ? ovl.poll_quota_on_overload
674
+ : DEFAULT_CONFIG.overload_recovery.poll_quota_on_overload,
675
+ };
676
+ }
677
+
518
678
  return config;
519
679
  }
520
680
 
package/lib/oauth.mjs CHANGED
@@ -269,6 +269,7 @@ export async function exchange(code, verifier) {
269
269
  break;
270
270
  }
271
271
 
272
+ // Defensive guard — control flow guarantees result is always set, but kept for safety
272
273
  if (!result) {
273
274
  return fail(undefined, "Token exchange request did not complete");
274
275
  }
@@ -336,7 +337,6 @@ export async function revoke(refreshToken) {
336
337
  export async function refreshToken(refreshTokenValue, options = {}) {
337
338
  // QA fix M13: default 30s timeout to prevent hanging on unresponsive server
338
339
  const signal = options.signal ?? AbortSignal.timeout(30_000);
339
- let lastError = null;
340
340
 
341
341
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
342
342
  let resp;
@@ -357,7 +357,6 @@ export async function refreshToken(refreshTokenValue, options = {}) {
357
357
  signal,
358
358
  });
359
359
  } catch (err) {
360
- lastError = err;
361
360
  if (attempt < OAUTH_MAX_RETRIES) {
362
361
  await sleep(Math.min(500 * 2 ** attempt, 2000));
363
362
  continue;
@@ -393,8 +392,6 @@ export async function refreshToken(refreshTokenValue, options = {}) {
393
392
 
394
393
  return resp.json();
395
394
  }
396
-
397
- throw lastError instanceof Error ? lastError : new Error("Token refresh failed after retries");
398
395
  }
399
396
 
400
397
  /**
@@ -60,7 +60,16 @@ export async function acquireRefreshLock(accountId, options = {}) {
60
60
  try {
61
61
  const stat = await fs.stat(lockPath);
62
62
  if (Date.now() - stat.mtimeMs > staleMs) {
63
- await fs.unlink(lockPath);
63
+ // QA fix L-toctou: re-stat after potential concurrent lock renewal
64
+ // to reduce the TOCTOU window. If mtime changed, skip the unlink.
65
+ try {
66
+ const reStat = await fs.stat(lockPath);
67
+ if (Date.now() - reStat.mtimeMs > staleMs) {
68
+ await fs.unlink(lockPath);
69
+ }
70
+ } catch {
71
+ // File vanished between stat calls — another process released it
72
+ }
64
73
  continue;
65
74
  }
66
75
  } catch {
package/lib/rotation.mjs CHANGED
@@ -216,8 +216,9 @@ const SWITCH_THRESHOLD = 100;
216
216
  function calculateHybridScore(account, maxTokens) {
217
217
  const healthComponent = account.healthScore * 2;
218
218
  const tokenComponent = (account.tokens / maxTokens) * 100 * 5;
219
- // QA fix M17: lastUsed=0 means never-used; treat as 0 freshness bonus instead of max
220
- const secondsSinceUsed = account.lastUsed > 0 ? (Date.now() - account.lastUsed) / 1000 : 0;
219
+ // QA fix M17+: lastUsed=0 means never-used; treat as max freshness (3600s cap)
220
+ // so new accounts aren't perpetually ranked below recently-active ones.
221
+ const secondsSinceUsed = account.lastUsed > 0 ? (Date.now() - account.lastUsed) / 1000 : 3600;
221
222
  const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1;
222
223
 
223
224
  return Math.max(0, healthComponent + tokenComponent + freshnessComponent);
package/lib/storage.mjs CHANGED
@@ -261,7 +261,7 @@ export async function loadAccounts() {
261
261
  * @param {AccountStorage} storage
262
262
  * @returns {Promise<void>}
263
263
  */
264
- export async function saveAccounts(storage) {
264
+ export async function saveAccounts(storage, storageOrDiskData = undefined) {
265
265
  const storagePath = getStoragePath();
266
266
  const configDir = dirname(storagePath);
267
267
 
@@ -276,8 +276,9 @@ export async function saveAccounts(storage) {
276
276
  // Merge auth fields against disk by freshness to avoid stale-process clobber.
277
277
  // We do not resurrect removed accounts; only merge for accounts present in
278
278
  // the incoming storage payload.
279
+ // QA fix: declare diskData param explicitly instead of hidden arguments[1].
279
280
  try {
280
- const disk = arguments[1] ?? (await loadAccounts());
281
+ const disk = storageOrDiskData ?? (await loadAccounts());
281
282
  if (disk && storage.accounts.length > 0) {
282
283
  const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
283
284
  /** @type {Map<number, AccountMetadata[]>} */
@@ -211,12 +211,16 @@ describe("loadAccounts", () => {
211
211
  });
212
212
 
213
213
  it("returns best-effort data for newer version (forward compat)", async () => {
214
+ // QA fix L-warnSpy: mock console.warn to prevent test output contamination
215
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
214
216
  fs.readFile.mockResolvedValue(JSON.stringify({ version: 99, accounts: [] }));
215
217
  const result = await loadAccounts();
216
218
  // H12 fix: no longer returns null; attempts best-effort load with a warning
217
219
  expect(result).not.toBeNull();
218
220
  expect(result.accounts).toEqual([]);
219
221
  expect(result.version).toBe(1); // normalized to current version
222
+ expect(warnSpy).toHaveBeenCalledOnce();
223
+ warnSpy.mockRestore();
220
224
  });
221
225
 
222
226
  it("returns null when accounts is not an array", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [