opencode-anthropic-fix 0.0.42 → 0.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +402 -174
- 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(
|
|
@@ -2354,28 +2429,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2354
2429
|
// pin the first attempt to that account so files are accessible.
|
|
2355
2430
|
// Without this, round-robin could route to an account that doesn't
|
|
2356
2431
|
// have the referenced files, causing file_not_found errors.
|
|
2357
|
-
|
|
2358
|
-
|
|
2432
|
+
// Parse body ONCE before the retry loop and reuse for all downstream logic.
|
|
2433
|
+
// This eliminates 4-5 redundant JSON.parse calls per turn (file pinning,
|
|
2434
|
+
// parseRequestBodyMetadata, adaptive context, toast, microcompact).
|
|
2435
|
+
let _parsedBodyOnce = null;
|
|
2436
|
+
if (typeof requestInit.body === "string") {
|
|
2359
2437
|
try {
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2438
|
+
_parsedBodyOnce = JSON.parse(requestInit.body);
|
|
2439
|
+
} catch {
|
|
2440
|
+
// Non-JSON body — downstream code handles gracefully
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
let pinnedAccount = null;
|
|
2445
|
+
if (_parsedBodyOnce && fileAccountMap.size > 0) {
|
|
2446
|
+
const fileIds = extractFileIds(_parsedBodyOnce);
|
|
2447
|
+
for (const fid of fileIds) {
|
|
2448
|
+
const pinnedIndex = fileAccountMap.get(fid);
|
|
2449
|
+
if (pinnedIndex !== undefined) {
|
|
2450
|
+
const candidates = accountManager.getEnabledAccounts();
|
|
2451
|
+
pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
|
|
2452
|
+
if (pinnedAccount) {
|
|
2453
|
+
debugLog("file-id pinning: routing to account", {
|
|
2454
|
+
fileId: fid,
|
|
2455
|
+
accountIndex: pinnedIndex,
|
|
2456
|
+
email: pinnedAccount.email,
|
|
2457
|
+
});
|
|
2458
|
+
break;
|
|
2375
2459
|
}
|
|
2376
2460
|
}
|
|
2377
|
-
} catch {
|
|
2378
|
-
// Non-JSON body or parse error — skip pinning
|
|
2379
2461
|
}
|
|
2380
2462
|
}
|
|
2381
2463
|
|
|
@@ -2394,6 +2476,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2394
2476
|
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
2395
2477
|
let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
|
|
2396
2478
|
let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
|
|
2479
|
+
let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
|
|
2397
2480
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2398
2481
|
// Select account — use pinned account on first attempt if available
|
|
2399
2482
|
const account =
|
|
@@ -2538,8 +2621,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2538
2621
|
|
|
2539
2622
|
// Pre-compute the beta header so it can be injected into both the
|
|
2540
2623
|
// request body (betas field) and the anthropic-beta header.
|
|
2624
|
+
// Reuse _parsedBodyOnce to avoid redundant JSON.parse.
|
|
2541
2625
|
const { model: _reqModel, hasFileReferences: _reqHasFileRefs } = parseRequestBodyMetadata(
|
|
2542
2626
|
requestInit.body,
|
|
2627
|
+
_parsedBodyOnce,
|
|
2543
2628
|
);
|
|
2544
2629
|
const _reqProvider = detectProvider(requestUrl);
|
|
2545
2630
|
|
|
@@ -2555,12 +2640,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2555
2640
|
escalation_threshold: 150_000,
|
|
2556
2641
|
deescalation_threshold: 100_000,
|
|
2557
2642
|
},
|
|
2643
|
+
_parsedBodyOnce,
|
|
2558
2644
|
);
|
|
2559
2645
|
// Emit visual cue on state transitions (only when adaptive mode is on)
|
|
2560
2646
|
if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
|
|
2561
2647
|
const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
|
|
2562
2648
|
const variant = adaptiveContextState.active ? "info" : "success";
|
|
2563
|
-
const est =
|
|
2649
|
+
const est = _parsedBodyOnce
|
|
2650
|
+
? estimatePromptTokensFromParsed(_parsedBodyOnce)
|
|
2651
|
+
: estimatePromptTokens(requestInit.body);
|
|
2564
2652
|
toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
|
|
2565
2653
|
debounceKey: "adaptive-ctx",
|
|
2566
2654
|
}).catch(() => {});
|
|
@@ -2578,7 +2666,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2578
2666
|
// Microcompact: inject clear betas at high context utilization
|
|
2579
2667
|
let _microcompactBetas = null;
|
|
2580
2668
|
if (requestInit.body) {
|
|
2581
|
-
const estimatedTokens =
|
|
2669
|
+
const estimatedTokens = _parsedBodyOnce
|
|
2670
|
+
? estimatePromptTokensFromParsed(_parsedBodyOnce)
|
|
2671
|
+
: estimatePromptTokens(requestInit.body);
|
|
2582
2672
|
if (shouldMicrocompact(estimatedTokens, config)) {
|
|
2583
2673
|
_microcompactBetas = buildMicrocompactBetas();
|
|
2584
2674
|
if (!microcompactState.active) {
|
|
@@ -2642,7 +2732,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2642
2732
|
const effectiveCachePolicy = latchedCachePolicy ||
|
|
2643
2733
|
config.cache_policy || { ttl: "1h", ttl_supported: true };
|
|
2644
2734
|
|
|
2645
|
-
|
|
2735
|
+
const body = transformRequestBody(
|
|
2646
2736
|
requestInit.body,
|
|
2647
2737
|
{
|
|
2648
2738
|
enabled: getSignatureEmulationEnabled(),
|
|
@@ -2722,11 +2812,13 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2722
2812
|
errMsg.includes("socket hang up") ||
|
|
2723
2813
|
errMsg.includes("network socket disconnected");
|
|
2724
2814
|
|
|
2725
|
-
if (isConnectionReset) {
|
|
2815
|
+
if (isConnectionReset && _connectionResetRetries < 3) {
|
|
2816
|
+
_connectionResetRetries++;
|
|
2726
2817
|
requestInit._disableKeepalive = true;
|
|
2727
2818
|
debugLog("connection reset detected, disabling keepalive for retry", {
|
|
2728
2819
|
code: errCode,
|
|
2729
2820
|
message: errMsg,
|
|
2821
|
+
retryCount: _connectionResetRetries,
|
|
2730
2822
|
});
|
|
2731
2823
|
// Don't mark the account as failed — this is a transport issue, not auth.
|
|
2732
2824
|
// Retry the same account with keepalive disabled.
|
|
@@ -2751,34 +2843,90 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2751
2843
|
throw fetchError;
|
|
2752
2844
|
}
|
|
2753
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
|
+
|
|
2754
2886
|
// Proactive rate limit detection from response headers
|
|
2755
|
-
//
|
|
2887
|
+
// Anthropic sends window-based unified headers: 5h and 7d windows
|
|
2756
2888
|
if (response.ok && account && accountManager) {
|
|
2757
|
-
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
|
+
];
|
|
2758
2893
|
let maxUtilization = 0;
|
|
2759
|
-
let
|
|
2894
|
+
let maxUtilizationWindow = "";
|
|
2760
2895
|
let anySurpassed = false;
|
|
2761
2896
|
let surpassedResetAt = null;
|
|
2762
2897
|
|
|
2763
|
-
|
|
2764
|
-
|
|
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`);
|
|
2765
2909
|
const surpassed = response.headers.get(
|
|
2766
|
-
`anthropic-ratelimit-unified-${
|
|
2910
|
+
`anthropic-ratelimit-unified-${win.key}-surpassed-threshold`,
|
|
2767
2911
|
);
|
|
2768
|
-
const resetAt = response.headers.get(`anthropic-ratelimit-unified-${
|
|
2912
|
+
const resetAt = response.headers.get(`anthropic-ratelimit-unified-${win.key}-reset`);
|
|
2769
2913
|
|
|
2770
2914
|
if (utilizationStr) {
|
|
2771
2915
|
const utilization = parseFloat(utilizationStr);
|
|
2772
2916
|
if (!isNaN(utilization)) {
|
|
2773
|
-
// Store per-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
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
|
+
};
|
|
2777
2925
|
sessionMetrics.lastQuota.updatedAt = Date.now();
|
|
2778
2926
|
|
|
2779
2927
|
if (utilization > maxUtilization) {
|
|
2780
2928
|
maxUtilization = utilization;
|
|
2781
|
-
|
|
2929
|
+
maxUtilizationWindow = win.key;
|
|
2782
2930
|
}
|
|
2783
2931
|
}
|
|
2784
2932
|
}
|
|
@@ -2789,12 +2937,30 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2789
2937
|
}
|
|
2790
2938
|
}
|
|
2791
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
|
+
|
|
2792
2958
|
if (maxUtilization > 0.8) {
|
|
2793
2959
|
const penalty = Math.round((maxUtilization - 0.8) * 50); // 0-10 points
|
|
2794
2960
|
accountManager.applyUtilizationPenalty(account, penalty);
|
|
2795
2961
|
debugLog("high rate limit utilization", {
|
|
2796
2962
|
accountIndex: account.index,
|
|
2797
|
-
|
|
2963
|
+
window: maxUtilizationWindow,
|
|
2798
2964
|
utilization: (maxUtilization * 100).toFixed(1) + "%",
|
|
2799
2965
|
penalty,
|
|
2800
2966
|
});
|
|
@@ -2811,7 +2977,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2811
2977
|
// Toast at 90%+ utilization to warn user before rate limit hits
|
|
2812
2978
|
if (maxUtilization >= 0.9 && !config.toasts?.quiet) {
|
|
2813
2979
|
toast(
|
|
2814
|
-
`Rate limit ${
|
|
2980
|
+
`Rate limit ${maxUtilizationWindow} window: ${(maxUtilization * 100).toFixed(0)}% utilized`,
|
|
2815
2981
|
"warning",
|
|
2816
2982
|
{ debounceKey: "quota-warn" },
|
|
2817
2983
|
).catch(() => {});
|
|
@@ -2821,21 +2987,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2821
2987
|
// Parse reset timestamps to compute time-weighted risk
|
|
2822
2988
|
if (maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
|
|
2823
2989
|
let highestRisk = 0;
|
|
2824
|
-
for (const
|
|
2825
|
-
const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${
|
|
2826
|
-
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`);
|
|
2827
2993
|
if (!utilizationStr || !resetAtStr) continue;
|
|
2828
2994
|
|
|
2829
2995
|
const utilization = parseFloat(utilizationStr);
|
|
2830
|
-
const
|
|
2831
|
-
if (isNaN(utilization) || isNaN(
|
|
2996
|
+
const resetEpoch = parseInt(resetAtStr) * 1000; // unix epoch seconds → ms
|
|
2997
|
+
if (isNaN(utilization) || isNaN(resetEpoch)) continue;
|
|
2832
2998
|
|
|
2833
|
-
|
|
2834
|
-
// 5h window = 18000s, 7d window = 604800s
|
|
2835
|
-
const timeUntilReset = Math.max(0, resetAt - Date.now());
|
|
2999
|
+
const timeUntilReset = Math.max(0, resetEpoch - Date.now());
|
|
2836
3000
|
// Risk formula: how fast we're burning through the quota
|
|
2837
3001
|
// Higher utilization + less time remaining = higher risk
|
|
2838
|
-
const timeRemainingFraction = Math.max(0.01, timeUntilReset /
|
|
3002
|
+
const timeRemainingFraction = Math.max(0.01, timeUntilReset / win.windowMs);
|
|
2839
3003
|
const risk = utilization / timeRemainingFraction;
|
|
2840
3004
|
if (risk > highestRisk) highestRisk = risk;
|
|
2841
3005
|
}
|
|
@@ -2845,7 +3009,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2845
3009
|
const currentName = account.email || `Account ${account.index + 1}`;
|
|
2846
3010
|
const nextAccount = accountManager.peekNextAccount?.();
|
|
2847
3011
|
const nextName = nextAccount?.email || "next account";
|
|
2848
|
-
|
|
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);
|
|
2849
3015
|
toast(
|
|
2850
3016
|
`Predictive switch: ${currentName} at high burn rate, switching to ${nextName}`,
|
|
2851
3017
|
"warning",
|
|
@@ -2863,7 +3029,28 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2863
3029
|
if (!response.ok && accountManager && account) {
|
|
2864
3030
|
let errorBody = null;
|
|
2865
3031
|
try {
|
|
2866
|
-
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
|
+
}
|
|
2867
3054
|
} catch {
|
|
2868
3055
|
// Ignore read errors in debug logging path.
|
|
2869
3056
|
}
|
|
@@ -2895,11 +3082,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2895
3082
|
margin,
|
|
2896
3083
|
});
|
|
2897
3084
|
try {
|
|
2898
|
-
|
|
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);
|
|
2899
3088
|
recoveryBody.max_tokens = safeMaxTokens;
|
|
2900
3089
|
requestInit.body = JSON.stringify(recoveryBody);
|
|
2901
|
-
|
|
2902
|
-
body = requestInit.body;
|
|
3090
|
+
_parsedBodyOnce = null; // Invalidate stale parsed cache
|
|
2903
3091
|
requestInit._overflowRecoveryAttempted = true;
|
|
2904
3092
|
attempt--;
|
|
2905
3093
|
toast(
|
|
@@ -2929,7 +3117,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2929
3117
|
}
|
|
2930
3118
|
}
|
|
2931
3119
|
try {
|
|
2932
|
-
|
|
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);
|
|
2933
3123
|
if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
|
|
2934
3124
|
// Keep first 2 messages (initial context) and last 2 messages (recent work).
|
|
2935
3125
|
// Ensure the trimmed array never ends with an assistant message (prefill),
|
|
@@ -2978,6 +3168,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2978
3168
|
// tool_result was in a trimmed middle message).
|
|
2979
3169
|
parsedBody.messages = repairOrphanedToolUseBlocks(trimmed);
|
|
2980
3170
|
requestInit.body = JSON.stringify(parsedBody);
|
|
3171
|
+
_parsedBodyOnce = null; // Invalidate stale parsed cache
|
|
2981
3172
|
requestInit._reactiveCompactAttempted = true;
|
|
2982
3173
|
// Retry with trimmed messages (decrement attempt to not consume account slot)
|
|
2983
3174
|
attempt--;
|
|
@@ -3111,9 +3302,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3111
3302
|
// Track consecutive 529s for model fallback
|
|
3112
3303
|
if (response.status === 529) {
|
|
3113
3304
|
consecutive529Count++;
|
|
3114
|
-
if (consecutive529Count >= 3 && body) {
|
|
3305
|
+
if (consecutive529Count >= 3 && requestInit.body) {
|
|
3115
3306
|
try {
|
|
3116
|
-
|
|
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);
|
|
3117
3310
|
const currentModel = parsedForFallback.model || "";
|
|
3118
3311
|
let fallbackModel = null;
|
|
3119
3312
|
if (/opus-4-6|opus-4/i.test(currentModel))
|
|
@@ -3124,7 +3317,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3124
3317
|
if (fallbackModel) {
|
|
3125
3318
|
parsedForFallback.model = fallbackModel;
|
|
3126
3319
|
requestInit.body = JSON.stringify(parsedForFallback);
|
|
3127
|
-
|
|
3320
|
+
_parsedBodyOnce = null; // Invalidate stale parsed cache
|
|
3128
3321
|
toast(
|
|
3129
3322
|
`Model fallback: ${currentModel} → ${fallbackModel} after ${consecutive529Count} overloads`,
|
|
3130
3323
|
"warning",
|
|
@@ -3144,7 +3337,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3144
3337
|
consecutive529Count = 0;
|
|
3145
3338
|
}
|
|
3146
3339
|
|
|
3147
|
-
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount),
|
|
3340
|
+
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 3);
|
|
3148
3341
|
const jitter = 1 - Math.random() * 0.25;
|
|
3149
3342
|
const sleepMs = Math.round(baseDelay * jitter * 1000);
|
|
3150
3343
|
const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
|
|
@@ -3566,7 +3759,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3566
3759
|
// Session-level cache & cost tracking (Phase 4)
|
|
3567
3760
|
// ---------------------------------------------------------------------------
|
|
3568
3761
|
|
|
3569
|
-
/** @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[]}}} */
|
|
3570
3763
|
const sessionMetrics = {
|
|
3571
3764
|
turns: 0,
|
|
3572
3765
|
totalInput: 0,
|
|
@@ -3583,9 +3776,17 @@ const sessionMetrics = {
|
|
|
3583
3776
|
requests: 0,
|
|
3584
3777
|
inputTokens: 0,
|
|
3585
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,
|
|
3586
3789
|
// Usage endpoint polling (A6)
|
|
3587
|
-
fiveHour: { utilization: 0, resets_at: null },
|
|
3588
|
-
sevenDay: { utilization: 0, resets_at: null },
|
|
3589
3790
|
lastPollAt: 0,
|
|
3590
3791
|
},
|
|
3591
3792
|
lastStopReason: null, // tracks most recent stop_reason for output cap escalation
|
|
@@ -3711,12 +3912,14 @@ async function pollOAuthUsage(config, accessToken) {
|
|
|
3711
3912
|
const data = await resp.json();
|
|
3712
3913
|
if (data.five_hour) {
|
|
3713
3914
|
sessionMetrics.lastQuota.fiveHour = {
|
|
3915
|
+
...sessionMetrics.lastQuota.fiveHour,
|
|
3714
3916
|
utilization: data.five_hour.utilization ?? 0,
|
|
3715
3917
|
resets_at: data.five_hour.resets_at ?? null,
|
|
3716
3918
|
};
|
|
3717
3919
|
}
|
|
3718
3920
|
if (data.seven_day) {
|
|
3719
3921
|
sessionMetrics.lastQuota.sevenDay = {
|
|
3922
|
+
...sessionMetrics.lastQuota.sevenDay,
|
|
3720
3923
|
utilization: data.seven_day.utilization ?? 0,
|
|
3721
3924
|
resets_at: data.seven_day.resets_at ?? null,
|
|
3722
3925
|
};
|
|
@@ -3864,10 +4067,10 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
|
|
|
3864
4067
|
* @param {string} bodyStr - JSON request body
|
|
3865
4068
|
* @returns {Map<string, string>} source_id → hash
|
|
3866
4069
|
*/
|
|
3867
|
-
function extractCacheSourceHashes(bodyStr) {
|
|
4070
|
+
function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
|
|
3868
4071
|
const hashes = new Map();
|
|
3869
4072
|
try {
|
|
3870
|
-
const parsed = JSON.parse(bodyStr);
|
|
4073
|
+
const parsed = parsedBody ?? JSON.parse(bodyStr);
|
|
3871
4074
|
|
|
3872
4075
|
// Hash system prompt (excluding token budget blocks injected by injectTokenBudgetBlock)
|
|
3873
4076
|
if (Array.isArray(parsed.system)) {
|
|
@@ -4120,48 +4323,59 @@ function estimatePromptTokens(bodyString) {
|
|
|
4120
4323
|
if (!bodyString || typeof bodyString !== "string") return 0;
|
|
4121
4324
|
try {
|
|
4122
4325
|
const parsed = JSON.parse(bodyString);
|
|
4123
|
-
|
|
4326
|
+
return estimatePromptTokensFromParsed(parsed);
|
|
4327
|
+
} catch {
|
|
4328
|
+
// Fallback: raw body length / 4 if JSON parsing fails
|
|
4329
|
+
return Math.ceil(bodyString.length / 4);
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4124
4332
|
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4333
|
+
/**
|
|
4334
|
+
* Estimate prompt tokens from an already-parsed request body object.
|
|
4335
|
+
* Avoids redundant JSON.parse when the caller already has the parsed object.
|
|
4336
|
+
* @param {object} parsed - The parsed request body
|
|
4337
|
+
* @returns {number} Estimated token count
|
|
4338
|
+
*/
|
|
4339
|
+
function estimatePromptTokensFromParsed(parsed) {
|
|
4340
|
+
if (!parsed || typeof parsed !== "object") return 0;
|
|
4341
|
+
let charCount = 0;
|
|
4342
|
+
|
|
4343
|
+
// Count system prompt text
|
|
4344
|
+
if (Array.isArray(parsed.system)) {
|
|
4345
|
+
for (const block of parsed.system) {
|
|
4346
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4347
|
+
charCount += block.text.length;
|
|
4131
4348
|
}
|
|
4132
|
-
} else if (typeof parsed.system === "string") {
|
|
4133
|
-
charCount += parsed.system.length;
|
|
4134
4349
|
}
|
|
4350
|
+
} else if (typeof parsed.system === "string") {
|
|
4351
|
+
charCount += parsed.system.length;
|
|
4352
|
+
}
|
|
4135
4353
|
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
}
|
|
4354
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
4355
|
+
if (Array.isArray(parsed.messages)) {
|
|
4356
|
+
for (const msg of parsed.messages) {
|
|
4357
|
+
if (typeof msg.content === "string") {
|
|
4358
|
+
charCount += msg.content.length;
|
|
4359
|
+
} else if (Array.isArray(msg.content)) {
|
|
4360
|
+
for (const block of msg.content) {
|
|
4361
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4362
|
+
charCount += block.text.length;
|
|
4363
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
4364
|
+
charCount += block.content.length;
|
|
4365
|
+
} else if (block.type === "tool_use") {
|
|
4366
|
+
// Count serialized input as tokens
|
|
4367
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
4368
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
4369
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
4370
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
4154
4371
|
}
|
|
4155
4372
|
}
|
|
4156
4373
|
}
|
|
4157
4374
|
}
|
|
4158
|
-
|
|
4159
|
-
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
4160
|
-
return Math.ceil(charCount / 4);
|
|
4161
|
-
} catch {
|
|
4162
|
-
// Fallback: raw body length / 4 if JSON parsing fails
|
|
4163
|
-
return Math.ceil(bodyString.length / 4);
|
|
4164
4375
|
}
|
|
4376
|
+
|
|
4377
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
4378
|
+
return Math.ceil(charCount / 4);
|
|
4165
4379
|
}
|
|
4166
4380
|
|
|
4167
4381
|
/**
|
|
@@ -4284,7 +4498,7 @@ function analyzeRequestContext(bodyStr) {
|
|
|
4284
4498
|
* @param {import('./lib/config.mjs').AdaptiveContextConfig} adaptiveConfig
|
|
4285
4499
|
* @returns {boolean}
|
|
4286
4500
|
*/
|
|
4287
|
-
function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
4501
|
+
function resolveAdaptiveContext(bodyString, model, adaptiveConfig, parsedBody) {
|
|
4288
4502
|
// Non-adaptive: use static check (only explicit "1m" models)
|
|
4289
4503
|
if (!adaptiveConfig.enabled) {
|
|
4290
4504
|
return hasOneMillionContext(model);
|
|
@@ -4300,7 +4514,7 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
4300
4514
|
return false;
|
|
4301
4515
|
}
|
|
4302
4516
|
|
|
4303
|
-
const estimatedTokens = estimatePromptTokens(bodyString);
|
|
4517
|
+
const estimatedTokens = parsedBody ? estimatePromptTokensFromParsed(parsedBody) : estimatePromptTokens(bodyString);
|
|
4304
4518
|
const turnsSinceTransition = sessionMetrics.turns - adaptiveContextState.lastTransitionTurn;
|
|
4305
4519
|
|
|
4306
4520
|
if (adaptiveContextState.active) {
|
|
@@ -4661,10 +4875,12 @@ const SESSION_START_TIME = Date.now();
|
|
|
4661
4875
|
const liveTokenRef = { token: "" };
|
|
4662
4876
|
|
|
4663
4877
|
// Best-effort exit telemetry (QA fix M10: use 'once' to prevent listener stacking on re-import)
|
|
4664
|
-
|
|
4878
|
+
// QA fix L-beforeExit: store handler reference for cleanup; prevents leaked refs to telemetryEmitter
|
|
4879
|
+
const _beforeExitHandler = () => {
|
|
4665
4880
|
const duration = Date.now() - SESSION_START_TIME;
|
|
4666
4881
|
telemetryEmitter.sendExitEvent(liveTokenRef.token, duration).catch(() => {});
|
|
4667
|
-
}
|
|
4882
|
+
};
|
|
4883
|
+
process.once("beforeExit", _beforeExitHandler);
|
|
4668
4884
|
|
|
4669
4885
|
// ---------------------------------------------------------------------------
|
|
4670
4886
|
// Request building helpers (extracted from original fetch interceptor)
|
|
@@ -5231,21 +5447,27 @@ function detectProvider(requestUrl) {
|
|
|
5231
5447
|
* @param {any} body
|
|
5232
5448
|
* @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
|
|
5233
5449
|
*/
|
|
5234
|
-
function parseRequestBodyMetadata(body) {
|
|
5235
|
-
|
|
5450
|
+
function parseRequestBodyMetadata(body, parsedBody) {
|
|
5451
|
+
const parsed =
|
|
5452
|
+
parsedBody ||
|
|
5453
|
+
(typeof body === "string"
|
|
5454
|
+
? (() => {
|
|
5455
|
+
try {
|
|
5456
|
+
return JSON.parse(body);
|
|
5457
|
+
} catch {
|
|
5458
|
+
return null;
|
|
5459
|
+
}
|
|
5460
|
+
})()
|
|
5461
|
+
: null);
|
|
5462
|
+
if (!parsed) {
|
|
5236
5463
|
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
5237
5464
|
}
|
|
5238
5465
|
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
const hasFileReferences = extractFileIds(parsed).length > 0;
|
|
5245
|
-
return { model, tools, messages, hasFileReferences };
|
|
5246
|
-
} catch {
|
|
5247
|
-
return { model: "", tools: [], messages: [], hasFileReferences: false };
|
|
5248
|
-
}
|
|
5466
|
+
const model = typeof parsed?.model === "string" ? parsed.model : "";
|
|
5467
|
+
const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
|
|
5468
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
5469
|
+
const hasFileReferences = extractFileIds(parsed).length > 0;
|
|
5470
|
+
return { model, tools, messages, hasFileReferences };
|
|
5249
5471
|
}
|
|
5250
5472
|
|
|
5251
5473
|
/**
|
|
@@ -5972,20 +6194,19 @@ function buildRequestHeaders(
|
|
|
5972
6194
|
);
|
|
5973
6195
|
// x-stainless-timeout: sent only for non-streaming requests.
|
|
5974
6196
|
// Claude Code sends 600 (10 minutes) as the default timeout in seconds.
|
|
5975
|
-
//
|
|
5976
|
-
let xStainlessTimeout = "600";
|
|
6197
|
+
// For streaming requests, the SDK omits this header entirely.
|
|
5977
6198
|
if (requestBody) {
|
|
5978
6199
|
try {
|
|
5979
6200
|
const parsed = JSON.parse(requestBody);
|
|
5980
|
-
|
|
5981
|
-
if (
|
|
5982
|
-
|
|
6201
|
+
// stream defaults to true in Claude Code; only explicitly false means non-streaming
|
|
6202
|
+
if (parsed.stream === false) {
|
|
6203
|
+
requestHeaders.set("x-stainless-timeout", "600");
|
|
5983
6204
|
}
|
|
6205
|
+
// Streaming requests: omit x-stainless-timeout (real SDK behavior)
|
|
5984
6206
|
} catch {
|
|
5985
|
-
// Non-JSON body or parse error —
|
|
6207
|
+
// Non-JSON body or parse error — omit header (safe default)
|
|
5986
6208
|
}
|
|
5987
6209
|
}
|
|
5988
|
-
requestHeaders.set("x-stainless-timeout", xStainlessTimeout);
|
|
5989
6210
|
const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
|
|
5990
6211
|
if (stainlessHelpers) {
|
|
5991
6212
|
requestHeaders.set("x-stainless-helper", stainlessHelpers);
|
|
@@ -6027,6 +6248,10 @@ function buildRequestHeaders(
|
|
|
6027
6248
|
function resolveMaxTokens(body, config) {
|
|
6028
6249
|
if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
|
|
6029
6250
|
if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
|
|
6251
|
+
// QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
|
|
6252
|
+
// completes. The timing works correctly for "escalate for one turn" because this function runs
|
|
6253
|
+
// BEFORE the next request's stream starts. If the response pipeline changes to update stop
|
|
6254
|
+
// reason mid-stream or before response completion, this ordering assumption would break.
|
|
6030
6255
|
const escalated = sessionMetrics.lastStopReason === "max_tokens";
|
|
6031
6256
|
const result = escalated
|
|
6032
6257
|
? (config.output_cap.escalated_max_tokens ?? 64_000)
|
|
@@ -6856,18 +7081,21 @@ function parseCommandArgs(raw) {
|
|
|
6856
7081
|
function extractFileIds(body) {
|
|
6857
7082
|
const ids = [];
|
|
6858
7083
|
if (!body || typeof body !== "object") return ids;
|
|
6859
|
-
|
|
7084
|
+
// QA fix L-depth: cap recursion depth to prevent stack overflow on pathological payloads
|
|
7085
|
+
const MAX_DEPTH = 20;
|
|
7086
|
+
function walk(obj, depth) {
|
|
7087
|
+
if (depth > MAX_DEPTH) return;
|
|
6860
7088
|
if (Array.isArray(obj)) {
|
|
6861
|
-
for (const item of obj) walk(item);
|
|
7089
|
+
for (const item of obj) walk(item, depth + 1);
|
|
6862
7090
|
} else if (obj && typeof obj === "object") {
|
|
6863
7091
|
if (obj.source?.file_id) ids.push(obj.source.file_id);
|
|
6864
7092
|
for (const val of Object.values(obj)) {
|
|
6865
|
-
if (val && typeof val === "object") walk(val);
|
|
7093
|
+
if (val && typeof val === "object") walk(val, depth + 1);
|
|
6866
7094
|
}
|
|
6867
7095
|
}
|
|
6868
7096
|
}
|
|
6869
|
-
walk(body.messages);
|
|
6870
|
-
walk(body.system);
|
|
7097
|
+
walk(body.messages, 0);
|
|
7098
|
+
walk(body.system, 0);
|
|
6871
7099
|
return ids;
|
|
6872
7100
|
}
|
|
6873
7101
|
|