opencode-anthropic-fix 0.0.39 → 0.0.42
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/README.md +72 -1
- package/index.mjs +1210 -66
- package/lib/backoff.mjs +8 -2
- package/lib/config.mjs +69 -7
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
parseRetryAfterHeader,
|
|
16
16
|
parseRetryAfterMsHeader,
|
|
17
17
|
parseShouldRetryHeader,
|
|
18
|
+
TRANSIENT_RETRY_THRESHOLD_MS,
|
|
18
19
|
} from "./lib/backoff.mjs";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -626,6 +627,37 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
626
627
|
|
|
627
628
|
// /anthropic stats — show enhanced session statistics
|
|
628
629
|
if (primary === "stats") {
|
|
630
|
+
// Handle reset subcommand
|
|
631
|
+
const secondary = (args[1] || "").toLowerCase();
|
|
632
|
+
if (secondary === "reset") {
|
|
633
|
+
sessionMetrics.turns = 0;
|
|
634
|
+
sessionMetrics.totalInput = 0;
|
|
635
|
+
sessionMetrics.totalOutput = 0;
|
|
636
|
+
sessionMetrics.totalCacheRead = 0;
|
|
637
|
+
sessionMetrics.totalCacheWrite = 0;
|
|
638
|
+
sessionMetrics.totalWebSearchRequests = 0;
|
|
639
|
+
sessionMetrics.recentCacheRates = [];
|
|
640
|
+
sessionMetrics.sessionCostUsd = 0;
|
|
641
|
+
sessionMetrics.costBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
642
|
+
sessionMetrics.sessionStartTime = Date.now();
|
|
643
|
+
sessionMetrics.lastQuota = {
|
|
644
|
+
tokens: 0,
|
|
645
|
+
requests: 0,
|
|
646
|
+
inputTokens: 0,
|
|
647
|
+
updatedAt: 0,
|
|
648
|
+
fiveHour: { utilization: 0, resets_at: null },
|
|
649
|
+
sevenDay: { utilization: 0, resets_at: null },
|
|
650
|
+
lastPollAt: 0,
|
|
651
|
+
};
|
|
652
|
+
sessionMetrics.lastStopReason = null;
|
|
653
|
+
sessionMetrics.perModel = {};
|
|
654
|
+
sessionMetrics.lastModelId = null;
|
|
655
|
+
sessionMetrics.lastRequestBody = null;
|
|
656
|
+
sessionMetrics.tokenBudget = { limit: 0, used: 0, continuations: 0, outputHistory: [] };
|
|
657
|
+
await sendCommandMessage(input.sessionID, "\u25a3 Anthropic\n\nStats reset.");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
629
661
|
const avgRate = getAverageCacheHitRate();
|
|
630
662
|
const totalTokens =
|
|
631
663
|
sessionMetrics.totalInput +
|
|
@@ -679,6 +711,17 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
679
711
|
lines.push(`Burn rate: $${(burnRate * 60).toFixed(2)}/hr`);
|
|
680
712
|
}
|
|
681
713
|
|
|
714
|
+
// Per-model breakdown (only show when multiple models used)
|
|
715
|
+
const modelIds = Object.keys(sessionMetrics.perModel);
|
|
716
|
+
if (modelIds.length > 1) {
|
|
717
|
+
lines.push("", "Per-model breakdown:");
|
|
718
|
+
for (const mid of modelIds) {
|
|
719
|
+
const pm = sessionMetrics.perModel[mid];
|
|
720
|
+
const totalTk = pm.input + pm.output + pm.cacheRead + pm.cacheWrite;
|
|
721
|
+
lines.push(` ${mid}: ${totalTk.toLocaleString()} tokens, $${pm.costUsd.toFixed(4)} (${pm.turns} turns)`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
682
725
|
const maxBudget = parseFloat(process.env.OPENCODE_ANTHROPIC_MAX_BUDGET_USD || "0");
|
|
683
726
|
if (maxBudget > 0) {
|
|
684
727
|
const pct = (sessionMetrics.sessionCostUsd / maxBudget) * 100;
|
|
@@ -705,6 +748,27 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
705
748
|
lines.push("", `Rate limit utilization: ${quotaParts.join(", ")}`);
|
|
706
749
|
}
|
|
707
750
|
|
|
751
|
+
// Usage endpoint data (A6)
|
|
752
|
+
if (sessionMetrics.lastQuota.lastPollAt > 0) {
|
|
753
|
+
const q5h = sessionMetrics.lastQuota.fiveHour;
|
|
754
|
+
const q7d = sessionMetrics.lastQuota.sevenDay;
|
|
755
|
+
const pollAge = Math.round((Date.now() - sessionMetrics.lastQuota.lastPollAt) / 60_000);
|
|
756
|
+
lines.push("", `Usage endpoint (${pollAge}m ago):`);
|
|
757
|
+
lines.push(` 5-hour: ${q5h.utilization.toFixed(0)}% used${q5h.resets_at ? ` (resets ${q5h.resets_at})` : ""}`);
|
|
758
|
+
lines.push(` 7-day: ${q7d.utilization.toFixed(0)}% used${q7d.resets_at ? ` (resets ${q7d.resets_at})` : ""}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Token budget display (A9)
|
|
762
|
+
const tb = sessionMetrics.tokenBudget;
|
|
763
|
+
if (tb.limit > 0) {
|
|
764
|
+
const pct = ((tb.used / tb.limit) * 100).toFixed(0);
|
|
765
|
+
lines.push("", `Token budget: ${tb.used.toLocaleString()} / ${tb.limit.toLocaleString()} (${pct}%)`);
|
|
766
|
+
lines.push(` Continuations: ${tb.continuations}`);
|
|
767
|
+
if (detectDiminishingReturns(tb.outputHistory)) {
|
|
768
|
+
lines.push(` Warning: Diminishing returns detected (last 3 outputs < 500 tokens)`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
708
772
|
await sendCommandMessage(input.sessionID, lines.join("\n"));
|
|
709
773
|
return;
|
|
710
774
|
}
|
|
@@ -744,6 +808,49 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
744
808
|
return;
|
|
745
809
|
}
|
|
746
810
|
|
|
811
|
+
// /anthropic context — show token breakdown of last request
|
|
812
|
+
if (primary === "context") {
|
|
813
|
+
if (!sessionMetrics.lastRequestBody) {
|
|
814
|
+
await sendCommandMessage(
|
|
815
|
+
input.sessionID,
|
|
816
|
+
"▣ Anthropic Context\n\nNo request captured yet. Make at least one API request first.",
|
|
817
|
+
);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const analysis = analyzeRequestContext(sessionMetrics.lastRequestBody);
|
|
822
|
+
const lines = [
|
|
823
|
+
"▣ Anthropic Context Breakdown (estimated)",
|
|
824
|
+
"",
|
|
825
|
+
`System: ${analysis.systemTokens.toLocaleString()} tokens`,
|
|
826
|
+
`User messages: ${analysis.userTokens.toLocaleString()} tokens`,
|
|
827
|
+
];
|
|
828
|
+
|
|
829
|
+
if (analysis.toolResultTokens > 0) {
|
|
830
|
+
lines.push(` tool_result: ${analysis.toolResultTokens.toLocaleString()} tokens`);
|
|
831
|
+
const toolNames = Object.keys(analysis.toolBreakdown).sort(
|
|
832
|
+
(a, b) => analysis.toolBreakdown[b].tokens - analysis.toolBreakdown[a].tokens,
|
|
833
|
+
);
|
|
834
|
+
for (const name of toolNames) {
|
|
835
|
+
const tb = analysis.toolBreakdown[name];
|
|
836
|
+
lines.push(` ${name}: ${tb.tokens.toLocaleString()} tokens (${tb.count} blocks)`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
lines.push(`Assistant: ${analysis.assistantTokens.toLocaleString()} tokens`);
|
|
841
|
+
lines.push(`Total: ${analysis.totalTokens.toLocaleString()} tokens`);
|
|
842
|
+
|
|
843
|
+
if (analysis.duplicates.count > 0) {
|
|
844
|
+
lines.push(
|
|
845
|
+
"",
|
|
846
|
+
`\u26a0 ${analysis.duplicates.count} duplicate file contents detected (~${analysis.duplicates.wastedTokens.toLocaleString()} tokens wasted)`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
await sendCommandMessage(input.sessionID, lines.join("\n"));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
747
854
|
// /anthropic accounts — show per-account stats and health
|
|
748
855
|
if (primary === "accounts") {
|
|
749
856
|
if (!accountManager || accountManager.getAccountCount() === 0) {
|
|
@@ -2180,6 +2287,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2180
2287
|
sessionId: signatureSessionId,
|
|
2181
2288
|
});
|
|
2182
2289
|
|
|
2290
|
+
// Pre-warm TCP+TLS connection to Anthropic API (fire-and-forget)
|
|
2291
|
+
preconnectApi(config);
|
|
2292
|
+
|
|
2183
2293
|
return {
|
|
2184
2294
|
apiKey: "",
|
|
2185
2295
|
/**
|
|
@@ -2272,8 +2382,18 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2272
2382
|
let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
|
|
2273
2383
|
let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
|
|
2274
2384
|
let consecutive529Count = 0;
|
|
2385
|
+
// Classify request for retry budget (A8)
|
|
2386
|
+
const requestClass =
|
|
2387
|
+
config.request_classification?.enabled !== false ? classifyApiRequest(requestInit.body) : "foreground";
|
|
2388
|
+
const maxServiceRetries =
|
|
2389
|
+
requestClass === "background"
|
|
2390
|
+
? (config.request_classification?.background_max_service_retries ?? 0)
|
|
2391
|
+
: 2;
|
|
2392
|
+
const maxShouldRetries =
|
|
2393
|
+
requestClass === "background" ? (config.request_classification?.background_max_should_retries ?? 1) : 3;
|
|
2275
2394
|
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
2276
2395
|
let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
|
|
2396
|
+
let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
|
|
2277
2397
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2278
2398
|
// Select account — use pinned account on first attempt if available
|
|
2279
2399
|
const account =
|
|
@@ -2455,6 +2575,25 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2455
2575
|
// Token economy config (resolved once, passed to beta builder)
|
|
2456
2576
|
const _tokenEconomy = config.token_economy || {};
|
|
2457
2577
|
|
|
2578
|
+
// Microcompact: inject clear betas at high context utilization
|
|
2579
|
+
let _microcompactBetas = null;
|
|
2580
|
+
if (requestInit.body) {
|
|
2581
|
+
const estimatedTokens = estimatePromptTokens(requestInit.body);
|
|
2582
|
+
if (shouldMicrocompact(estimatedTokens, config)) {
|
|
2583
|
+
_microcompactBetas = buildMicrocompactBetas();
|
|
2584
|
+
if (!microcompactState.active) {
|
|
2585
|
+
microcompactState.active = true;
|
|
2586
|
+
microcompactState.lastActivatedTurn = sessionMetrics.turns;
|
|
2587
|
+
toast(`Microcompact activated at ~${Math.round(estimatedTokens / 1000)}K tokens`, "info", {
|
|
2588
|
+
debounceKey: "microcompact",
|
|
2589
|
+
}).catch(() => {});
|
|
2590
|
+
}
|
|
2591
|
+
} else if (microcompactState.active) {
|
|
2592
|
+
// Deactivate if tokens dropped below threshold
|
|
2593
|
+
microcompactState.active = false;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2458
2597
|
let computedBetaHeader = buildAnthropicBetaHeader(
|
|
2459
2598
|
"",
|
|
2460
2599
|
getSignatureEmulationEnabled(),
|
|
@@ -2466,6 +2605,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2466
2605
|
_reqHasFileRefs,
|
|
2467
2606
|
_adaptiveOverride,
|
|
2468
2607
|
_tokenEconomy,
|
|
2608
|
+
_microcompactBetas, // NEW
|
|
2469
2609
|
);
|
|
2470
2610
|
|
|
2471
2611
|
// Beta header latching: once a beta has been sent in this session,
|
|
@@ -2519,9 +2659,25 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2519
2659
|
accountId: getAccountIdentifier(account),
|
|
2520
2660
|
},
|
|
2521
2661
|
computedBetaHeader,
|
|
2662
|
+
config,
|
|
2522
2663
|
);
|
|
2523
2664
|
logTransformedSystemPrompt(body);
|
|
2524
2665
|
|
|
2666
|
+
// Capture request body for /anthropic context (2MB cap)
|
|
2667
|
+
if (typeof body === "string" && body.length <= 2_000_000) {
|
|
2668
|
+
sessionMetrics.lastRequestBody = body;
|
|
2669
|
+
} else if (typeof body === "string") {
|
|
2670
|
+
sessionMetrics.lastRequestBody = body.slice(0, 2_000_000);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// Pre-call: extract cache source hashes for cache break detection
|
|
2674
|
+
if (config.cache_break_detection?.enabled && typeof body === "string") {
|
|
2675
|
+
const currentHashes = extractCacheSourceHashes(body);
|
|
2676
|
+
if (currentHashes.size > 0) {
|
|
2677
|
+
cacheBreakState._pendingHashes = currentHashes;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2525
2681
|
// Build headers with the selected account's token
|
|
2526
2682
|
const requestHeaders = buildRequestHeaders(
|
|
2527
2683
|
input,
|
|
@@ -2720,6 +2876,45 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2720
2876
|
!requestInit._reactiveCompactAttempted
|
|
2721
2877
|
) {
|
|
2722
2878
|
debugLog("prompt too long — attempting reactive message trimming");
|
|
2879
|
+
|
|
2880
|
+
// --- Overflow recovery: parse structured error and reduce max_tokens ---
|
|
2881
|
+
// This is attempted BEFORE message trimming. If we can parse the exact
|
|
2882
|
+
// numbers from the error, we reduce max_tokens to fit and retry without
|
|
2883
|
+
// losing any conversation history.
|
|
2884
|
+
if (config.overflow_recovery?.enabled && !requestInit._overflowRecoveryAttempted) {
|
|
2885
|
+
const overflow = parseContextLimitError(errorBody);
|
|
2886
|
+
if (overflow) {
|
|
2887
|
+
const margin = config.overflow_recovery.safety_margin ?? 1_000;
|
|
2888
|
+
const safeMaxTokens = computeSafeMaxTokens(overflow.input, overflow.limit, margin);
|
|
2889
|
+
if (safeMaxTokens > 0) {
|
|
2890
|
+
debugLog("overflow recovery: reducing max_tokens", {
|
|
2891
|
+
original: overflow.maxTokens,
|
|
2892
|
+
safe: safeMaxTokens,
|
|
2893
|
+
input: overflow.input,
|
|
2894
|
+
limit: overflow.limit,
|
|
2895
|
+
margin,
|
|
2896
|
+
});
|
|
2897
|
+
try {
|
|
2898
|
+
const recoveryBody = JSON.parse(body);
|
|
2899
|
+
recoveryBody.max_tokens = safeMaxTokens;
|
|
2900
|
+
requestInit.body = JSON.stringify(recoveryBody);
|
|
2901
|
+
// Update `body` variable for subsequent iterations
|
|
2902
|
+
body = requestInit.body;
|
|
2903
|
+
requestInit._overflowRecoveryAttempted = true;
|
|
2904
|
+
attempt--;
|
|
2905
|
+
toast(
|
|
2906
|
+
`Context overflow: reduced max_tokens ${overflow.maxTokens.toLocaleString()} → ${safeMaxTokens.toLocaleString()}`,
|
|
2907
|
+
"warning",
|
|
2908
|
+
{ debounceKey: "overflow-recovery" },
|
|
2909
|
+
).catch(() => {});
|
|
2910
|
+
continue;
|
|
2911
|
+
} catch {
|
|
2912
|
+
// Body parse failed, fall through to message trimming
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2723
2918
|
// Auto-escalate adaptive context on prompt_too_long so the retry
|
|
2724
2919
|
// includes the 1M beta header (if model supports it).
|
|
2725
2920
|
if (config.adaptive_context?.enabled) {
|
|
@@ -2778,7 +2973,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2778
2973
|
},
|
|
2779
2974
|
...tail,
|
|
2780
2975
|
];
|
|
2781
|
-
|
|
2976
|
+
// Repair any orphaned tool_use blocks created by the trim boundary
|
|
2977
|
+
// (e.g. first 2 messages include an assistant tool_use whose
|
|
2978
|
+
// tool_result was in a trimmed middle message).
|
|
2979
|
+
parsedBody.messages = repairOrphanedToolUseBlocks(trimmed);
|
|
2782
2980
|
requestInit.body = JSON.stringify(parsedBody);
|
|
2783
2981
|
requestInit._reactiveCompactAttempted = true;
|
|
2784
2982
|
// Retry with trimmed messages (decrement attempt to not consume account slot)
|
|
@@ -2826,8 +3024,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2826
3024
|
const accountSpecific = isAccountSpecificError(response.status, errorBody);
|
|
2827
3025
|
|
|
2828
3026
|
// x-should-retry: true forces a retry for service-wide errors (RE doc §5.5)
|
|
2829
|
-
// Capped at
|
|
2830
|
-
if (shouldRetry === true && !accountSpecific && shouldRetryCount <
|
|
3027
|
+
// Capped at maxShouldRetries to prevent infinite loops (QA fix C1)
|
|
3028
|
+
if (shouldRetry === true && !accountSpecific && shouldRetryCount < maxShouldRetries) {
|
|
2831
3029
|
shouldRetryCount++;
|
|
2832
3030
|
const retryDelay = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response) ?? 2000;
|
|
2833
3031
|
debugLog("x-should-retry: true on service-wide error, sleeping before retry", {
|
|
@@ -2845,6 +3043,27 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2845
3043
|
if (accountSpecific) {
|
|
2846
3044
|
const reason = parseRateLimitReason(response.status, errorBody);
|
|
2847
3045
|
const retryAfterMs = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response);
|
|
3046
|
+
|
|
3047
|
+
// Transient 429: short retry-after (<=10s) is a burst throttle.
|
|
3048
|
+
// Retry on the SAME account instead of rotating — avoids wasting
|
|
3049
|
+
// the account pool on momentary rate spikes.
|
|
3050
|
+
if (
|
|
3051
|
+
response.status === 429 &&
|
|
3052
|
+
reason === "RATE_LIMIT_EXCEEDED" &&
|
|
3053
|
+
retryAfterMs != null &&
|
|
3054
|
+
retryAfterMs > 0 &&
|
|
3055
|
+
retryAfterMs <= TRANSIENT_RETRY_THRESHOLD_MS
|
|
3056
|
+
) {
|
|
3057
|
+
debugLog("transient 429: sleeping before same-account retry", {
|
|
3058
|
+
retryAfterMs,
|
|
3059
|
+
account: account.email || `Account ${account.index + 1}`,
|
|
3060
|
+
});
|
|
3061
|
+
await new Promise((r) => setTimeout(r, retryAfterMs));
|
|
3062
|
+
// Decrement attempt so this transient retry doesn't consume an account slot
|
|
3063
|
+
attempt--;
|
|
3064
|
+
continue;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
2848
3067
|
accountManager.markRateLimited(account, reason, retryAfterMs);
|
|
2849
3068
|
|
|
2850
3069
|
// On auth failures, clear token so next selection forces refresh
|
|
@@ -2882,8 +3101,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2882
3101
|
}
|
|
2883
3102
|
|
|
2884
3103
|
// 529 (overloaded) and 503 (service unavailable) — brief sleep-and-retry
|
|
2885
|
-
// per RE doc
|
|
2886
|
-
if (
|
|
3104
|
+
// per RE doc u00a75.5 (Stainless SDK retries 500+ codes up to maxServiceRetries times)
|
|
3105
|
+
if (
|
|
3106
|
+
(response.status === 529 || response.status === 503) &&
|
|
3107
|
+
serviceWideRetryCount < maxServiceRetries
|
|
3108
|
+
) {
|
|
2887
3109
|
serviceWideRetryCount++;
|
|
2888
3110
|
|
|
2889
3111
|
// Track consecutive 529s for model fallback
|
|
@@ -2925,21 +3147,71 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2925
3147
|
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 8);
|
|
2926
3148
|
const jitter = 1 - Math.random() * 0.25;
|
|
2927
3149
|
const sleepMs = Math.round(baseDelay * jitter * 1000);
|
|
2928
|
-
|
|
3150
|
+
const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
|
|
3151
|
+
debugLog(`service-wide ${retryLabel} error, sleeping before retry`, {
|
|
2929
3152
|
status: response.status,
|
|
2930
3153
|
attempt: serviceWideRetryCount,
|
|
3154
|
+
maxRetries: maxServiceRetries,
|
|
2931
3155
|
sleepMs,
|
|
2932
3156
|
});
|
|
3157
|
+
toast(
|
|
3158
|
+
`API ${retryLabel} (${response.status}): retry ${serviceWideRetryCount}/${maxServiceRetries} in ${(sleepMs / 1000).toFixed(1)}s`,
|
|
3159
|
+
"warning",
|
|
3160
|
+
{ debounceKey: "service-retry" },
|
|
3161
|
+
).catch(() => {});
|
|
2933
3162
|
await new Promise((r) => setTimeout(r, sleepMs));
|
|
2934
3163
|
// Decrement attempt so this retry doesn't consume an account slot
|
|
2935
3164
|
attempt--;
|
|
2936
3165
|
continue;
|
|
2937
3166
|
}
|
|
2938
3167
|
|
|
2939
|
-
// Non-retryable service-wide error —
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
3168
|
+
// Non-retryable service-wide error — attempt quota-aware account switch for 529
|
|
3169
|
+
if (
|
|
3170
|
+
response.status === 529 &&
|
|
3171
|
+
accountManager &&
|
|
3172
|
+
account &&
|
|
3173
|
+
config.overload_recovery?.enabled !== false &&
|
|
3174
|
+
!_overloadRecoveryAttempted
|
|
3175
|
+
) {
|
|
3176
|
+
_overloadRecoveryAttempted = true;
|
|
3177
|
+
const recovery = tryQuotaAwareAccountSwitch(account, accountManager, config);
|
|
3178
|
+
if (recovery.switched && recovery.nextAccount) {
|
|
3179
|
+
// Fire-and-forget: poll quota for the overloaded account in background
|
|
3180
|
+
if (config.overload_recovery?.poll_quota_on_overload && account?.access) {
|
|
3181
|
+
pollOAuthUsage(config, account.access).catch(() => {});
|
|
3182
|
+
}
|
|
3183
|
+
const fromName = account.email || `Account ${account.index + 1}`;
|
|
3184
|
+
const toName = recovery.nextAccount.email || `Account ${recovery.nextAccount.index + 1}`;
|
|
3185
|
+
const cooldownMin = Math.ceil(recovery.cooldownMs / 60_000);
|
|
3186
|
+
toast(`529 overloaded: ${fromName} → ${toName} (cooldown ${cooldownMin}m)`, "warning", {
|
|
3187
|
+
debounceKey: "overload-switch",
|
|
3188
|
+
}).catch(() => {});
|
|
3189
|
+
debugLog("overload recovery: retrying with new account", {
|
|
3190
|
+
from: account.index,
|
|
3191
|
+
to: recovery.nextAccount.index,
|
|
3192
|
+
cooldownMs: recovery.cooldownMs,
|
|
3193
|
+
});
|
|
3194
|
+
// Don't consume an attempt slot — this is a recovery switch
|
|
3195
|
+
attempt--;
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// Could not switch — build comprehensive error message and toast
|
|
3200
|
+
const errorMsg = buildOverloadErrorMessage(
|
|
3201
|
+
account,
|
|
3202
|
+
accountManager,
|
|
3203
|
+
serviceWideRetryCount,
|
|
3204
|
+
maxServiceRetries,
|
|
3205
|
+
);
|
|
3206
|
+
toast(errorMsg, "error", { debounceKey: "overload-exhausted" }).catch(() => {});
|
|
3207
|
+
debugLog("overload recovery: all accounts exhausted", {
|
|
3208
|
+
errorMsg,
|
|
3209
|
+
});
|
|
3210
|
+
} else {
|
|
3211
|
+
debugLog("service-wide response error, returning directly", {
|
|
3212
|
+
status: response.status,
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
2943
3215
|
return transformResponse(response);
|
|
2944
3216
|
}
|
|
2945
3217
|
|
|
@@ -3016,6 +3288,86 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3016
3288
|
() => {},
|
|
3017
3289
|
);
|
|
3018
3290
|
}
|
|
3291
|
+
|
|
3292
|
+
// Cache break detection (post-call)
|
|
3293
|
+
if (config.cache_break_detection?.enabled) {
|
|
3294
|
+
const cacheRead = usage.cacheReadTokens || 0;
|
|
3295
|
+
const threshold = config.cache_break_detection.alert_threshold ?? 2_000;
|
|
3296
|
+
|
|
3297
|
+
// Only alert after the first turn (need a baseline)
|
|
3298
|
+
if (
|
|
3299
|
+
cacheBreakState.prevCacheRead > 0 &&
|
|
3300
|
+
cacheBreakState.prevCacheRead - cacheRead > threshold &&
|
|
3301
|
+
cacheBreakState.lastAlertTurn !== sessionMetrics.turns
|
|
3302
|
+
) {
|
|
3303
|
+
const drop = cacheBreakState.prevCacheRead - cacheRead;
|
|
3304
|
+
let alertMsg = `Cache break detected (u2212${drop.toLocaleString()} tokens)`;
|
|
3305
|
+
|
|
3306
|
+
// Identify changed sources if we have pending hashes
|
|
3307
|
+
if (cacheBreakState._pendingHashes && cacheBreakState.sourceHashes.size > 0) {
|
|
3308
|
+
const changedSources = detectCacheBreakSources(
|
|
3309
|
+
cacheBreakState._pendingHashes,
|
|
3310
|
+
cacheBreakState.sourceHashes,
|
|
3311
|
+
);
|
|
3312
|
+
if (changedSources.length > 0) {
|
|
3313
|
+
alertMsg += `: ${changedSources.join(", ")} changed`;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
toast(alertMsg, "warning", { debounceKey: "cache-break" }).catch(() => {});
|
|
3318
|
+
cacheBreakState.lastAlertTurn = sessionMetrics.turns;
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
cacheBreakState.prevCacheRead = cacheRead;
|
|
3322
|
+
// Store current hashes as baseline for next comparison
|
|
3323
|
+
if (cacheBreakState._pendingHashes) {
|
|
3324
|
+
cacheBreakState.sourceHashes = cacheBreakState._pendingHashes;
|
|
3325
|
+
delete cacheBreakState._pendingHashes;
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
// Rate limit awareness: periodic usage endpoint polling (A6)
|
|
3330
|
+
const shouldPollUsage =
|
|
3331
|
+
sessionMetrics.turns % 10 === 0 ||
|
|
3332
|
+
Date.now() - sessionMetrics.lastQuota.lastPollAt > 5 * 60_000;
|
|
3333
|
+
if (shouldPollUsage && accessToken) {
|
|
3334
|
+
pollOAuthUsage(config, accessToken)
|
|
3335
|
+
.then(() => {
|
|
3336
|
+
// Check warning levels after poll
|
|
3337
|
+
const level5h = computeQuotaWarningLevel(sessionMetrics.lastQuota.fiveHour);
|
|
3338
|
+
const level7d = computeQuotaWarningLevel(sessionMetrics.lastQuota.sevenDay);
|
|
3339
|
+
const highestLevel =
|
|
3340
|
+
level5h === "danger" || level7d === "danger"
|
|
3341
|
+
? "danger"
|
|
3342
|
+
: level5h === "warning" || level7d === "warning"
|
|
3343
|
+
? "warning"
|
|
3344
|
+
: level5h === "caution" || level7d === "caution"
|
|
3345
|
+
? "caution"
|
|
3346
|
+
: null;
|
|
3347
|
+
|
|
3348
|
+
if (highestLevel === "danger") {
|
|
3349
|
+
toast(
|
|
3350
|
+
`Usage limit: \u226425% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3351
|
+
"warning",
|
|
3352
|
+
{ debounceKey: "usage-danger" },
|
|
3353
|
+
).catch(() => {});
|
|
3354
|
+
} else if (highestLevel === "warning") {
|
|
3355
|
+
toast(
|
|
3356
|
+
`Usage limit: \u226450% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3357
|
+
"warning",
|
|
3358
|
+
{ debounceKey: "usage-warning" },
|
|
3359
|
+
).catch(() => {});
|
|
3360
|
+
} else if (highestLevel === "caution" && !quotaWarningState.cautionShown) {
|
|
3361
|
+
quotaWarningState.cautionShown = true;
|
|
3362
|
+
toast(
|
|
3363
|
+
`Usage limit: \u226475% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3364
|
+
"info",
|
|
3365
|
+
{ debounceKey: "usage-caution" },
|
|
3366
|
+
).catch(() => {});
|
|
3367
|
+
}
|
|
3368
|
+
})
|
|
3369
|
+
.catch(() => {});
|
|
3370
|
+
}
|
|
3019
3371
|
}
|
|
3020
3372
|
: null;
|
|
3021
3373
|
|
|
@@ -3178,6 +3530,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3178
3530
|
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
3179
3531
|
adaptiveContextState.escalatedByError = false;
|
|
3180
3532
|
|
|
3533
|
+
// Reset cache break detection state on compaction
|
|
3534
|
+
cacheBreakState.prevCacheRead = 0;
|
|
3535
|
+
cacheBreakState.sourceHashes = new Map();
|
|
3536
|
+
cacheBreakState.lastAlertTurn = 0;
|
|
3537
|
+
|
|
3538
|
+
microcompactState.active = false;
|
|
3539
|
+
microcompactState.lastActivatedTurn = 0;
|
|
3540
|
+
|
|
3181
3541
|
// Inject Anthropic-specific context into compaction
|
|
3182
3542
|
if (!accountManager) return;
|
|
3183
3543
|
const account = accountManager.getCurrentAccount();
|
|
@@ -3206,7 +3566,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3206
3566
|
// Session-level cache & cost tracking (Phase 4)
|
|
3207
3567
|
// ---------------------------------------------------------------------------
|
|
3208
3568
|
|
|
3209
|
-
/** @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}}} */
|
|
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[]}}} */
|
|
3210
3570
|
const sessionMetrics = {
|
|
3211
3571
|
turns: 0,
|
|
3212
3572
|
totalInput: 0,
|
|
@@ -3218,7 +3578,27 @@ const sessionMetrics = {
|
|
|
3218
3578
|
sessionCostUsd: 0,
|
|
3219
3579
|
costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
3220
3580
|
sessionStartTime: Date.now(),
|
|
3221
|
-
lastQuota: {
|
|
3581
|
+
lastQuota: {
|
|
3582
|
+
tokens: 0,
|
|
3583
|
+
requests: 0,
|
|
3584
|
+
inputTokens: 0,
|
|
3585
|
+
updatedAt: 0,
|
|
3586
|
+
// Usage endpoint polling (A6)
|
|
3587
|
+
fiveHour: { utilization: 0, resets_at: null },
|
|
3588
|
+
sevenDay: { utilization: 0, resets_at: null },
|
|
3589
|
+
lastPollAt: 0,
|
|
3590
|
+
},
|
|
3591
|
+
lastStopReason: null, // tracks most recent stop_reason for output cap escalation
|
|
3592
|
+
perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
|
|
3593
|
+
lastModelId: null,
|
|
3594
|
+
lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
|
|
3595
|
+
/** Token budget tracking (A9) */
|
|
3596
|
+
tokenBudget: {
|
|
3597
|
+
limit: 0, // 0 = unset
|
|
3598
|
+
used: 0, // accumulated output tokens
|
|
3599
|
+
continuations: 0,
|
|
3600
|
+
outputHistory: [], // last 5 output token deltas
|
|
3601
|
+
},
|
|
3222
3602
|
};
|
|
3223
3603
|
|
|
3224
3604
|
// ---------------------------------------------------------------------------
|
|
@@ -3241,6 +3621,495 @@ const adaptiveContextState = {
|
|
|
3241
3621
|
escalatedByError: false,
|
|
3242
3622
|
};
|
|
3243
3623
|
|
|
3624
|
+
// ---------------------------------------------------------------------------
|
|
3625
|
+
// Cache break detection state (Phase 2, Task 2.3)
|
|
3626
|
+
// ---------------------------------------------------------------------------
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* Tracks cache source hashes and previous cache_read_input_tokens to detect
|
|
3630
|
+
* cache breaks (e.g. system prompt or tool schema changes).
|
|
3631
|
+
*
|
|
3632
|
+
* @type {{ prevCacheRead: number, sourceHashes: Map<string, string>, lastAlertTurn: number }}
|
|
3633
|
+
*/
|
|
3634
|
+
const cacheBreakState = {
|
|
3635
|
+
prevCacheRead: 0,
|
|
3636
|
+
sourceHashes: new Map(),
|
|
3637
|
+
lastAlertTurn: 0,
|
|
3638
|
+
};
|
|
3639
|
+
|
|
3640
|
+
// ---------------------------------------------------------------------------
|
|
3641
|
+
// Microcompact state (Phase 3, Task 3.4)
|
|
3642
|
+
// ---------------------------------------------------------------------------
|
|
3643
|
+
|
|
3644
|
+
/**
|
|
3645
|
+
* Tracks whether microcompact betas are currently active.
|
|
3646
|
+
* @type {{ active: boolean, lastActivatedTurn: number }}
|
|
3647
|
+
*/
|
|
3648
|
+
const microcompactState = {
|
|
3649
|
+
active: false,
|
|
3650
|
+
lastActivatedTurn: 0,
|
|
3651
|
+
};
|
|
3652
|
+
|
|
3653
|
+
/**
|
|
3654
|
+
* Determine if microcompact betas should be injected based on estimated token usage.
|
|
3655
|
+
* @param {number} estimatedTokens - Estimated prompt token count
|
|
3656
|
+
* @param {object} config - Plugin config
|
|
3657
|
+
* @returns {boolean}
|
|
3658
|
+
*/
|
|
3659
|
+
function shouldMicrocompact(estimatedTokens, config) {
|
|
3660
|
+
if (!config.microcompact?.enabled) return false;
|
|
3661
|
+
const thresholdPct = config.microcompact.threshold_percent ?? 80;
|
|
3662
|
+
// Use the model's context window. Default to 200K if unknown.
|
|
3663
|
+
// Adaptive context may escalate to 1M, but we use the base 200K for threshold
|
|
3664
|
+
// to be conservative (microcompact at 160K tokens is still valuable).
|
|
3665
|
+
const contextWindow = 200_000;
|
|
3666
|
+
const threshold = contextWindow * (thresholdPct / 100);
|
|
3667
|
+
return estimatedTokens >= threshold;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/**
|
|
3671
|
+
* Build the list of microcompact betas to inject.
|
|
3672
|
+
* @returns {string[]} Array of beta flag strings
|
|
3673
|
+
*/
|
|
3674
|
+
function buildMicrocompactBetas() {
|
|
3675
|
+
return ["clear_tool_uses_20250919", "clear_thinking_20251015"];
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
/**
|
|
3679
|
+
* Hash a string for cache source fingerprinting.
|
|
3680
|
+
* @param {string} content
|
|
3681
|
+
* @returns {string} 16-char hex hash
|
|
3682
|
+
*/
|
|
3683
|
+
function hashCacheSource(content) {
|
|
3684
|
+
return createHashCrypto("sha256").update(content).digest("hex").slice(0, 16);
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
// ---------------------------------------------------------------------------
|
|
3688
|
+
// OAuth usage endpoint polling (A6)
|
|
3689
|
+
// ---------------------------------------------------------------------------
|
|
3690
|
+
|
|
3691
|
+
/**
|
|
3692
|
+
* Poll the /api/oauth/usage endpoint for session/weekly utilization.
|
|
3693
|
+
* Fire-and-forget: non-2xx responses are silently ignored.
|
|
3694
|
+
* @param {object} config
|
|
3695
|
+
* @param {string} accessToken
|
|
3696
|
+
*/
|
|
3697
|
+
async function pollOAuthUsage(config, accessToken) {
|
|
3698
|
+
try {
|
|
3699
|
+
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
3700
|
+
method: "GET",
|
|
3701
|
+
headers: {
|
|
3702
|
+
authorization: `Bearer ${accessToken}`,
|
|
3703
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
3704
|
+
accept: "application/json",
|
|
3705
|
+
},
|
|
3706
|
+
signal: AbortSignal.timeout(5_000),
|
|
3707
|
+
});
|
|
3708
|
+
if (!resp.ok) {
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
const data = await resp.json();
|
|
3712
|
+
if (data.five_hour) {
|
|
3713
|
+
sessionMetrics.lastQuota.fiveHour = {
|
|
3714
|
+
utilization: data.five_hour.utilization ?? 0,
|
|
3715
|
+
resets_at: data.five_hour.resets_at ?? null,
|
|
3716
|
+
};
|
|
3717
|
+
}
|
|
3718
|
+
if (data.seven_day) {
|
|
3719
|
+
sessionMetrics.lastQuota.sevenDay = {
|
|
3720
|
+
utilization: data.seven_day.utilization ?? 0,
|
|
3721
|
+
resets_at: data.seven_day.resets_at ?? null,
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
sessionMetrics.lastQuota.lastPollAt = Date.now();
|
|
3725
|
+
} catch {
|
|
3726
|
+
// Polling is fire-and-forget; errors are silently swallowed.
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
/** @type {{ cautionShown: boolean }} */
|
|
3731
|
+
const quotaWarningState = { cautionShown: false };
|
|
3732
|
+
|
|
3733
|
+
/**
|
|
3734
|
+
* Compute warning level based on utilization percentage.
|
|
3735
|
+
* @param {{ utilization: number }} quota - utilization is 0-100
|
|
3736
|
+
* @returns {"danger" | "warning" | "caution" | null}
|
|
3737
|
+
*/
|
|
3738
|
+
function computeQuotaWarningLevel(quota) {
|
|
3739
|
+
if (!quota || typeof quota.utilization !== "number") return null;
|
|
3740
|
+
const remaining = 100 - quota.utilization;
|
|
3741
|
+
if (remaining <= 25) return "danger";
|
|
3742
|
+
if (remaining <= 50) return "warning";
|
|
3743
|
+
if (remaining <= 75) return "caution";
|
|
3744
|
+
return null;
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
// ---------------------------------------------------------------------------
|
|
3748
|
+
// Overload recovery: quota-aware account switching on 529 exhaustion (3.5)
|
|
3749
|
+
// ---------------------------------------------------------------------------
|
|
3750
|
+
|
|
3751
|
+
/**
|
|
3752
|
+
* Build a comprehensive error message for 529/overloaded errors.
|
|
3753
|
+
* Includes quota info, account status, and reset times.
|
|
3754
|
+
*
|
|
3755
|
+
* @param {object} account - Current account
|
|
3756
|
+
* @param {object} accountManager - Account manager instance
|
|
3757
|
+
* @param {number} serviceWideRetryCount - How many 529 retries were attempted
|
|
3758
|
+
* @param {number} maxServiceRetries - Max allowed retries
|
|
3759
|
+
* @returns {string}
|
|
3760
|
+
*/
|
|
3761
|
+
function buildOverloadErrorMessage(account, accountManager, serviceWideRetryCount, maxServiceRetries) {
|
|
3762
|
+
const accountName = account?.email || `Account ${(account?.index ?? 0) + 1}`;
|
|
3763
|
+
const totalAccounts = accountManager?.getAccountCount() ?? 1;
|
|
3764
|
+
const parts = [
|
|
3765
|
+
`Anthropic API overloaded (529).`,
|
|
3766
|
+
`Retried ${serviceWideRetryCount}/${maxServiceRetries} times on ${accountName}.`,
|
|
3767
|
+
];
|
|
3768
|
+
|
|
3769
|
+
// Add quota information if available
|
|
3770
|
+
const fh = sessionMetrics.lastQuota.fiveHour;
|
|
3771
|
+
const sd = sessionMetrics.lastQuota.sevenDay;
|
|
3772
|
+
if (fh?.utilization > 0 || sd?.utilization > 0) {
|
|
3773
|
+
parts.push(
|
|
3774
|
+
`Quota: 5h=${fh?.utilization?.toFixed(0) ?? "?"}%` +
|
|
3775
|
+
(fh?.resets_at ? ` (resets ${formatResetTime(fh.resets_at)})` : "") +
|
|
3776
|
+
`, 7d=${sd?.utilization?.toFixed(0) ?? "?"}%` +
|
|
3777
|
+
(sd?.resets_at ? ` (resets ${formatResetTime(sd.resets_at)})` : ""),
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
if (totalAccounts > 1) {
|
|
3782
|
+
parts.push(`Tried switching across ${totalAccounts} accounts — all exhausted or overloaded.`);
|
|
3783
|
+
} else {
|
|
3784
|
+
parts.push(`Only 1 account configured. Add more accounts with '/anthropic login' for automatic failover.`);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
parts.push(`Wait a few minutes or switch models with a smaller context window.`);
|
|
3788
|
+
return parts.join(" ");
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
/**
|
|
3792
|
+
* Format a reset timestamp into a human-readable relative string.
|
|
3793
|
+
* @param {string | null} isoTimestamp
|
|
3794
|
+
* @returns {string}
|
|
3795
|
+
*/
|
|
3796
|
+
function formatResetTime(isoTimestamp) {
|
|
3797
|
+
if (!isoTimestamp) return "unknown";
|
|
3798
|
+
try {
|
|
3799
|
+
const resetMs = new Date(isoTimestamp).getTime();
|
|
3800
|
+
if (isNaN(resetMs)) return "unknown";
|
|
3801
|
+
const diffMs = resetMs - Date.now();
|
|
3802
|
+
if (diffMs <= 0) return "now";
|
|
3803
|
+
const mins = Math.ceil(diffMs / 60_000);
|
|
3804
|
+
if (mins < 60) return `~${mins}m`;
|
|
3805
|
+
const hours = Math.round(mins / 60);
|
|
3806
|
+
return `~${hours}h`;
|
|
3807
|
+
} catch {
|
|
3808
|
+
return "unknown";
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
/**
|
|
3813
|
+
* Attempt quota-aware account switch after 529 retries are exhausted.
|
|
3814
|
+
* Polls quota, marks current account with cooldown, tries to switch.
|
|
3815
|
+
*
|
|
3816
|
+
* @param {object} account - Current (overloaded) account
|
|
3817
|
+
* @param {object} accountManager - Account manager
|
|
3818
|
+
* @param {object} config - Plugin config
|
|
3819
|
+
* @returns {{ switched: boolean, nextAccount: object | null, cooldownMs: number }}
|
|
3820
|
+
*/
|
|
3821
|
+
function tryQuotaAwareAccountSwitch(account, accountManager, config) {
|
|
3822
|
+
const result = { switched: false, nextAccount: null, cooldownMs: 0 };
|
|
3823
|
+
if (!config.overload_recovery?.enabled) return result;
|
|
3824
|
+
|
|
3825
|
+
const defaultCooldown = config.overload_recovery.default_cooldown_ms ?? 60_000;
|
|
3826
|
+
|
|
3827
|
+
// Use cached quota data for smarter cooldown (no HTTP calls in retry path)
|
|
3828
|
+
let cooldownMs = defaultCooldown;
|
|
3829
|
+
const fh = sessionMetrics.lastQuota.fiveHour;
|
|
3830
|
+
if (fh?.resets_at) {
|
|
3831
|
+
try {
|
|
3832
|
+
const resetMs = new Date(fh.resets_at).getTime();
|
|
3833
|
+
if (!isNaN(resetMs) && resetMs > Date.now()) {
|
|
3834
|
+
// Set cooldown to last until quota resets (capped at 30 min)
|
|
3835
|
+
cooldownMs = Math.min(resetMs - Date.now(), 30 * 60_000);
|
|
3836
|
+
}
|
|
3837
|
+
} catch {
|
|
3838
|
+
// Date parse failed, use default cooldown
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
// Mark current account with cooldown
|
|
3843
|
+
if (account && accountManager) {
|
|
3844
|
+
accountManager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", cooldownMs);
|
|
3845
|
+
result.cooldownMs = cooldownMs;
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// Try to get a different account
|
|
3849
|
+
if (accountManager && accountManager.getAccountCount() > 0) {
|
|
3850
|
+
const nextAccount = accountManager.getCurrentAccount();
|
|
3851
|
+
if (nextAccount && nextAccount.index !== account?.index) {
|
|
3852
|
+
result.switched = true;
|
|
3853
|
+
result.nextAccount = nextAccount;
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
return result;
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
/**
|
|
3861
|
+
* Extract cache source hashes from a request body.
|
|
3862
|
+
* Hashes system prompt blocks and tool schemas to identify what changed.
|
|
3863
|
+
*
|
|
3864
|
+
* @param {string} bodyStr - JSON request body
|
|
3865
|
+
* @returns {Map<string, string>} source_id → hash
|
|
3866
|
+
*/
|
|
3867
|
+
function extractCacheSourceHashes(bodyStr) {
|
|
3868
|
+
const hashes = new Map();
|
|
3869
|
+
try {
|
|
3870
|
+
const parsed = JSON.parse(bodyStr);
|
|
3871
|
+
|
|
3872
|
+
// Hash system prompt (excluding token budget blocks injected by injectTokenBudgetBlock)
|
|
3873
|
+
if (Array.isArray(parsed.system)) {
|
|
3874
|
+
const systemText = parsed.system
|
|
3875
|
+
.filter((b) => !(b.text && b.text.startsWith("Token budget:")))
|
|
3876
|
+
.map((b) => b.text || "")
|
|
3877
|
+
.join("");
|
|
3878
|
+
if (systemText) hashes.set("system_prompt", hashCacheSource(systemText));
|
|
3879
|
+
} else if (typeof parsed.system === "string" && parsed.system) {
|
|
3880
|
+
hashes.set("system_prompt", hashCacheSource(parsed.system));
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
// Hash tool schemas (by name)
|
|
3884
|
+
if (Array.isArray(parsed.tools)) {
|
|
3885
|
+
for (const tool of parsed.tools) {
|
|
3886
|
+
if (tool.name) {
|
|
3887
|
+
hashes.set(`tool:${tool.name}`, hashCacheSource(JSON.stringify(tool)));
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
} catch {
|
|
3892
|
+
// Ignore parse errors
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
// LRU eviction: cap at 10 entries
|
|
3896
|
+
if (hashes.size > 10) {
|
|
3897
|
+
const entries = [...hashes.entries()];
|
|
3898
|
+
return new Map(entries.slice(entries.length - 10));
|
|
3899
|
+
}
|
|
3900
|
+
return hashes;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
/**
|
|
3904
|
+
* Detect cache break by comparing current vs previous source hashes.
|
|
3905
|
+
* @param {Map<string, string>} currentHashes
|
|
3906
|
+
* @param {Map<string, string>} previousHashes
|
|
3907
|
+
* @returns {string[]} Names of changed sources, or empty array
|
|
3908
|
+
*/
|
|
3909
|
+
function detectCacheBreakSources(currentHashes, previousHashes) {
|
|
3910
|
+
if (previousHashes.size === 0) return []; // No baseline yet
|
|
3911
|
+
const changed = [];
|
|
3912
|
+
for (const [key, hash] of currentHashes) {
|
|
3913
|
+
const prev = previousHashes.get(key);
|
|
3914
|
+
if (prev && prev !== hash) {
|
|
3915
|
+
changed.push(key);
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
// Check for removed sources
|
|
3919
|
+
for (const key of previousHashes.keys()) {
|
|
3920
|
+
if (!currentHashes.has(key)) {
|
|
3921
|
+
changed.push(key);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
return changed;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* Parse the structured context limit error message from the Anthropic API.
|
|
3929
|
+
* @param {string | null | undefined} msg - Error body text
|
|
3930
|
+
* @returns {{ input: number, maxTokens: number, limit: number } | null}
|
|
3931
|
+
*/
|
|
3932
|
+
function parseContextLimitError(msg) {
|
|
3933
|
+
if (!msg || typeof msg !== "string") return null;
|
|
3934
|
+
const m = msg.match(/input length and `max_tokens` exceed context limit:\s*(\d+)\s*\+\s*(\d+)\s*>\s*(\d+)/);
|
|
3935
|
+
if (!m) return null;
|
|
3936
|
+
return { input: +m[1], maxTokens: +m[2], limit: +m[3] };
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
/**
|
|
3940
|
+
* Compute a safe max_tokens value that fits within the context limit.
|
|
3941
|
+
* @param {number} input - Input token count from error
|
|
3942
|
+
* @param {number} limit - Context window limit from error
|
|
3943
|
+
* @param {number} [margin=1000] - Safety margin to subtract
|
|
3944
|
+
* @returns {number}
|
|
3945
|
+
*/
|
|
3946
|
+
function computeSafeMaxTokens(input, limit, margin = 1000) {
|
|
3947
|
+
return Math.max(1, limit - input - margin);
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3950
|
+
/**
|
|
3951
|
+
* Detect whether the environment uses a proxy or custom mTLS configuration.
|
|
3952
|
+
* Pure predicate — no side effects.
|
|
3953
|
+
* @returns {boolean}
|
|
3954
|
+
*/
|
|
3955
|
+
function isProxyOrMtlsEnvironment() {
|
|
3956
|
+
const proxyVars = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY"];
|
|
3957
|
+
const mtlsVars = ["NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE"];
|
|
3958
|
+
for (const v of proxyVars) {
|
|
3959
|
+
if (process.env[v]) return true;
|
|
3960
|
+
}
|
|
3961
|
+
for (const v of mtlsVars) {
|
|
3962
|
+
if (process.env[v]) return true;
|
|
3963
|
+
}
|
|
3964
|
+
return false;
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
/**
|
|
3968
|
+
* Fire-and-forget HEAD request to pre-warm TCP+TLS connection pool.
|
|
3969
|
+
* Skips in proxy/mTLS environments where the HEAD may be intercepted.
|
|
3970
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
3971
|
+
*/
|
|
3972
|
+
async function preconnectApi(config) {
|
|
3973
|
+
if (!config.preconnect?.enabled) return;
|
|
3974
|
+
if (isProxyOrMtlsEnvironment()) return;
|
|
3975
|
+
try {
|
|
3976
|
+
await Promise.race([
|
|
3977
|
+
globalThis.fetch("https://api.anthropic.com", { method: "HEAD" }),
|
|
3978
|
+
new Promise((_, r) =>
|
|
3979
|
+
setTimeout(() => r(new Error("preconnect timeout")), config.preconnect.timeout_ms ?? 10_000),
|
|
3980
|
+
),
|
|
3981
|
+
]);
|
|
3982
|
+
} catch {
|
|
3983
|
+
/* fire-and-forget — never throws */
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
/**
|
|
3988
|
+
* Classify an API request as foreground (user-initiated) or background
|
|
3989
|
+
* (title generation, speculation). Background requests receive a reduced
|
|
3990
|
+
* retry budget to preserve quota for user-facing work.
|
|
3991
|
+
*
|
|
3992
|
+
* @param {object|string} body - Parsed request body (or raw string to parse)
|
|
3993
|
+
* @returns {"foreground" | "background"}
|
|
3994
|
+
*/
|
|
3995
|
+
function classifyApiRequest(body) {
|
|
3996
|
+
try {
|
|
3997
|
+
const parsed = typeof body === "string" ? JSON.parse(body) : body;
|
|
3998
|
+
if (!parsed || typeof parsed !== "object") return "foreground";
|
|
3999
|
+
|
|
4000
|
+
const msgCount = parsed.messages?.length ?? 0;
|
|
4001
|
+
const maxToks = parsed.max_tokens ?? 99999;
|
|
4002
|
+
|
|
4003
|
+
// Title generation signal: system prompt contains "Generate a short title"
|
|
4004
|
+
const systemBlocks = Array.isArray(parsed.system) ? parsed.system : [];
|
|
4005
|
+
const hasTitleSignal = systemBlocks.some(
|
|
4006
|
+
(b) => typeof b.text === "string" && b.text.includes("Generate a short title"),
|
|
4007
|
+
);
|
|
4008
|
+
|
|
4009
|
+
// Background: title generation OR very short context with tiny output
|
|
4010
|
+
if (hasTitleSignal) return "background";
|
|
4011
|
+
if (msgCount <= 2 && maxToks <= 256) return "background";
|
|
4012
|
+
|
|
4013
|
+
return "foreground";
|
|
4014
|
+
} catch {
|
|
4015
|
+
return "foreground"; // Parse error → safe default
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// ---------------------------------------------------------------------------
|
|
4020
|
+
// Token Budget Parsing & Enforcement (A9)
|
|
4021
|
+
// ---------------------------------------------------------------------------
|
|
4022
|
+
|
|
4023
|
+
/**
|
|
4024
|
+
* Parse natural-language budget expressions from user messages.
|
|
4025
|
+
* Supports: +500k, 500,000, 2M, 2 million, "spend 500k", "use 2M tokens", "budget: 1M".
|
|
4026
|
+
* Only scans the last user message to avoid re-triggering from history.
|
|
4027
|
+
*
|
|
4028
|
+
* @param {Array<{role: string, content: string | Array<{type: string, text?: string}>}>} messages
|
|
4029
|
+
* @returns {number} Parsed token count, or 0 if no budget expression found
|
|
4030
|
+
*/
|
|
4031
|
+
function parseNaturalLanguageBudget(messages) {
|
|
4032
|
+
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
|
4033
|
+
|
|
4034
|
+
// Find the last user message
|
|
4035
|
+
let lastUserText = "";
|
|
4036
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4037
|
+
if (messages[i].role === "user") {
|
|
4038
|
+
const content = messages[i].content;
|
|
4039
|
+
if (typeof content === "string") {
|
|
4040
|
+
lastUserText = content;
|
|
4041
|
+
} else if (Array.isArray(content)) {
|
|
4042
|
+
lastUserText = content
|
|
4043
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
4044
|
+
.map((b) => b.text)
|
|
4045
|
+
.join(" ");
|
|
4046
|
+
}
|
|
4047
|
+
break;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
if (!lastUserText) return 0;
|
|
4051
|
+
|
|
4052
|
+
// Patterns ordered from most specific to least
|
|
4053
|
+
const patterns = [
|
|
4054
|
+
/\buse\s+(\d[\d,]*)\s*([mk])\s*tokens?\b/i,
|
|
4055
|
+
/\bspend\s+(\d[\d,]*)\s*([mk])?\b/i,
|
|
4056
|
+
/\bbudget[:\s]+(\d[\d,]*)\s*([mk])?\b/i,
|
|
4057
|
+
/\+(\d[\d,]*)\s*([mk])\b/i,
|
|
4058
|
+
/\b(\d[\d,]*)\s*million\s*tokens?\b/i,
|
|
4059
|
+
];
|
|
4060
|
+
|
|
4061
|
+
for (const re of patterns) {
|
|
4062
|
+
const m = lastUserText.match(re);
|
|
4063
|
+
if (m) {
|
|
4064
|
+
const num = parseFloat(m[1].replace(/,/g, ""));
|
|
4065
|
+
if (isNaN(num) || num <= 0) continue;
|
|
4066
|
+
const suffix = (m[2] || "").toLowerCase();
|
|
4067
|
+
if (re === patterns[4]) {
|
|
4068
|
+
// "N million tokens" — the regex has no suffix group
|
|
4069
|
+
return num * 1_000_000;
|
|
4070
|
+
}
|
|
4071
|
+
if (suffix === "m") return num * 1_000_000;
|
|
4072
|
+
if (suffix === "k") return num * 1_000;
|
|
4073
|
+
// No suffix — treat as absolute count
|
|
4074
|
+
return num;
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
return 0;
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
/**
|
|
4081
|
+
* Inject a token budget status block into the system prompt.
|
|
4082
|
+
* Prepends a text block with budget progress and threshold info.
|
|
4083
|
+
*
|
|
4084
|
+
* @param {Array<{type: string, text?: string, [k: string]: any}>} systemBlocks
|
|
4085
|
+
* @param {{limit: number, used: number, continuations: number}} budget
|
|
4086
|
+
* @param {number} threshold - Completion threshold (0-1, e.g. 0.9)
|
|
4087
|
+
* @returns {Array<{type: string, text?: string, [k: string]: any}>}
|
|
4088
|
+
*/
|
|
4089
|
+
function injectTokenBudgetBlock(systemBlocks, budget, threshold) {
|
|
4090
|
+
if (!budget || budget.limit <= 0) return systemBlocks;
|
|
4091
|
+
const pct = ((budget.used / budget.limit) * 100).toFixed(0);
|
|
4092
|
+
const thresholdTokens = Math.round(budget.limit * threshold);
|
|
4093
|
+
const remaining = Math.max(0, budget.limit - budget.used);
|
|
4094
|
+
const block = {
|
|
4095
|
+
type: "text",
|
|
4096
|
+
text: `Token budget: ${budget.used.toLocaleString()}/${budget.limit.toLocaleString()} tokens used (${pct}%). Stop generating at ${thresholdTokens.toLocaleString()} tokens. Remaining: ${remaining.toLocaleString()} tokens.`,
|
|
4097
|
+
};
|
|
4098
|
+
return [block, ...(systemBlocks || [])];
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/**
|
|
4102
|
+
* Detect diminishing returns: ≥3 continuations AND last 3 output deltas all < 500 tokens.
|
|
4103
|
+
*
|
|
4104
|
+
* @param {number[]} outputHistory - Recent output token deltas
|
|
4105
|
+
* @returns {boolean}
|
|
4106
|
+
*/
|
|
4107
|
+
function detectDiminishingReturns(outputHistory) {
|
|
4108
|
+
if (!Array.isArray(outputHistory) || outputHistory.length < 3) return false;
|
|
4109
|
+
const last3 = outputHistory.slice(-3);
|
|
4110
|
+
return last3.every((d) => d < 500);
|
|
4111
|
+
}
|
|
4112
|
+
|
|
3244
4113
|
/**
|
|
3245
4114
|
* Estimate prompt token count from the raw request body string.
|
|
3246
4115
|
* Uses a 4-character-per-token heuristic (conservative for English + code).
|
|
@@ -3295,6 +4164,108 @@ function estimatePromptTokens(bodyString) {
|
|
|
3295
4164
|
}
|
|
3296
4165
|
}
|
|
3297
4166
|
|
|
4167
|
+
/**
|
|
4168
|
+
* Analyze a request body to produce a token breakdown by role and tool.
|
|
4169
|
+
* Used by `/anthropic context` command.
|
|
4170
|
+
*
|
|
4171
|
+
* @param {string} bodyStr - JSON request body string
|
|
4172
|
+
* @returns {{ systemTokens: number, userTokens: number, assistantTokens: number, toolResultTokens: number, toolBreakdown: Record<string, { tokens: number, count: number }>, totalTokens: number, duplicates: { count: number, wastedTokens: number } }}
|
|
4173
|
+
*/
|
|
4174
|
+
function analyzeRequestContext(bodyStr) {
|
|
4175
|
+
const result = {
|
|
4176
|
+
systemTokens: 0,
|
|
4177
|
+
userTokens: 0,
|
|
4178
|
+
assistantTokens: 0,
|
|
4179
|
+
toolResultTokens: 0,
|
|
4180
|
+
toolBreakdown: /** @type {Record<string, { tokens: number, count: number }>} */ ({}),
|
|
4181
|
+
totalTokens: 0,
|
|
4182
|
+
duplicates: { count: 0, wastedTokens: 0 },
|
|
4183
|
+
};
|
|
4184
|
+
|
|
4185
|
+
if (!bodyStr || typeof bodyStr !== "string") return result;
|
|
4186
|
+
|
|
4187
|
+
try {
|
|
4188
|
+
const parsed = JSON.parse(bodyStr);
|
|
4189
|
+
const contentHashes = new Map(); // hash → { tokens, count }
|
|
4190
|
+
|
|
4191
|
+
// Estimate tokens from a string (4 chars/token heuristic)
|
|
4192
|
+
const estimateTokens = (/** @type {string} */ s) => Math.ceil((s || "").length / 4);
|
|
4193
|
+
|
|
4194
|
+
// System prompt
|
|
4195
|
+
if (Array.isArray(parsed.system)) {
|
|
4196
|
+
for (const block of parsed.system) {
|
|
4197
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4198
|
+
result.systemTokens += estimateTokens(block.text);
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
} else if (typeof parsed.system === "string") {
|
|
4202
|
+
result.systemTokens += estimateTokens(parsed.system);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
// Messages
|
|
4206
|
+
if (Array.isArray(parsed.messages)) {
|
|
4207
|
+
for (const msg of parsed.messages) {
|
|
4208
|
+
const role = msg.role || "unknown";
|
|
4209
|
+
const blocks =
|
|
4210
|
+
typeof msg.content === "string"
|
|
4211
|
+
? [{ type: "text", text: msg.content }]
|
|
4212
|
+
: Array.isArray(msg.content)
|
|
4213
|
+
? msg.content
|
|
4214
|
+
: [];
|
|
4215
|
+
|
|
4216
|
+
for (const block of blocks) {
|
|
4217
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4218
|
+
const tokens = estimateTokens(block.text);
|
|
4219
|
+
if (role === "user") result.userTokens += tokens;
|
|
4220
|
+
else if (role === "assistant") result.assistantTokens += tokens;
|
|
4221
|
+
} else if (block.type === "tool_result") {
|
|
4222
|
+
// tool_result content can be string or array of content blocks
|
|
4223
|
+
let content = "";
|
|
4224
|
+
if (typeof block.content === "string") {
|
|
4225
|
+
content = block.content;
|
|
4226
|
+
} else if (Array.isArray(block.content)) {
|
|
4227
|
+
content = block.content.map((b) => b.text || "").join("");
|
|
4228
|
+
}
|
|
4229
|
+
const tokens = estimateTokens(content);
|
|
4230
|
+
result.toolResultTokens += tokens;
|
|
4231
|
+
result.userTokens += tokens; // tool_result is part of user turn
|
|
4232
|
+
|
|
4233
|
+
// Group by tool_name (may be on the block or need to look up from tool_use_id)
|
|
4234
|
+
const toolName = block.tool_name || block.name || "unknown_tool";
|
|
4235
|
+
if (!result.toolBreakdown[toolName]) {
|
|
4236
|
+
result.toolBreakdown[toolName] = { tokens: 0, count: 0 };
|
|
4237
|
+
}
|
|
4238
|
+
result.toolBreakdown[toolName].tokens += tokens;
|
|
4239
|
+
result.toolBreakdown[toolName].count += 1;
|
|
4240
|
+
|
|
4241
|
+
// Duplicate detection via content hash
|
|
4242
|
+
if (content.length > 0) {
|
|
4243
|
+
const hash = createHashCrypto("sha256").update(content).digest("hex").slice(0, 16);
|
|
4244
|
+
const existing = contentHashes.get(hash);
|
|
4245
|
+
if (existing) {
|
|
4246
|
+
existing.count += 1;
|
|
4247
|
+
result.duplicates.count += 1;
|
|
4248
|
+
result.duplicates.wastedTokens += tokens;
|
|
4249
|
+
} else {
|
|
4250
|
+
contentHashes.set(hash, { tokens, count: 1 });
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
} else if (block.type === "tool_use") {
|
|
4254
|
+
const tokens = estimateTokens(JSON.stringify(block.input || {}));
|
|
4255
|
+
if (role === "assistant") result.assistantTokens += tokens;
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
result.totalTokens = result.systemTokens + result.userTokens + result.assistantTokens;
|
|
4262
|
+
} catch {
|
|
4263
|
+
// Malformed JSON — return zeroes
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
return result;
|
|
4267
|
+
}
|
|
4268
|
+
|
|
3298
4269
|
/**
|
|
3299
4270
|
* Decide whether to include the context-1m beta for this request.
|
|
3300
4271
|
* Returns true if 1M context should be activated.
|
|
@@ -3478,6 +4449,31 @@ function updateSessionMetrics(usage, model) {
|
|
|
3478
4449
|
|
|
3479
4450
|
// Total cost
|
|
3480
4451
|
sessionMetrics.sessionCostUsd += calculateCostUsd(usage, model);
|
|
4452
|
+
|
|
4453
|
+
// Per-model breakdown
|
|
4454
|
+
if (model) {
|
|
4455
|
+
if (!sessionMetrics.perModel[model]) {
|
|
4456
|
+
sessionMetrics.perModel[model] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, turns: 0 };
|
|
4457
|
+
}
|
|
4458
|
+
const pm = sessionMetrics.perModel[model];
|
|
4459
|
+
pm.input += usage.inputTokens;
|
|
4460
|
+
pm.output += usage.outputTokens;
|
|
4461
|
+
pm.cacheRead += usage.cacheReadTokens;
|
|
4462
|
+
pm.cacheWrite += usage.cacheWriteTokens;
|
|
4463
|
+
pm.costUsd += calculateCostUsd(usage, model);
|
|
4464
|
+
pm.turns += 1;
|
|
4465
|
+
sessionMetrics.lastModelId = model;
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
// Token budget tracking (A9)
|
|
4469
|
+
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
4470
|
+
sessionMetrics.tokenBudget.used += usage.outputTokens;
|
|
4471
|
+
sessionMetrics.tokenBudget.continuations += 1;
|
|
4472
|
+
sessionMetrics.tokenBudget.outputHistory.push(usage.outputTokens);
|
|
4473
|
+
if (sessionMetrics.tokenBudget.outputHistory.length > 5) {
|
|
4474
|
+
sessionMetrics.tokenBudget.outputHistory.shift();
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
3481
4477
|
}
|
|
3482
4478
|
|
|
3483
4479
|
/**
|
|
@@ -3674,17 +4670,19 @@ process.once("beforeExit", () => {
|
|
|
3674
4670
|
// Request building helpers (extracted from original fetch interceptor)
|
|
3675
4671
|
// ---------------------------------------------------------------------------
|
|
3676
4672
|
|
|
3677
|
-
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.
|
|
4673
|
+
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.90";
|
|
3678
4674
|
const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
3679
|
-
const CLAUDE_CODE_BUILD_TIME = "2026-
|
|
4675
|
+
const CLAUDE_CODE_BUILD_TIME = "2026-04-01T22:53:10Z";
|
|
3680
4676
|
|
|
3681
|
-
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.
|
|
4677
|
+
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.90.
|
|
3682
4678
|
// This is distinct from the CLI version and goes in X-Stainless-Package-Version.
|
|
3683
|
-
// Verified by extracting VERSION="0.208.0" from the bundled cli.js of all versions .
|
|
4679
|
+
// Verified by extracting VERSION="0.208.0" from the bundled cli.js of all versions .80-.90.
|
|
3684
4680
|
const ANTHROPIC_SDK_VERSION = "0.208.0";
|
|
3685
4681
|
|
|
3686
4682
|
// Map of CLI version → bundled SDK version (update when CLI version changes)
|
|
3687
4683
|
const CLI_TO_SDK_VERSION = new Map([
|
|
4684
|
+
["2.1.90", "0.208.0"],
|
|
4685
|
+
["2.1.89", "0.208.0"],
|
|
3688
4686
|
["2.1.88", "0.208.0"],
|
|
3689
4687
|
["2.1.87", "0.208.0"],
|
|
3690
4688
|
["2.1.86", "0.208.0"],
|
|
@@ -3744,6 +4742,94 @@ function computeBillingCacheHash(firstUserMessage, version) {
|
|
|
3744
4742
|
* @param {Array} messages — The messages array from the parsed request body
|
|
3745
4743
|
* @returns {Array} — Filtered messages array
|
|
3746
4744
|
*/
|
|
4745
|
+
|
|
4746
|
+
/**
|
|
4747
|
+
* Repair orphaned tool_use blocks in the message array.
|
|
4748
|
+
*
|
|
4749
|
+
* The Anthropic API requires that every assistant message containing `tool_use`
|
|
4750
|
+
* blocks is immediately followed by a user message with `tool_result` blocks
|
|
4751
|
+
* for each tool_use ID. When OpenCode crashes or hangs mid-tool-execution, the
|
|
4752
|
+
* conversation may be persisted with assistant tool_use blocks that lack
|
|
4753
|
+
* corresponding tool_result responses, causing:
|
|
4754
|
+
*
|
|
4755
|
+
* "messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
4756
|
+
* immediately after: toolu_XXXXX"
|
|
4757
|
+
*
|
|
4758
|
+
* This function scans the entire message array and inserts synthetic
|
|
4759
|
+
* tool_result user messages wherever they are missing.
|
|
4760
|
+
*
|
|
4761
|
+
* @param {Array} messages — The messages array from the parsed request body
|
|
4762
|
+
* @returns {Array} — Repaired messages array
|
|
4763
|
+
*/
|
|
4764
|
+
function repairOrphanedToolUseBlocks(messages) {
|
|
4765
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
4766
|
+
|
|
4767
|
+
const repaired = [];
|
|
4768
|
+
|
|
4769
|
+
for (let i = 0; i < messages.length; i++) {
|
|
4770
|
+
const msg = messages[i];
|
|
4771
|
+
repaired.push(msg);
|
|
4772
|
+
|
|
4773
|
+
// Only check assistant messages with array content
|
|
4774
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
4775
|
+
|
|
4776
|
+
// Collect tool_use IDs from this assistant message
|
|
4777
|
+
const toolUseIds = [];
|
|
4778
|
+
for (const block of msg.content) {
|
|
4779
|
+
if (block.type === "tool_use" && block.id) {
|
|
4780
|
+
toolUseIds.push(block.id);
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
if (toolUseIds.length === 0) continue;
|
|
4784
|
+
|
|
4785
|
+
// Check if the next message is a user message with matching tool_results
|
|
4786
|
+
const next = messages[i + 1];
|
|
4787
|
+
if (next && next.role === "user" && Array.isArray(next.content)) {
|
|
4788
|
+
// Collect tool_result IDs present in the next user message
|
|
4789
|
+
const resultIds = new Set();
|
|
4790
|
+
for (const block of next.content) {
|
|
4791
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
4792
|
+
resultIds.add(block.tool_use_id);
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
|
|
4796
|
+
// Find which tool_use IDs are missing from the tool_results
|
|
4797
|
+
const missingIds = toolUseIds.filter((id) => !resultIds.has(id));
|
|
4798
|
+
if (missingIds.length === 0) continue; // All paired, nothing to fix
|
|
4799
|
+
|
|
4800
|
+
// There are missing tool_results — inject them into the existing user message.
|
|
4801
|
+
// Clone the next message to avoid mutating the original.
|
|
4802
|
+
const patchedNext = {
|
|
4803
|
+
...next,
|
|
4804
|
+
content: [
|
|
4805
|
+
...missingIds.map((id) => ({
|
|
4806
|
+
type: "tool_result",
|
|
4807
|
+
tool_use_id: id,
|
|
4808
|
+
content: "[Result unavailable — tool execution was interrupted]",
|
|
4809
|
+
})),
|
|
4810
|
+
...next.content,
|
|
4811
|
+
],
|
|
4812
|
+
};
|
|
4813
|
+
// Replace the next message in-place by skipping it and pushing the patched version
|
|
4814
|
+
i++; // skip original next
|
|
4815
|
+
repaired.push(patchedNext);
|
|
4816
|
+
} else {
|
|
4817
|
+
// Next message is missing or is not a user message — synthesize a full
|
|
4818
|
+
// tool_result user message for all tool_use IDs.
|
|
4819
|
+
repaired.push({
|
|
4820
|
+
role: "user",
|
|
4821
|
+
content: toolUseIds.map((id) => ({
|
|
4822
|
+
type: "tool_result",
|
|
4823
|
+
tool_use_id: id,
|
|
4824
|
+
content: "[Result unavailable — tool execution was interrupted]",
|
|
4825
|
+
})),
|
|
4826
|
+
});
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
return repaired;
|
|
4831
|
+
}
|
|
4832
|
+
|
|
3747
4833
|
function stripSlashCommandMessages(messages) {
|
|
3748
4834
|
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
3749
4835
|
|
|
@@ -3845,7 +4931,6 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
|
|
|
3845
4931
|
"interleaved-thinking-2025-05-14",
|
|
3846
4932
|
"context-1m-2025-08-07",
|
|
3847
4933
|
"tool-search-tool-2025-10-19",
|
|
3848
|
-
"tool-examples-2025-10-29",
|
|
3849
4934
|
"code-execution-2025-08-25",
|
|
3850
4935
|
"files-api-2025-04-14",
|
|
3851
4936
|
"fine-grained-tool-streaming-2025-05-14",
|
|
@@ -3864,9 +4949,6 @@ const EXPERIMENTAL_BETA_FLAGS = new Set([
|
|
|
3864
4949
|
"prompt-caching-scope-2026-01-05",
|
|
3865
4950
|
"redact-thinking-2026-02-12",
|
|
3866
4951
|
"structured-outputs-2025-12-15",
|
|
3867
|
-
"summarize-connector-text-2026-03-13",
|
|
3868
|
-
"token-efficient-tools-2026-03-28",
|
|
3869
|
-
"tool-examples-2025-10-29",
|
|
3870
4952
|
"tool-search-tool-2025-10-19",
|
|
3871
4953
|
"web-search-2025-03-05",
|
|
3872
4954
|
]);
|
|
@@ -3879,11 +4961,7 @@ const BETA_SHORTCUTS = new Map([
|
|
|
3879
4961
|
["opus-fast", "fast-mode-2026-02-01"],
|
|
3880
4962
|
["task-budgets", "task-budgets-2026-03-13"],
|
|
3881
4963
|
["budgets", "task-budgets-2026-03-13"],
|
|
3882
|
-
["efficient-tools", "token-efficient-tools-2026-03-28"],
|
|
3883
|
-
["token-efficient", "token-efficient-tools-2026-03-28"],
|
|
3884
4964
|
["redact-thinking", "redact-thinking-2026-02-12"],
|
|
3885
|
-
["connector-text", "summarize-connector-text-2026-03-13"],
|
|
3886
|
-
["anti-distill", "summarize-connector-text-2026-03-13"],
|
|
3887
4965
|
]);
|
|
3888
4966
|
const STAINLESS_HELPER_KEYS = [
|
|
3889
4967
|
"x_stainless_helper",
|
|
@@ -4400,12 +5478,11 @@ function normalizeSystemTextBlocks(system) {
|
|
|
4400
5478
|
text: item.text,
|
|
4401
5479
|
};
|
|
4402
5480
|
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
}
|
|
5481
|
+
// Intentionally strip cache_control from incoming system blocks.
|
|
5482
|
+
// The plugin controls cache placement: only the identity block and
|
|
5483
|
+
// boundary-split blocks get cache_control (added in buildSystemPromptBlocks).
|
|
5484
|
+
// Passing through upstream markers can cause "maximum of 4 blocks with
|
|
5485
|
+
// cache_control" API errors when combined with our own markers.
|
|
4409
5486
|
|
|
4410
5487
|
output.push(normalized);
|
|
4411
5488
|
}
|
|
@@ -4468,14 +5545,21 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4468
5545
|
// TTL must be non-decreasing across tools → system → messages, so all cached
|
|
4469
5546
|
// system blocks must share the same TTL to avoid "1h after 5m" API rejection.
|
|
4470
5547
|
const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
4471
|
-
const
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
//
|
|
4477
|
-
|
|
4478
|
-
|
|
5548
|
+
const hasTtl = effectiveCachePolicy.ttl !== "off" && effectiveCachePolicy.ttl_supported !== false;
|
|
5549
|
+
|
|
5550
|
+
// CC v2.1.90 cache_control scoping (verified against bundle WQ() function):
|
|
5551
|
+
// - scope "global" is only emitted for static pre-boundary blocks
|
|
5552
|
+
// - scope "org" is internal-only, NEVER emitted on the wire
|
|
5553
|
+
// - identity block gets no cache_control in boundary mode, or {type:"ephemeral"} in fallback
|
|
5554
|
+
const baseCacheControl = hasTtl ? { type: "ephemeral", ttl: effectiveCachePolicy.ttl } : { type: "ephemeral" };
|
|
5555
|
+
const globalCacheControl = hasTtl
|
|
5556
|
+
? { type: "ephemeral", scope: "global", ttl: effectiveCachePolicy.ttl }
|
|
5557
|
+
: { type: "ephemeral", scope: "global" };
|
|
5558
|
+
|
|
5559
|
+
// Identity block: per CC v2.1.90, gets {type:"ephemeral"} with NO scope field.
|
|
5560
|
+
// In boundary mode CC assigns cacheScope:null (no cache_control at all), but
|
|
5561
|
+
// we always include it for better cache hit rates on the proxy side.
|
|
5562
|
+
blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: baseCacheControl });
|
|
4479
5563
|
|
|
4480
5564
|
// Filtered blocks: keep as-is, with optional static/dynamic boundary marker
|
|
4481
5565
|
if (filtered.length > 0) {
|
|
@@ -4500,9 +5584,9 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4500
5584
|
|
|
4501
5585
|
const effectiveSplit = splitIndex > 0 ? splitIndex : Math.ceil(filtered.length / 2);
|
|
4502
5586
|
|
|
4503
|
-
// Static blocks (before boundary) get cache_control
|
|
5587
|
+
// Static blocks (before boundary) get global-scope cache_control
|
|
4504
5588
|
for (let i = 0; i < effectiveSplit; i++) {
|
|
4505
|
-
blocks.push({ ...filtered[i], cache_control:
|
|
5589
|
+
blocks.push({ ...filtered[i], cache_control: globalCacheControl });
|
|
4506
5590
|
}
|
|
4507
5591
|
|
|
4508
5592
|
// Boundary marker
|
|
@@ -4522,7 +5606,7 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4522
5606
|
blocks.push(rest);
|
|
4523
5607
|
}
|
|
4524
5608
|
const lastFiltered = filtered[filtered.length - 1];
|
|
4525
|
-
blocks.push({ ...lastFiltered, cache_control:
|
|
5609
|
+
blocks.push({ ...lastFiltered, cache_control: baseCacheControl });
|
|
4526
5610
|
}
|
|
4527
5611
|
}
|
|
4528
5612
|
|
|
@@ -4552,6 +5636,7 @@ function buildAnthropicBetaHeader(
|
|
|
4552
5636
|
hasFileReferences,
|
|
4553
5637
|
adaptiveOverride,
|
|
4554
5638
|
tokenEconomy,
|
|
5639
|
+
microcompactBetas, // NEW 11th param
|
|
4555
5640
|
) {
|
|
4556
5641
|
const incomingBetasList = incomingBeta
|
|
4557
5642
|
.split(",")
|
|
@@ -4580,15 +5665,15 @@ function buildAnthropicBetaHeader(
|
|
|
4580
5665
|
const isRoundRobin = strategy === "round-robin";
|
|
4581
5666
|
const te = tokenEconomy || {};
|
|
4582
5667
|
|
|
4583
|
-
// === ALWAYS-ON BETAS (Claude Code v2.1.
|
|
5668
|
+
// === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
|
|
4584
5669
|
// These are ALWAYS included regardless of env vars or feature flags.
|
|
4585
5670
|
if (!haiku) {
|
|
4586
5671
|
betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
|
|
4587
5672
|
}
|
|
4588
5673
|
|
|
4589
5674
|
// Tool search: use provider-aware header.
|
|
4590
|
-
// 1P/Foundry
|
|
4591
|
-
// Vertex/Bedrock
|
|
5675
|
+
// 1P/Foundry u2192 advanced-tool-use-2025-11-20 (enables broader tool capabilities)
|
|
5676
|
+
// Vertex/Bedrock u2192 tool-search-tool-2025-10-19 (3P-compatible subset)
|
|
4592
5677
|
if (provider === "vertex" || provider === "bedrock") {
|
|
4593
5678
|
betas.push("tool-search-tool-2025-10-19");
|
|
4594
5679
|
} else {
|
|
@@ -4621,17 +5706,15 @@ function buildAnthropicBetaHeader(
|
|
|
4621
5706
|
|
|
4622
5707
|
// === CONDITIONAL BETAS (model/context-dependent) ===
|
|
4623
5708
|
|
|
4624
|
-
// Context management —
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
//
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
// Skip structured-outputs — API rejects both together
|
|
4634
|
-
} else if (supportsStructuredOutputs(model)) {
|
|
5709
|
+
// Context management — gated to Claude 4+ models in CC v2.1.90.
|
|
5710
|
+
// Excluded for Claude 3.x (not supported). Always-on for Claude 4+ on 1P/Foundry.
|
|
5711
|
+
if (!/claude-3-/i.test(model)) {
|
|
5712
|
+
betas.push("context-management-2025-06-27");
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
// Structured outputs: only -2025-12-15 is active in v2.1.90 runtime.
|
|
5716
|
+
// token-efficient-tools-2026-03-28 was fully removed from v90 bundle.
|
|
5717
|
+
if (supportsStructuredOutputs(model)) {
|
|
4635
5718
|
betas.push("structured-outputs-2025-12-15");
|
|
4636
5719
|
}
|
|
4637
5720
|
|
|
@@ -4659,15 +5742,19 @@ function buildAnthropicBetaHeader(
|
|
|
4659
5742
|
betas.push("redact-thinking-2026-02-12");
|
|
4660
5743
|
}
|
|
4661
5744
|
|
|
4662
|
-
// summarize-connector-text
|
|
4663
|
-
//
|
|
4664
|
-
if (te.connector_text_summarization && !disableExperimentalBetas) {
|
|
4665
|
-
betas.push("summarize-connector-text-2026-03-13");
|
|
4666
|
-
}
|
|
5745
|
+
// summarize-connector-text-2026-03-13 was removed in v2.1.90 (dead slot njq="").
|
|
5746
|
+
// compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
|
|
4667
5747
|
|
|
4668
5748
|
// afk-mode — NOT auto-included (requires user opt-in)
|
|
4669
5749
|
// Available via: /anthropic betas add afk-mode-2026-01-31
|
|
4670
5750
|
|
|
5751
|
+
// === MICROCOMPACT BETAS (context-aware, Phase 3 Task 3.4) ===
|
|
5752
|
+
if (microcompactBetas?.length) {
|
|
5753
|
+
for (const mb of microcompactBetas) {
|
|
5754
|
+
if (!betas.includes(mb)) betas.push(mb);
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
5757
|
+
|
|
4671
5758
|
// Merge incoming betas from the original request
|
|
4672
5759
|
let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
|
|
4673
5760
|
|
|
@@ -4928,6 +6015,29 @@ function buildRequestHeaders(
|
|
|
4928
6015
|
return requestHeaders;
|
|
4929
6016
|
}
|
|
4930
6017
|
|
|
6018
|
+
/**
|
|
6019
|
+
* Resolve max_tokens for a request based on output cap configuration.
|
|
6020
|
+
* If the caller specified max_tokens, it is preserved. Otherwise, defaults
|
|
6021
|
+
* to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
|
|
6022
|
+
*
|
|
6023
|
+
* @param {Record<string, any>} body - Parsed request body
|
|
6024
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
6025
|
+
* @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
|
|
6026
|
+
*/
|
|
6027
|
+
function resolveMaxTokens(body, config) {
|
|
6028
|
+
if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
|
|
6029
|
+
if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
|
|
6030
|
+
const escalated = sessionMetrics.lastStopReason === "max_tokens";
|
|
6031
|
+
const result = escalated
|
|
6032
|
+
? (config.output_cap.escalated_max_tokens ?? 64_000)
|
|
6033
|
+
: (config.output_cap.default_max_tokens ?? 8_000);
|
|
6034
|
+
// Reset after escalation is consumed (sticky for exactly one turn)
|
|
6035
|
+
if (escalated) {
|
|
6036
|
+
sessionMetrics.lastStopReason = null;
|
|
6037
|
+
}
|
|
6038
|
+
return result;
|
|
6039
|
+
}
|
|
6040
|
+
|
|
4931
6041
|
/**
|
|
4932
6042
|
* Transform the request body: system prompt sanitization and tool prefixing.
|
|
4933
6043
|
* Preserves behaviors E1-E7.
|
|
@@ -4936,15 +6046,20 @@ function buildRequestHeaders(
|
|
|
4936
6046
|
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
|
|
4937
6047
|
* @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
|
|
4938
6048
|
* @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
|
|
6049
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
|
|
4939
6050
|
* @returns {string | undefined}
|
|
4940
6051
|
*/
|
|
4941
|
-
function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
6052
|
+
function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
4942
6053
|
if (!body || typeof body !== "string") return body;
|
|
4943
6054
|
|
|
4944
6055
|
const TOOL_PREFIX = "mcp_";
|
|
4945
6056
|
|
|
4946
6057
|
try {
|
|
4947
6058
|
const parsed = JSON.parse(body);
|
|
6059
|
+
// Output cap: resolve max_tokens before any other body transforms
|
|
6060
|
+
if (config?.output_cap?.enabled) {
|
|
6061
|
+
parsed.max_tokens = resolveMaxTokens(parsed, config);
|
|
6062
|
+
}
|
|
4948
6063
|
// Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
|
|
4949
6064
|
// doesn't forward custom HTTP headers. First-party API rejects "betas" in body
|
|
4950
6065
|
// with "Extra inputs are not permitted" — betas are header-only for first-party.
|
|
@@ -5004,6 +6119,25 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
5004
6119
|
// Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
|
|
5005
6120
|
parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
|
|
5006
6121
|
|
|
6122
|
+
// Token budget (A9): parse NL budget from last user message, inject status block
|
|
6123
|
+
if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
|
|
6124
|
+
const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
|
|
6125
|
+
if (budgetExpr > 0) {
|
|
6126
|
+
sessionMetrics.tokenBudget.limit = budgetExpr;
|
|
6127
|
+
} else if (config.token_budget.default > 0 && sessionMetrics.tokenBudget.limit === 0) {
|
|
6128
|
+
sessionMetrics.tokenBudget.limit = config.token_budget.default;
|
|
6129
|
+
}
|
|
6130
|
+
// If budget is active, inject status into system prompt
|
|
6131
|
+
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
6132
|
+
const threshold = config.token_budget.completion_threshold ?? 0.9;
|
|
6133
|
+
parsed.system = injectTokenBudgetBlock(parsed.system, sessionMetrics.tokenBudget, threshold);
|
|
6134
|
+
// Soft stop: if we've exceeded the threshold, cap max_tokens to 1
|
|
6135
|
+
if (sessionMetrics.tokenBudget.used >= sessionMetrics.tokenBudget.limit * threshold) {
|
|
6136
|
+
parsed.max_tokens = 1;
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
}
|
|
6140
|
+
|
|
5007
6141
|
if (signature.enabled) {
|
|
5008
6142
|
const currentMetadata =
|
|
5009
6143
|
parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
|
|
@@ -5087,14 +6221,20 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
5087
6221
|
parsed.speed = "fast";
|
|
5088
6222
|
}
|
|
5089
6223
|
|
|
5090
|
-
// Guard:
|
|
5091
|
-
// The API
|
|
6224
|
+
// Guard: repair orphaned tool_use blocks anywhere in the message array.
|
|
6225
|
+
// The Anthropic API requires that every assistant message containing tool_use
|
|
6226
|
+
// blocks is immediately followed by a user message with matching tool_result
|
|
6227
|
+
// blocks. When OpenCode crashes/hangs mid-tool-execution, the conversation
|
|
6228
|
+
// state may be saved with unpaired tool_use blocks. This causes:
|
|
6229
|
+
// "messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
6230
|
+
// immediately after: toolu_XXXXX"
|
|
6231
|
+
// We scan the full array and synthesize missing tool_result messages.
|
|
5092
6232
|
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
6233
|
+
parsed.messages = repairOrphanedToolUseBlocks(parsed.messages);
|
|
6234
|
+
|
|
6235
|
+
// Also ensure the array never ends with an assistant message (prefill guard).
|
|
5093
6236
|
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
5094
6237
|
if (lastMsg && lastMsg.role === "assistant") {
|
|
5095
|
-
// Check if the assistant message contains tool_use blocks — if so,
|
|
5096
|
-
// synthesize tool_result responses instead of bare "Continue." to
|
|
5097
|
-
// respect the Anthropic tool protocol.
|
|
5098
6238
|
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
5099
6239
|
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
5100
6240
|
if (toolUseBlocks.length > 0) {
|
|
@@ -5180,6 +6320,10 @@ function extractUsageFromSSEEvent(parsed, stats) {
|
|
|
5180
6320
|
if (typeof u.server_tool_use?.web_search_requests === "number") {
|
|
5181
6321
|
stats.webSearchRequests = u.server_tool_use.web_search_requests;
|
|
5182
6322
|
}
|
|
6323
|
+
// Capture stop_reason from message_delta for output cap escalation
|
|
6324
|
+
if (parsed.delta?.stop_reason) {
|
|
6325
|
+
sessionMetrics.lastStopReason = parsed.delta.stop_reason;
|
|
6326
|
+
}
|
|
5183
6327
|
return;
|
|
5184
6328
|
}
|
|
5185
6329
|
|