opencode-anthropic-fix 0.0.43 → 0.0.45

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.
package/index.mjs CHANGED
@@ -145,19 +145,22 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
145
145
  /** @type {Set<string>} */
146
146
  const idleRefreshInFlight = new Set();
147
147
 
148
- const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
149
- const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
150
- const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
148
+ // QA fix H3: use getter functions so /anthropic set toggles take effect at runtime
149
+ // (same pattern as getSignatureEmulationEnabled at line 113).
150
+ const getIdleRefreshEnabled = () => config.idle_refresh.enabled;
151
+ const getIdleRefreshWindowMs = () => config.idle_refresh.window_minutes * 60 * 1000;
152
+ const getIdleRefreshMinIntervalMs = () => config.idle_refresh.min_interval_minutes * 60 * 1000;
151
153
 
152
154
  // Willow Mode: detect inactivity and suggest context reset.
153
155
  // Named after the willow tree — when idle, the session "droops" and a gentle
154
156
  // nudge suggests starting fresh rather than accumulating stale context.
155
- const WILLOW_ENABLED = config.willow_mode?.enabled ?? true;
156
- const WILLOW_IDLE_THRESHOLD_MS = (config.willow_mode?.idle_threshold_minutes ?? 30) * 60 * 1000;
157
- const WILLOW_COOLDOWN_MS = (config.willow_mode?.cooldown_minutes ?? 60) * 60 * 1000;
158
- const WILLOW_MIN_TURNS = config.willow_mode?.min_turns_before_suggest ?? 3;
157
+ const getWillowEnabled = () => config.willow_mode?.enabled ?? true;
158
+ const getWillowIdleThresholdMs = () => (config.willow_mode?.idle_threshold_minutes ?? 30) * 60 * 1000;
159
+ const getWillowCooldownMs = () => (config.willow_mode?.cooldown_minutes ?? 60) * 60 * 1000;
160
+ const getWillowMinTurns = () => config.willow_mode?.min_turns_before_suggest ?? 3;
159
161
  let willowLastRequestTime = Date.now();
160
162
  let willowLastSuggestionTime = 0;
163
+ let _lastOAuthPruneTime = 0; // QA fix L-oauthPrune: throttle for periodic prune
161
164
 
162
165
  // Beta header latching: once a beta is sent in a session, it stays on for
163
166
  // the rest of the session to prevent cache key churn (~50-70K tokens per flip).
@@ -645,8 +648,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
645
648
  requests: 0,
646
649
  inputTokens: 0,
647
650
  updatedAt: 0,
648
- fiveHour: { utilization: 0, resets_at: null },
649
- sevenDay: { utilization: 0, resets_at: null },
651
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
652
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
653
+ overallStatus: null,
654
+ representativeClaim: null,
655
+ fallback: null,
656
+ fallbackPercentage: null,
657
+ overageStatus: null,
658
+ overageReason: null,
650
659
  lastPollAt: 0,
651
660
  };
652
661
  sessionMetrics.lastStopReason = null;
@@ -740,22 +749,25 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
740
749
  // Quota info (if available from rate-limit headers)
741
750
  if (sessionMetrics.lastQuota.updatedAt > 0) {
742
751
  const q = sessionMetrics.lastQuota;
743
- const quotaParts = [];
744
- // QA fix L8: show 0% utilization too — valid data point meaning plenty of capacity
745
- quotaParts.push(`tokens: ${(q.tokens * 100).toFixed(0)}%`);
746
- quotaParts.push(`requests: ${(q.requests * 100).toFixed(0)}%`);
747
- quotaParts.push(`input-tokens: ${(q.inputTokens * 100).toFixed(0)}%`);
748
- lines.push("", `Rate limit utilization: ${quotaParts.join(", ")}`);
749
- }
750
-
751
- // Usage endpoint data (A6)
752
- if (sessionMetrics.lastQuota.lastPollAt > 0) {
753
- const q5h = sessionMetrics.lastQuota.fiveHour;
754
- const q7d = sessionMetrics.lastQuota.sevenDay;
755
- const pollAge = Math.round((Date.now() - sessionMetrics.lastQuota.lastPollAt) / 60_000);
756
- lines.push("", `Usage endpoint (${pollAge}m ago):`);
757
- lines.push(` 5-hour: ${q5h.utilization.toFixed(0)}% used${q5h.resets_at ? ` (resets ${q5h.resets_at})` : ""}`);
758
- lines.push(` 7-day: ${q7d.utilization.toFixed(0)}% used${q7d.resets_at ? ` (resets ${q7d.resets_at})` : ""}`);
752
+ const q5h = q.fiveHour;
753
+ const q7d = q.sevenDay;
754
+ lines.push("", `Rate limit utilization:`);
755
+ lines.push(
756
+ ` 5-hour: ${q5h.utilization.toFixed(0)}% used${q5h.status ? ` [${q5h.status}]` : ""}${q5h.resets_at ? ` (resets ${q5h.resets_at})` : ""}`,
757
+ );
758
+ lines.push(
759
+ ` 7-day: ${q7d.utilization.toFixed(0)}% used${q7d.status ? ` [${q7d.status}]` : ""}${q7d.resets_at ? ` (resets ${q7d.resets_at})` : ""}`,
760
+ );
761
+ if (q.overallStatus)
762
+ lines.push(
763
+ ` Status: ${q.overallStatus}${q.representativeClaim ? ` (claim: ${q.representativeClaim})` : ""}`,
764
+ );
765
+ if (q.fallback)
766
+ lines.push(
767
+ ` Fallback: ${q.fallback}${q.fallbackPercentage != null ? ` (${(q.fallbackPercentage * 100).toFixed(0)}%)` : ""}`,
768
+ );
769
+ if (q.overageStatus)
770
+ lines.push(` Overage: ${q.overageStatus}${q.overageReason ? ` (${q.overageReason})` : ""}`);
759
771
  }
760
772
 
761
773
  // Token budget display (A9)
@@ -789,16 +801,39 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
789
801
  const filled = Math.max(0, Math.min(20, Math.round(pct * 20)));
790
802
  return "[" + "█".repeat(filled) + "░".repeat(20 - filled) + "]";
791
803
  };
804
+ const q5h = q.fiveHour;
805
+ const q7d = q.sevenDay;
792
806
  const lines = [
793
807
  "▣ Anthropic Rate Limit Quota",
794
808
  "",
795
- `Tokens: ${bar(q.tokens)} ${(q.tokens * 100).toFixed(0)}%`,
796
- `Requests: ${bar(q.requests)} ${(q.requests * 100).toFixed(0)}%`,
797
- `Input tokens: ${bar(q.inputTokens)} ${(q.inputTokens * 100).toFixed(0)}%`,
809
+ `5-hour window:`,
810
+ ` ${bar(q5h.utilization / 100)} ${q5h.utilization.toFixed(0)}%${q5h.status ? ` [${q5h.status}]` : ""}`,
811
+ q5h.resets_at ? ` Resets: ${q5h.resets_at}` : null,
812
+ q5h.surpassedThreshold != null ? ` Surpassed threshold: ${(q5h.surpassedThreshold * 100).toFixed(0)}%` : null,
798
813
  "",
799
- `Last updated: ${agoStr}`,
800
- ];
801
- const maxUtil = Math.max(q.tokens, q.requests, q.inputTokens);
814
+ `7-day window:`,
815
+ ` ${bar(q7d.utilization / 100)} ${q7d.utilization.toFixed(0)}%${q7d.status ? ` [${q7d.status}]` : ""}`,
816
+ q7d.resets_at ? ` Resets: ${q7d.resets_at}` : null,
817
+ q7d.surpassedThreshold != null ? ` Surpassed threshold: ${(q7d.surpassedThreshold * 100).toFixed(0)}%` : null,
818
+ "",
819
+ ].filter(Boolean);
820
+
821
+ if (q.overallStatus) {
822
+ lines.push(
823
+ `Overall status: ${q.overallStatus}${q.representativeClaim ? ` (claim: ${q.representativeClaim})` : ""}`,
824
+ );
825
+ }
826
+ if (q.fallback) {
827
+ lines.push(
828
+ `Fallback: ${q.fallback}${q.fallbackPercentage != null ? ` (${(q.fallbackPercentage * 100).toFixed(0)}% capacity)` : ""}`,
829
+ );
830
+ }
831
+ if (q.overageStatus) {
832
+ lines.push(`Overage: ${q.overageStatus}${q.overageReason ? ` (${q.overageReason})` : ""}`);
833
+ }
834
+ lines.push("", `Last updated: ${agoStr}`);
835
+
836
+ const maxUtil = Math.max(q5h.utilization, q7d.utilization) / 100;
802
837
  if (maxUtil >= 0.9) {
803
838
  lines.push("", "⚠ High utilization — consider slowing request rate or rotating accounts");
804
839
  } else if (maxUtil >= 0.7) {
@@ -902,16 +937,38 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
902
937
  const value = (args[2] || "").toLowerCase();
903
938
  /** @type {Record<string, () => void>} */
904
939
  const setters = {
905
- emulation: () =>
906
- saveConfig({ signature_emulation: { enabled: value === "on" || value === "1" || value === "true" } }),
907
- compaction: () =>
908
- saveConfig({ signature_emulation: { prompt_compaction: value === "off" ? "off" : "minimal" } }),
909
- "1m-context": () =>
910
- saveConfig({ override_model_limits: { enabled: value === "on" || value === "1" || value === "true" } }),
911
- "idle-refresh": () =>
912
- saveConfig({ idle_refresh: { enabled: value === "on" || value === "1" || value === "true" } }),
913
- debug: () => saveConfig({ debug: value === "on" || value === "1" || value === "true" }),
914
- quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
940
+ emulation: () => {
941
+ const enabled = value === "on" || value === "1" || value === "true";
942
+ saveConfig({ signature_emulation: { enabled } });
943
+ config.signature_emulation.enabled = enabled;
944
+ },
945
+ compaction: () => {
946
+ const mode = value === "off" ? "off" : "minimal";
947
+ saveConfig({ signature_emulation: { prompt_compaction: mode } });
948
+ config.signature_emulation.prompt_compaction = mode;
949
+ },
950
+ "1m-context": () => {
951
+ const enabled = value === "on" || value === "1" || value === "true";
952
+ saveConfig({ override_model_limits: { enabled } });
953
+ if (!config.override_model_limits) config.override_model_limits = { enabled: false };
954
+ config.override_model_limits.enabled = enabled;
955
+ },
956
+ "idle-refresh": () => {
957
+ const enabled = value === "on" || value === "1" || value === "true";
958
+ saveConfig({ idle_refresh: { enabled } });
959
+ if (!config.idle_refresh) config.idle_refresh = { enabled: false };
960
+ config.idle_refresh.enabled = enabled;
961
+ },
962
+ debug: () => {
963
+ const enabled = value === "on" || value === "1" || value === "true";
964
+ saveConfig({ debug: enabled });
965
+ config.debug = enabled;
966
+ },
967
+ quiet: () => {
968
+ const enabled = value === "on" || value === "1" || value === "true";
969
+ saveConfig({ toasts: { quiet: enabled } });
970
+ config.toasts.quiet = enabled;
971
+ },
915
972
  strategy: () => {
916
973
  const valid = ["sticky", "round-robin", "hybrid"];
917
974
  if (valid.includes(value)) {
@@ -920,12 +977,18 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
920
977
  strategyState.mode = "CONFIGURED";
921
978
  } else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
922
979
  },
923
- boundary: () =>
924
- saveConfig({ cache_policy: { boundary_marker: value === "on" || value === "1" || value === "true" } }),
980
+ boundary: () => {
981
+ const enabled = value === "on" || value === "1" || value === "true";
982
+ saveConfig({ cache_policy: { boundary_marker: enabled } });
983
+ if (!config.cache_policy) config.cache_policy = {};
984
+ config.cache_policy.boundary_marker = enabled;
985
+ },
925
986
  "cache-ttl": () => {
926
987
  const valid = ["1h", "5m", "off"];
927
- if (valid.includes(value)) saveConfig({ cache_policy: { ttl: value } });
928
- else throw new Error(`Invalid TTL. Valid: ${valid.join(", ")}`);
988
+ if (!valid.includes(value)) throw new Error(`Invalid TTL. Valid: ${valid.join(", ")}`);
989
+ saveConfig({ cache_policy: { ttl: value } });
990
+ if (!config.cache_policy) config.cache_policy = {};
991
+ config.cache_policy.ttl = value;
929
992
  },
930
993
  fast: () => {
931
994
  const enabled = value === "on" || value === "true" || value === "1";
@@ -1876,8 +1939,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1876
1939
  }
1877
1940
  debouncedToastTimestamps.set(options.debounceKey, now);
1878
1941
  // QA fix M2: prune stale entries to prevent unbounded growth
1942
+ // QA fix L-debounce: use fixed 5-minute cutoff instead of config-dependent minGapMs*2
1943
+ // to avoid entries surviving longer than intended if debounce_seconds changes at runtime
1879
1944
  if (debouncedToastTimestamps.size > 200) {
1880
- const cutoff = now - minGapMs * 2;
1945
+ const cutoff = now - 300_000; // 5 minutes — generous for any realistic debounce window
1881
1946
  for (const [k, ts] of debouncedToastTimestamps) {
1882
1947
  if (ts < cutoff) debouncedToastTimestamps.delete(k);
1883
1948
  }
@@ -2133,16 +2198,16 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2133
2198
  * @param {import('./lib/accounts.mjs').ManagedAccount} activeAccount
2134
2199
  */
2135
2200
  function maybeRefreshIdleAccounts(activeAccount) {
2136
- if (!IDLE_REFRESH_ENABLED || !accountManager) return;
2201
+ if (!getIdleRefreshEnabled() || !accountManager) return;
2137
2202
 
2138
2203
  const now = Date.now();
2139
2204
  const excluded = new Set([activeAccount.index]);
2140
2205
  const candidates = accountManager
2141
2206
  .getEnabledAccounts(excluded)
2142
- .filter((acc) => !acc.expires || acc.expires <= now + IDLE_REFRESH_WINDOW_MS)
2207
+ .filter((acc) => !acc.expires || acc.expires <= now + getIdleRefreshWindowMs())
2143
2208
  .filter((acc) => {
2144
2209
  const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
2145
- return now - last >= IDLE_REFRESH_MIN_INTERVAL_MS;
2210
+ return now - last >= getIdleRefreshMinIntervalMs();
2146
2211
  })
2147
2212
  .sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
2148
2213
 
@@ -2324,14 +2389,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2324
2389
  await accountManager.syncActiveIndexFromDisk();
2325
2390
  }
2326
2391
 
2392
+ // QA fix L-oauthPrune: periodically prune expired pending OAuth flows on API requests
2393
+ // (throttled to at most once per 60 seconds) to avoid PKCE verifiers living in memory indefinitely.
2394
+ {
2395
+ const _now = Date.now();
2396
+ if (_now - _lastOAuthPruneTime > 60_000) {
2397
+ _lastOAuthPruneTime = _now;
2398
+ pruneExpiredPendingOAuth();
2399
+ }
2400
+ }
2401
+
2327
2402
  // Willow Mode: if the session has been idle for longer than the
2328
2403
  // configured threshold and has enough turns, show a gentle toast
2329
2404
  // suggesting the user consider starting a fresh context.
2330
- if (WILLOW_ENABLED && showUsageToast) {
2405
+ if (getWillowEnabled() && showUsageToast) {
2331
2406
  const now = Date.now();
2332
2407
  const idleMs = now - willowLastRequestTime;
2333
- const cooldownOk = now - willowLastSuggestionTime >= WILLOW_COOLDOWN_MS;
2334
- if (idleMs >= WILLOW_IDLE_THRESHOLD_MS && cooldownOk && sessionMetrics.turns >= WILLOW_MIN_TURNS) {
2408
+ const cooldownOk = now - willowLastSuggestionTime >= getWillowCooldownMs();
2409
+ if (idleMs >= getWillowIdleThresholdMs() && cooldownOk && sessionMetrics.turns >= getWillowMinTurns()) {
2335
2410
  const idleMin = Math.round(idleMs / 60_000);
2336
2411
  willowLastSuggestionTime = now;
2337
2412
  toast(
@@ -2401,6 +2476,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2401
2476
  let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
2402
2477
  let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
2403
2478
  let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
2479
+ let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
2404
2480
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2405
2481
  // Select account — use pinned account on first attempt if available
2406
2482
  const account =
@@ -2656,7 +2732,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2656
2732
  const effectiveCachePolicy = latchedCachePolicy ||
2657
2733
  config.cache_policy || { ttl: "1h", ttl_supported: true };
2658
2734
 
2659
- let body = transformRequestBody(
2735
+ const body = transformRequestBody(
2660
2736
  requestInit.body,
2661
2737
  {
2662
2738
  enabled: getSignatureEmulationEnabled(),
@@ -2736,11 +2812,13 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2736
2812
  errMsg.includes("socket hang up") ||
2737
2813
  errMsg.includes("network socket disconnected");
2738
2814
 
2739
- if (isConnectionReset) {
2815
+ if (isConnectionReset && _connectionResetRetries < 3) {
2816
+ _connectionResetRetries++;
2740
2817
  requestInit._disableKeepalive = true;
2741
2818
  debugLog("connection reset detected, disabling keepalive for retry", {
2742
2819
  code: errCode,
2743
2820
  message: errMsg,
2821
+ retryCount: _connectionResetRetries,
2744
2822
  });
2745
2823
  // Don't mark the account as failed — this is a transport issue, not auth.
2746
2824
  // Retry the same account with keepalive disabled.
@@ -2765,34 +2843,90 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2765
2843
  throw fetchError;
2766
2844
  }
2767
2845
 
2846
+ // Debug: log all response headers to file for diagnosis
2847
+ // Placed BEFORE the response.ok guard so we capture headers on ALL responses
2848
+ if (config.debug) {
2849
+ const rlHeaders = {};
2850
+ const allHeaders = {};
2851
+ response.headers.forEach((value, key) => {
2852
+ allHeaders[key] = value;
2853
+ if (key.includes("ratelimit") || key.includes("retry") || key.includes("x-should")) {
2854
+ rlHeaders[key] = value;
2855
+ }
2856
+ });
2857
+ debugLog(
2858
+ "response status:",
2859
+ response.status,
2860
+ "ok:",
2861
+ response.ok,
2862
+ "account:",
2863
+ !!account,
2864
+ "accountManager:",
2865
+ !!accountManager,
2866
+ );
2867
+ debugLog("ALL response headers:", allHeaders);
2868
+ // Write to file for reliable access
2869
+ try {
2870
+ const { writeFileSync } = await import("node:fs");
2871
+ const { join } = await import("node:path");
2872
+ const debugFile = join(getConfigDir(), "debug-headers.log");
2873
+ const ts = new Date().toISOString();
2874
+ const entry = [
2875
+ `\n=== ${ts} | status=${response.status} ok=${response.ok} account=${!!account} mgr=${!!accountManager} ===`,
2876
+ `Rate-limit headers: ${JSON.stringify(rlHeaders, null, 2)}`,
2877
+ `All headers: ${JSON.stringify(allHeaders, null, 2)}`,
2878
+ "",
2879
+ ].join("\n");
2880
+ writeFileSync(debugFile, entry, { flag: "a" });
2881
+ } catch (e) {
2882
+ debugLog("failed to write debug-headers.log", e);
2883
+ }
2884
+ }
2885
+
2768
2886
  // Proactive rate limit detection from response headers
2769
- // Check all rate-limit subtypes: tokens, requests, input-tokens (RE doc §5.6)
2887
+ // Anthropic sends window-based unified headers: 5h and 7d windows
2770
2888
  if (response.ok && account && accountManager) {
2771
- const RATE_LIMIT_SUBTYPES = ["tokens", "requests", "input-tokens"];
2889
+ const RATE_LIMIT_WINDOWS = [
2890
+ { key: "5h", field: "fiveHour", windowMs: 5 * 3600 * 1000 },
2891
+ { key: "7d", field: "sevenDay", windowMs: 7 * 24 * 3600 * 1000 },
2892
+ ];
2772
2893
  let maxUtilization = 0;
2773
- let maxUtilizationSubtype = "";
2894
+ let maxUtilizationWindow = "";
2774
2895
  let anySurpassed = false;
2775
2896
  let surpassedResetAt = null;
2776
2897
 
2777
- for (const subtype of RATE_LIMIT_SUBTYPES) {
2778
- const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
2898
+ // Also capture overall status and fallback info
2899
+ const overallStatus = response.headers.get("anthropic-ratelimit-unified-status");
2900
+ const representativeClaim = response.headers.get("anthropic-ratelimit-unified-representative-claim");
2901
+ const fallbackStatus = response.headers.get("anthropic-ratelimit-unified-fallback");
2902
+ const fallbackPct = response.headers.get("anthropic-ratelimit-unified-fallback-percentage");
2903
+ const overageStatus = response.headers.get("anthropic-ratelimit-unified-overage-status");
2904
+ const overageReason = response.headers.get("anthropic-ratelimit-unified-overage-disabled-reason");
2905
+
2906
+ for (const win of RATE_LIMIT_WINDOWS) {
2907
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-utilization`);
2908
+ const status = response.headers.get(`anthropic-ratelimit-unified-${win.key}-status`);
2779
2909
  const surpassed = response.headers.get(
2780
- `anthropic-ratelimit-unified-${subtype}-surpassed-threshold`,
2910
+ `anthropic-ratelimit-unified-${win.key}-surpassed-threshold`,
2781
2911
  );
2782
- const resetAt = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
2912
+ const resetAt = response.headers.get(`anthropic-ratelimit-unified-${win.key}-reset`);
2783
2913
 
2784
2914
  if (utilizationStr) {
2785
2915
  const utilization = parseFloat(utilizationStr);
2786
2916
  if (!isNaN(utilization)) {
2787
- // Store per-subtype quota for user display
2788
- if (subtype === "tokens") sessionMetrics.lastQuota.tokens = utilization;
2789
- else if (subtype === "requests") sessionMetrics.lastQuota.requests = utilization;
2790
- else if (subtype === "input-tokens") sessionMetrics.lastQuota.inputTokens = utilization;
2917
+ // Store per-window quota for user display
2918
+ const resetDate = resetAt ? new Date(parseInt(resetAt) * 1000).toISOString() : null;
2919
+ sessionMetrics.lastQuota[win.field] = {
2920
+ utilization: utilization * 100, // store as percentage 0-100
2921
+ resets_at: resetDate,
2922
+ status: status || null,
2923
+ surpassedThreshold: surpassed ? parseFloat(surpassed) : null,
2924
+ };
2791
2925
  sessionMetrics.lastQuota.updatedAt = Date.now();
2792
2926
 
2793
2927
  if (utilization > maxUtilization) {
2794
2928
  maxUtilization = utilization;
2795
- maxUtilizationSubtype = subtype;
2929
+ maxUtilizationWindow = win.key;
2796
2930
  }
2797
2931
  }
2798
2932
  }
@@ -2803,12 +2937,30 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2803
2937
  }
2804
2938
  }
2805
2939
 
2940
+ // Store overall/fallback/overage info
2941
+ if (overallStatus) {
2942
+ sessionMetrics.lastQuota.overallStatus = overallStatus;
2943
+ sessionMetrics.lastQuota.representativeClaim = representativeClaim;
2944
+ sessionMetrics.lastQuota.fallback = fallbackStatus;
2945
+ sessionMetrics.lastQuota.fallbackPercentage = fallbackPct ? parseFloat(fallbackPct) : null;
2946
+ sessionMetrics.lastQuota.overageStatus = overageStatus;
2947
+ sessionMetrics.lastQuota.overageReason = overageReason;
2948
+ }
2949
+
2950
+ // Back-compat: also update tokens/requests/inputTokens from the highest window
2951
+ // so existing code that reads these fields still works
2952
+ if (maxUtilization > 0) {
2953
+ sessionMetrics.lastQuota.tokens = maxUtilization;
2954
+ sessionMetrics.lastQuota.requests = maxUtilization;
2955
+ sessionMetrics.lastQuota.inputTokens = maxUtilization;
2956
+ }
2957
+
2806
2958
  if (maxUtilization > 0.8) {
2807
2959
  const penalty = Math.round((maxUtilization - 0.8) * 50); // 0-10 points
2808
2960
  accountManager.applyUtilizationPenalty(account, penalty);
2809
2961
  debugLog("high rate limit utilization", {
2810
2962
  accountIndex: account.index,
2811
- subtype: maxUtilizationSubtype,
2963
+ window: maxUtilizationWindow,
2812
2964
  utilization: (maxUtilization * 100).toFixed(1) + "%",
2813
2965
  penalty,
2814
2966
  });
@@ -2825,7 +2977,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2825
2977
  // Toast at 90%+ utilization to warn user before rate limit hits
2826
2978
  if (maxUtilization >= 0.9 && !config.toasts?.quiet) {
2827
2979
  toast(
2828
- `Rate limit ${maxUtilizationSubtype}: ${(maxUtilization * 100).toFixed(0)}% utilized`,
2980
+ `Rate limit ${maxUtilizationWindow} window: ${(maxUtilization * 100).toFixed(0)}% utilized`,
2829
2981
  "warning",
2830
2982
  { debounceKey: "quota-warn" },
2831
2983
  ).catch(() => {});
@@ -2835,21 +2987,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2835
2987
  // Parse reset timestamps to compute time-weighted risk
2836
2988
  if (maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
2837
2989
  let highestRisk = 0;
2838
- for (const subtype of RATE_LIMIT_SUBTYPES) {
2839
- const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
2840
- const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
2990
+ for (const win of RATE_LIMIT_WINDOWS) {
2991
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-utilization`);
2992
+ const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-reset`);
2841
2993
  if (!utilizationStr || !resetAtStr) continue;
2842
2994
 
2843
2995
  const utilization = parseFloat(utilizationStr);
2844
- const resetAt = new Date(resetAtStr).getTime();
2845
- if (isNaN(utilization) || isNaN(resetAt)) continue;
2996
+ const resetEpoch = parseInt(resetAtStr) * 1000; // unix epoch seconds → ms
2997
+ if (isNaN(utilization) || isNaN(resetEpoch)) continue;
2846
2998
 
2847
- // Estimate window duration from reset time
2848
- // 5h window = 18000s, 7d window = 604800s
2849
- const timeUntilReset = Math.max(0, resetAt - Date.now());
2999
+ const timeUntilReset = Math.max(0, resetEpoch - Date.now());
2850
3000
  // Risk formula: how fast we're burning through the quota
2851
3001
  // Higher utilization + less time remaining = higher risk
2852
- const timeRemainingFraction = Math.max(0.01, timeUntilReset / (5 * 3600 * 1000)); // assume 5h window
3002
+ const timeRemainingFraction = Math.max(0.01, timeUntilReset / win.windowMs);
2853
3003
  const risk = utilization / timeRemainingFraction;
2854
3004
  if (risk > highestRisk) highestRisk = risk;
2855
3005
  }
@@ -2859,7 +3009,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2859
3009
  const currentName = account.email || `Account ${account.index + 1}`;
2860
3010
  const nextAccount = accountManager.peekNextAccount?.();
2861
3011
  const nextName = nextAccount?.email || "next account";
2862
- accountManager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
3012
+ // QA fix L-predictive: use markPreemptiveSwitch instead of markRateLimited
3013
+ // — the request succeeded (200), so don't penalise consecutiveFailures or health.
3014
+ accountManager.markPreemptiveSwitch(account);
2863
3015
  toast(
2864
3016
  `Predictive switch: ${currentName} at high burn rate, switching to ${nextName}`,
2865
3017
  "warning",
@@ -2877,7 +3029,28 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2877
3029
  if (!response.ok && accountManager && account) {
2878
3030
  let errorBody = null;
2879
3031
  try {
2880
- errorBody = await response.clone().text();
3032
+ // QA fix L-errorBody: size-bound the read (16 KB) to avoid OOM on large error responses,
3033
+ // and add a 5s timeout so streaming error bodies don't stall the retry logic.
3034
+ const cloned = response.clone();
3035
+ const reader = cloned.body?.getReader();
3036
+ if (reader) {
3037
+ const chunks = [];
3038
+ let totalLen = 0;
3039
+ const maxLen = 16_384;
3040
+ const deadline = Date.now() + 5_000;
3041
+ while (totalLen < maxLen && Date.now() < deadline) {
3042
+ const { done, value } = await reader.read();
3043
+ if (done) break;
3044
+ chunks.push(value);
3045
+ totalLen += value.byteLength;
3046
+ }
3047
+ reader.cancel().catch(() => {});
3048
+ errorBody = new TextDecoder()
3049
+ .decode(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks))
3050
+ .slice(0, maxLen);
3051
+ } else {
3052
+ errorBody = await cloned.text();
3053
+ }
2881
3054
  } catch {
2882
3055
  // Ignore read errors in debug logging path.
2883
3056
  }
@@ -2909,11 +3082,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2909
3082
  margin,
2910
3083
  });
2911
3084
  try {
2912
- const recoveryBody = JSON.parse(body);
3085
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3086
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3087
+ const recoveryBody = JSON.parse(requestInit.body);
2913
3088
  recoveryBody.max_tokens = safeMaxTokens;
2914
3089
  requestInit.body = JSON.stringify(recoveryBody);
2915
- // Update `body` variable for subsequent iterations
2916
- body = requestInit.body;
3090
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
2917
3091
  requestInit._overflowRecoveryAttempted = true;
2918
3092
  attempt--;
2919
3093
  toast(
@@ -2943,7 +3117,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2943
3117
  }
2944
3118
  }
2945
3119
  try {
2946
- const parsedBody = JSON.parse(body);
3120
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3121
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3122
+ const parsedBody = JSON.parse(requestInit.body);
2947
3123
  if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
2948
3124
  // Keep first 2 messages (initial context) and last 2 messages (recent work).
2949
3125
  // Ensure the trimmed array never ends with an assistant message (prefill),
@@ -2992,6 +3168,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2992
3168
  // tool_result was in a trimmed middle message).
2993
3169
  parsedBody.messages = repairOrphanedToolUseBlocks(trimmed);
2994
3170
  requestInit.body = JSON.stringify(parsedBody);
3171
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
2995
3172
  requestInit._reactiveCompactAttempted = true;
2996
3173
  // Retry with trimmed messages (decrement attempt to not consume account slot)
2997
3174
  attempt--;
@@ -3125,9 +3302,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3125
3302
  // Track consecutive 529s for model fallback
3126
3303
  if (response.status === 529) {
3127
3304
  consecutive529Count++;
3128
- if (consecutive529Count >= 3 && body) {
3305
+ if (consecutive529Count >= 3 && requestInit.body) {
3129
3306
  try {
3130
- const parsedForFallback = JSON.parse(body);
3307
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3308
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3309
+ const parsedForFallback = JSON.parse(requestInit.body);
3131
3310
  const currentModel = parsedForFallback.model || "";
3132
3311
  let fallbackModel = null;
3133
3312
  if (/opus-4-6|opus-4/i.test(currentModel))
@@ -3138,7 +3317,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3138
3317
  if (fallbackModel) {
3139
3318
  parsedForFallback.model = fallbackModel;
3140
3319
  requestInit.body = JSON.stringify(parsedForFallback);
3141
- body = requestInit.body;
3320
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
3142
3321
  toast(
3143
3322
  `Model fallback: ${currentModel} → ${fallbackModel} after ${consecutive529Count} overloads`,
3144
3323
  "warning",
@@ -3580,7 +3759,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3580
3759
  // Session-level cache & cost tracking (Phase 4)
3581
3760
  // ---------------------------------------------------------------------------
3582
3761
 
3583
- /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null}, sevenDay: {utilization: number, resets_at: string|null}, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}}} */
3762
+ /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, sevenDay: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, overallStatus: string|null, representativeClaim: string|null, fallback: string|null, fallbackPercentage: number|null, overageStatus: string|null, overageReason: string|null, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}}} */
3584
3763
  const sessionMetrics = {
3585
3764
  turns: 0,
3586
3765
  totalInput: 0,
@@ -3597,9 +3776,17 @@ const sessionMetrics = {
3597
3776
  requests: 0,
3598
3777
  inputTokens: 0,
3599
3778
  updatedAt: 0,
3779
+ // Window-based unified headers from response
3780
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
3781
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
3782
+ // Overall/fallback/overage from response headers
3783
+ overallStatus: null,
3784
+ representativeClaim: null,
3785
+ fallback: null,
3786
+ fallbackPercentage: null,
3787
+ overageStatus: null,
3788
+ overageReason: null,
3600
3789
  // Usage endpoint polling (A6)
3601
- fiveHour: { utilization: 0, resets_at: null },
3602
- sevenDay: { utilization: 0, resets_at: null },
3603
3790
  lastPollAt: 0,
3604
3791
  },
3605
3792
  lastStopReason: null, // tracks most recent stop_reason for output cap escalation
@@ -3725,12 +3912,14 @@ async function pollOAuthUsage(config, accessToken) {
3725
3912
  const data = await resp.json();
3726
3913
  if (data.five_hour) {
3727
3914
  sessionMetrics.lastQuota.fiveHour = {
3915
+ ...sessionMetrics.lastQuota.fiveHour,
3728
3916
  utilization: data.five_hour.utilization ?? 0,
3729
3917
  resets_at: data.five_hour.resets_at ?? null,
3730
3918
  };
3731
3919
  }
3732
3920
  if (data.seven_day) {
3733
3921
  sessionMetrics.lastQuota.sevenDay = {
3922
+ ...sessionMetrics.lastQuota.sevenDay,
3734
3923
  utilization: data.seven_day.utilization ?? 0,
3735
3924
  resets_at: data.seven_day.resets_at ?? null,
3736
3925
  };
@@ -3878,10 +4067,10 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
3878
4067
  * @param {string} bodyStr - JSON request body
3879
4068
  * @returns {Map<string, string>} source_id → hash
3880
4069
  */
3881
- function extractCacheSourceHashes(bodyStr) {
4070
+ function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
3882
4071
  const hashes = new Map();
3883
4072
  try {
3884
- const parsed = JSON.parse(bodyStr);
4073
+ const parsed = parsedBody ?? JSON.parse(bodyStr);
3885
4074
 
3886
4075
  // Hash system prompt (excluding token budget blocks injected by injectTokenBudgetBlock)
3887
4076
  if (Array.isArray(parsed.system)) {
@@ -4686,26 +4875,29 @@ const SESSION_START_TIME = Date.now();
4686
4875
  const liveTokenRef = { token: "" };
4687
4876
 
4688
4877
  // Best-effort exit telemetry (QA fix M10: use 'once' to prevent listener stacking on re-import)
4689
- process.once("beforeExit", () => {
4878
+ // QA fix L-beforeExit: store handler reference for cleanup; prevents leaked refs to telemetryEmitter
4879
+ const _beforeExitHandler = () => {
4690
4880
  const duration = Date.now() - SESSION_START_TIME;
4691
4881
  telemetryEmitter.sendExitEvent(liveTokenRef.token, duration).catch(() => {});
4692
- });
4882
+ };
4883
+ process.once("beforeExit", _beforeExitHandler);
4693
4884
 
4694
4885
  // ---------------------------------------------------------------------------
4695
4886
  // Request building helpers (extracted from original fetch interceptor)
4696
4887
  // ---------------------------------------------------------------------------
4697
4888
 
4698
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.90";
4889
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.91";
4699
4890
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
4700
- const CLAUDE_CODE_BUILD_TIME = "2026-04-01T22:53:10Z";
4891
+ const CLAUDE_CODE_BUILD_TIME = "2026-04-02T21:58:41Z";
4701
4892
 
4702
- // The @anthropic-ai/sdk version bundled with Claude Code v2.1.90.
4893
+ // The @anthropic-ai/sdk version bundled with Claude Code v2.1.91.
4703
4894
  // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
4704
- // Verified by extracting VERSION="0.208.0" from the bundled cli.js of all versions .80-.90.
4895
+ // Verified by extracting VERSION="0.208.0" from the bundled cli.js of all versions .80-.91.
4705
4896
  const ANTHROPIC_SDK_VERSION = "0.208.0";
4706
4897
 
4707
4898
  // Map of CLI version → bundled SDK version (update when CLI version changes)
4708
4899
  const CLI_TO_SDK_VERSION = new Map([
4900
+ ["2.1.91", "0.208.0"],
4709
4901
  ["2.1.90", "0.208.0"],
4710
4902
  ["2.1.89", "0.208.0"],
4711
4903
  ["2.1.88", "0.208.0"],
@@ -6003,20 +6195,19 @@ function buildRequestHeaders(
6003
6195
  );
6004
6196
  // x-stainless-timeout: sent only for non-streaming requests.
6005
6197
  // Claude Code sends 600 (10 minutes) as the default timeout in seconds.
6006
- // We detect streaming from the request body.
6007
- let xStainlessTimeout = "600";
6198
+ // For streaming requests, the SDK omits this header entirely.
6008
6199
  if (requestBody) {
6009
6200
  try {
6010
6201
  const parsed = JSON.parse(requestBody);
6011
- const isStreaming = parsed.stream !== false;
6012
- if (!isStreaming) {
6013
- xStainlessTimeout = "600";
6202
+ // stream defaults to true in Claude Code; only explicitly false means non-streaming
6203
+ if (parsed.stream === false) {
6204
+ requestHeaders.set("x-stainless-timeout", "600");
6014
6205
  }
6206
+ // Streaming requests: omit x-stainless-timeout (real SDK behavior)
6015
6207
  } catch {
6016
- // Non-JSON body or parse error — assume streaming
6208
+ // Non-JSON body or parse error — omit header (safe default)
6017
6209
  }
6018
6210
  }
6019
- requestHeaders.set("x-stainless-timeout", xStainlessTimeout);
6020
6211
  const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
6021
6212
  if (stainlessHelpers) {
6022
6213
  requestHeaders.set("x-stainless-helper", stainlessHelpers);
@@ -6058,6 +6249,10 @@ function buildRequestHeaders(
6058
6249
  function resolveMaxTokens(body, config) {
6059
6250
  if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
6060
6251
  if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
6252
+ // QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
6253
+ // completes. The timing works correctly for "escalate for one turn" because this function runs
6254
+ // BEFORE the next request's stream starts. If the response pipeline changes to update stop
6255
+ // reason mid-stream or before response completion, this ordering assumption would break.
6061
6256
  const escalated = sessionMetrics.lastStopReason === "max_tokens";
6062
6257
  const result = escalated
6063
6258
  ? (config.output_cap.escalated_max_tokens ?? 64_000)
@@ -6887,18 +7082,21 @@ function parseCommandArgs(raw) {
6887
7082
  function extractFileIds(body) {
6888
7083
  const ids = [];
6889
7084
  if (!body || typeof body !== "object") return ids;
6890
- function walk(obj) {
7085
+ // QA fix L-depth: cap recursion depth to prevent stack overflow on pathological payloads
7086
+ const MAX_DEPTH = 20;
7087
+ function walk(obj, depth) {
7088
+ if (depth > MAX_DEPTH) return;
6891
7089
  if (Array.isArray(obj)) {
6892
- for (const item of obj) walk(item);
7090
+ for (const item of obj) walk(item, depth + 1);
6893
7091
  } else if (obj && typeof obj === "object") {
6894
7092
  if (obj.source?.file_id) ids.push(obj.source.file_id);
6895
7093
  for (const val of Object.values(obj)) {
6896
- if (val && typeof val === "object") walk(val);
7094
+ if (val && typeof val === "object") walk(val, depth + 1);
6897
7095
  }
6898
7096
  }
6899
7097
  }
6900
- walk(body.messages);
6901
- walk(body.system);
7098
+ walk(body.messages, 0);
7099
+ walk(body.system, 0);
6902
7100
  return ids;
6903
7101
  }
6904
7102
 
@@ -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.43",
3
+ "version": "0.0.45",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [