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 +307 -109
- package/lib/account-state.mjs +3 -0
- package/lib/accounts.mjs +22 -5
- package/lib/backoff.mjs +5 -2
- package/lib/backoff.test.mjs +62 -13
- package/lib/cc-credentials.mjs +4 -0
- package/lib/config.mjs +160 -0
- package/lib/oauth.mjs +1 -4
- package/lib/refresh-lock.mjs +10 -1
- package/lib/rotation.mjs +3 -2
- package/lib/storage.mjs +3 -2
- package/lib/storage.test.mjs +4 -0
- package/package.json +1 -1
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
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
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
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
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
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
`
|
|
796
|
-
`
|
|
797
|
-
`
|
|
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
|
-
`
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
saveConfig({
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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))
|
|
928
|
-
|
|
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 -
|
|
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 (!
|
|
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 +
|
|
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 >=
|
|
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 (
|
|
2405
|
+
if (getWillowEnabled() && showUsageToast) {
|
|
2331
2406
|
const now = Date.now();
|
|
2332
2407
|
const idleMs = now - willowLastRequestTime;
|
|
2333
|
-
const cooldownOk = now - willowLastSuggestionTime >=
|
|
2334
|
-
if (idleMs >=
|
|
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
|
-
|
|
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
|
-
//
|
|
2887
|
+
// Anthropic sends window-based unified headers: 5h and 7d windows
|
|
2770
2888
|
if (response.ok && account && accountManager) {
|
|
2771
|
-
const
|
|
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
|
|
2894
|
+
let maxUtilizationWindow = "";
|
|
2774
2895
|
let anySurpassed = false;
|
|
2775
2896
|
let surpassedResetAt = null;
|
|
2776
2897
|
|
|
2777
|
-
|
|
2778
|
-
|
|
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-${
|
|
2910
|
+
`anthropic-ratelimit-unified-${win.key}-surpassed-threshold`,
|
|
2781
2911
|
);
|
|
2782
|
-
const resetAt = response.headers.get(`anthropic-ratelimit-unified-${
|
|
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-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
2839
|
-
const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${
|
|
2840
|
-
const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${
|
|
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
|
|
2845
|
-
if (isNaN(utilization) || isNaN(
|
|
2996
|
+
const resetEpoch = parseInt(resetAtStr) * 1000; // unix epoch seconds → ms
|
|
2997
|
+
if (isNaN(utilization) || isNaN(resetEpoch)) continue;
|
|
2846
2998
|
|
|
2847
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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-.
|
|
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
|
-
//
|
|
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
|
-
|
|
6012
|
-
if (
|
|
6013
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
|
package/lib/account-state.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
|
package/lib/backoff.test.mjs
CHANGED
|
@@ -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
|
|
22
|
-
expect(
|
|
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
|
|
26
|
-
expect(
|
|
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
|
|
30
|
-
expect(
|
|
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
|
|
34
|
-
expect(
|
|
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
|
|
38
|
-
|
|
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
|
|
90
|
-
expect(
|
|
91
|
-
|
|
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
|
|
package/lib/cc-credentials.mjs
CHANGED
|
@@ -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
|
/**
|
package/lib/refresh-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
220
|
-
|
|
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 =
|
|
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[]>} */
|
package/lib/storage.test.mjs
CHANGED
|
@@ -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 () => {
|