opencode-anthropic-fix 0.0.38 → 0.0.41
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 +1205 -60
- 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) {
|
|
@@ -2826,8 +3021,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2826
3021
|
const accountSpecific = isAccountSpecificError(response.status, errorBody);
|
|
2827
3022
|
|
|
2828
3023
|
// x-should-retry: true forces a retry for service-wide errors (RE doc §5.5)
|
|
2829
|
-
// Capped at
|
|
2830
|
-
if (shouldRetry === true && !accountSpecific && shouldRetryCount <
|
|
3024
|
+
// Capped at maxShouldRetries to prevent infinite loops (QA fix C1)
|
|
3025
|
+
if (shouldRetry === true && !accountSpecific && shouldRetryCount < maxShouldRetries) {
|
|
2831
3026
|
shouldRetryCount++;
|
|
2832
3027
|
const retryDelay = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response) ?? 2000;
|
|
2833
3028
|
debugLog("x-should-retry: true on service-wide error, sleeping before retry", {
|
|
@@ -2845,6 +3040,27 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2845
3040
|
if (accountSpecific) {
|
|
2846
3041
|
const reason = parseRateLimitReason(response.status, errorBody);
|
|
2847
3042
|
const retryAfterMs = parseRetryAfterMsHeader(response) ?? parseRetryAfterHeader(response);
|
|
3043
|
+
|
|
3044
|
+
// Transient 429: short retry-after (<=10s) is a burst throttle.
|
|
3045
|
+
// Retry on the SAME account instead of rotating — avoids wasting
|
|
3046
|
+
// the account pool on momentary rate spikes.
|
|
3047
|
+
if (
|
|
3048
|
+
response.status === 429 &&
|
|
3049
|
+
reason === "RATE_LIMIT_EXCEEDED" &&
|
|
3050
|
+
retryAfterMs != null &&
|
|
3051
|
+
retryAfterMs > 0 &&
|
|
3052
|
+
retryAfterMs <= TRANSIENT_RETRY_THRESHOLD_MS
|
|
3053
|
+
) {
|
|
3054
|
+
debugLog("transient 429: sleeping before same-account retry", {
|
|
3055
|
+
retryAfterMs,
|
|
3056
|
+
account: account.email || `Account ${account.index + 1}`,
|
|
3057
|
+
});
|
|
3058
|
+
await new Promise((r) => setTimeout(r, retryAfterMs));
|
|
3059
|
+
// Decrement attempt so this transient retry doesn't consume an account slot
|
|
3060
|
+
attempt--;
|
|
3061
|
+
continue;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
2848
3064
|
accountManager.markRateLimited(account, reason, retryAfterMs);
|
|
2849
3065
|
|
|
2850
3066
|
// On auth failures, clear token so next selection forces refresh
|
|
@@ -2882,8 +3098,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2882
3098
|
}
|
|
2883
3099
|
|
|
2884
3100
|
// 529 (overloaded) and 503 (service unavailable) — brief sleep-and-retry
|
|
2885
|
-
// per RE doc
|
|
2886
|
-
if (
|
|
3101
|
+
// per RE doc u00a75.5 (Stainless SDK retries 500+ codes up to maxServiceRetries times)
|
|
3102
|
+
if (
|
|
3103
|
+
(response.status === 529 || response.status === 503) &&
|
|
3104
|
+
serviceWideRetryCount < maxServiceRetries
|
|
3105
|
+
) {
|
|
2887
3106
|
serviceWideRetryCount++;
|
|
2888
3107
|
|
|
2889
3108
|
// Track consecutive 529s for model fallback
|
|
@@ -2925,21 +3144,71 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2925
3144
|
const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 8);
|
|
2926
3145
|
const jitter = 1 - Math.random() * 0.25;
|
|
2927
3146
|
const sleepMs = Math.round(baseDelay * jitter * 1000);
|
|
2928
|
-
|
|
3147
|
+
const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
|
|
3148
|
+
debugLog(`service-wide ${retryLabel} error, sleeping before retry`, {
|
|
2929
3149
|
status: response.status,
|
|
2930
3150
|
attempt: serviceWideRetryCount,
|
|
3151
|
+
maxRetries: maxServiceRetries,
|
|
2931
3152
|
sleepMs,
|
|
2932
3153
|
});
|
|
3154
|
+
toast(
|
|
3155
|
+
`API ${retryLabel} (${response.status}): retry ${serviceWideRetryCount}/${maxServiceRetries} in ${(sleepMs / 1000).toFixed(1)}s`,
|
|
3156
|
+
"warning",
|
|
3157
|
+
{ debounceKey: "service-retry" },
|
|
3158
|
+
).catch(() => {});
|
|
2933
3159
|
await new Promise((r) => setTimeout(r, sleepMs));
|
|
2934
3160
|
// Decrement attempt so this retry doesn't consume an account slot
|
|
2935
3161
|
attempt--;
|
|
2936
3162
|
continue;
|
|
2937
3163
|
}
|
|
2938
3164
|
|
|
2939
|
-
// Non-retryable service-wide error —
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
3165
|
+
// Non-retryable service-wide error — attempt quota-aware account switch for 529
|
|
3166
|
+
if (
|
|
3167
|
+
response.status === 529 &&
|
|
3168
|
+
accountManager &&
|
|
3169
|
+
account &&
|
|
3170
|
+
config.overload_recovery?.enabled !== false &&
|
|
3171
|
+
!_overloadRecoveryAttempted
|
|
3172
|
+
) {
|
|
3173
|
+
_overloadRecoveryAttempted = true;
|
|
3174
|
+
const recovery = tryQuotaAwareAccountSwitch(account, accountManager, config);
|
|
3175
|
+
if (recovery.switched && recovery.nextAccount) {
|
|
3176
|
+
// Fire-and-forget: poll quota for the overloaded account in background
|
|
3177
|
+
if (config.overload_recovery?.poll_quota_on_overload && account?.access) {
|
|
3178
|
+
pollOAuthUsage(config, account.access).catch(() => {});
|
|
3179
|
+
}
|
|
3180
|
+
const fromName = account.email || `Account ${account.index + 1}`;
|
|
3181
|
+
const toName = recovery.nextAccount.email || `Account ${recovery.nextAccount.index + 1}`;
|
|
3182
|
+
const cooldownMin = Math.ceil(recovery.cooldownMs / 60_000);
|
|
3183
|
+
toast(`529 overloaded: ${fromName} → ${toName} (cooldown ${cooldownMin}m)`, "warning", {
|
|
3184
|
+
debounceKey: "overload-switch",
|
|
3185
|
+
}).catch(() => {});
|
|
3186
|
+
debugLog("overload recovery: retrying with new account", {
|
|
3187
|
+
from: account.index,
|
|
3188
|
+
to: recovery.nextAccount.index,
|
|
3189
|
+
cooldownMs: recovery.cooldownMs,
|
|
3190
|
+
});
|
|
3191
|
+
// Don't consume an attempt slot — this is a recovery switch
|
|
3192
|
+
attempt--;
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
// Could not switch — build comprehensive error message and toast
|
|
3197
|
+
const errorMsg = buildOverloadErrorMessage(
|
|
3198
|
+
account,
|
|
3199
|
+
accountManager,
|
|
3200
|
+
serviceWideRetryCount,
|
|
3201
|
+
maxServiceRetries,
|
|
3202
|
+
);
|
|
3203
|
+
toast(errorMsg, "error", { debounceKey: "overload-exhausted" }).catch(() => {});
|
|
3204
|
+
debugLog("overload recovery: all accounts exhausted", {
|
|
3205
|
+
errorMsg,
|
|
3206
|
+
});
|
|
3207
|
+
} else {
|
|
3208
|
+
debugLog("service-wide response error, returning directly", {
|
|
3209
|
+
status: response.status,
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
2943
3212
|
return transformResponse(response);
|
|
2944
3213
|
}
|
|
2945
3214
|
|
|
@@ -3016,6 +3285,86 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3016
3285
|
() => {},
|
|
3017
3286
|
);
|
|
3018
3287
|
}
|
|
3288
|
+
|
|
3289
|
+
// Cache break detection (post-call)
|
|
3290
|
+
if (config.cache_break_detection?.enabled) {
|
|
3291
|
+
const cacheRead = usage.cacheReadTokens || 0;
|
|
3292
|
+
const threshold = config.cache_break_detection.alert_threshold ?? 2_000;
|
|
3293
|
+
|
|
3294
|
+
// Only alert after the first turn (need a baseline)
|
|
3295
|
+
if (
|
|
3296
|
+
cacheBreakState.prevCacheRead > 0 &&
|
|
3297
|
+
cacheBreakState.prevCacheRead - cacheRead > threshold &&
|
|
3298
|
+
cacheBreakState.lastAlertTurn !== sessionMetrics.turns
|
|
3299
|
+
) {
|
|
3300
|
+
const drop = cacheBreakState.prevCacheRead - cacheRead;
|
|
3301
|
+
let alertMsg = `Cache break detected (u2212${drop.toLocaleString()} tokens)`;
|
|
3302
|
+
|
|
3303
|
+
// Identify changed sources if we have pending hashes
|
|
3304
|
+
if (cacheBreakState._pendingHashes && cacheBreakState.sourceHashes.size > 0) {
|
|
3305
|
+
const changedSources = detectCacheBreakSources(
|
|
3306
|
+
cacheBreakState._pendingHashes,
|
|
3307
|
+
cacheBreakState.sourceHashes,
|
|
3308
|
+
);
|
|
3309
|
+
if (changedSources.length > 0) {
|
|
3310
|
+
alertMsg += `: ${changedSources.join(", ")} changed`;
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
toast(alertMsg, "warning", { debounceKey: "cache-break" }).catch(() => {});
|
|
3315
|
+
cacheBreakState.lastAlertTurn = sessionMetrics.turns;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
cacheBreakState.prevCacheRead = cacheRead;
|
|
3319
|
+
// Store current hashes as baseline for next comparison
|
|
3320
|
+
if (cacheBreakState._pendingHashes) {
|
|
3321
|
+
cacheBreakState.sourceHashes = cacheBreakState._pendingHashes;
|
|
3322
|
+
delete cacheBreakState._pendingHashes;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
// Rate limit awareness: periodic usage endpoint polling (A6)
|
|
3327
|
+
const shouldPollUsage =
|
|
3328
|
+
sessionMetrics.turns % 10 === 0 ||
|
|
3329
|
+
Date.now() - sessionMetrics.lastQuota.lastPollAt > 5 * 60_000;
|
|
3330
|
+
if (shouldPollUsage && accessToken) {
|
|
3331
|
+
pollOAuthUsage(config, accessToken)
|
|
3332
|
+
.then(() => {
|
|
3333
|
+
// Check warning levels after poll
|
|
3334
|
+
const level5h = computeQuotaWarningLevel(sessionMetrics.lastQuota.fiveHour);
|
|
3335
|
+
const level7d = computeQuotaWarningLevel(sessionMetrics.lastQuota.sevenDay);
|
|
3336
|
+
const highestLevel =
|
|
3337
|
+
level5h === "danger" || level7d === "danger"
|
|
3338
|
+
? "danger"
|
|
3339
|
+
: level5h === "warning" || level7d === "warning"
|
|
3340
|
+
? "warning"
|
|
3341
|
+
: level5h === "caution" || level7d === "caution"
|
|
3342
|
+
? "caution"
|
|
3343
|
+
: null;
|
|
3344
|
+
|
|
3345
|
+
if (highestLevel === "danger") {
|
|
3346
|
+
toast(
|
|
3347
|
+
`Usage limit: \u226425% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3348
|
+
"warning",
|
|
3349
|
+
{ debounceKey: "usage-danger" },
|
|
3350
|
+
).catch(() => {});
|
|
3351
|
+
} else if (highestLevel === "warning") {
|
|
3352
|
+
toast(
|
|
3353
|
+
`Usage limit: \u226450% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3354
|
+
"warning",
|
|
3355
|
+
{ debounceKey: "usage-warning" },
|
|
3356
|
+
).catch(() => {});
|
|
3357
|
+
} else if (highestLevel === "caution" && !quotaWarningState.cautionShown) {
|
|
3358
|
+
quotaWarningState.cautionShown = true;
|
|
3359
|
+
toast(
|
|
3360
|
+
`Usage limit: \u226475% remaining (5h: ${sessionMetrics.lastQuota.fiveHour.utilization.toFixed(0)}%, 7d: ${sessionMetrics.lastQuota.sevenDay.utilization.toFixed(0)}%)`,
|
|
3361
|
+
"info",
|
|
3362
|
+
{ debounceKey: "usage-caution" },
|
|
3363
|
+
).catch(() => {});
|
|
3364
|
+
}
|
|
3365
|
+
})
|
|
3366
|
+
.catch(() => {});
|
|
3367
|
+
}
|
|
3019
3368
|
}
|
|
3020
3369
|
: null;
|
|
3021
3370
|
|
|
@@ -3178,6 +3527,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3178
3527
|
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
3179
3528
|
adaptiveContextState.escalatedByError = false;
|
|
3180
3529
|
|
|
3530
|
+
// Reset cache break detection state on compaction
|
|
3531
|
+
cacheBreakState.prevCacheRead = 0;
|
|
3532
|
+
cacheBreakState.sourceHashes = new Map();
|
|
3533
|
+
cacheBreakState.lastAlertTurn = 0;
|
|
3534
|
+
|
|
3535
|
+
microcompactState.active = false;
|
|
3536
|
+
microcompactState.lastActivatedTurn = 0;
|
|
3537
|
+
|
|
3181
3538
|
// Inject Anthropic-specific context into compaction
|
|
3182
3539
|
if (!accountManager) return;
|
|
3183
3540
|
const account = accountManager.getCurrentAccount();
|
|
@@ -3206,7 +3563,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
3206
3563
|
// Session-level cache & cost tracking (Phase 4)
|
|
3207
3564
|
// ---------------------------------------------------------------------------
|
|
3208
3565
|
|
|
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}}} */
|
|
3566
|
+
/** @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
3567
|
const sessionMetrics = {
|
|
3211
3568
|
turns: 0,
|
|
3212
3569
|
totalInput: 0,
|
|
@@ -3218,7 +3575,27 @@ const sessionMetrics = {
|
|
|
3218
3575
|
sessionCostUsd: 0,
|
|
3219
3576
|
costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
3220
3577
|
sessionStartTime: Date.now(),
|
|
3221
|
-
lastQuota: {
|
|
3578
|
+
lastQuota: {
|
|
3579
|
+
tokens: 0,
|
|
3580
|
+
requests: 0,
|
|
3581
|
+
inputTokens: 0,
|
|
3582
|
+
updatedAt: 0,
|
|
3583
|
+
// Usage endpoint polling (A6)
|
|
3584
|
+
fiveHour: { utilization: 0, resets_at: null },
|
|
3585
|
+
sevenDay: { utilization: 0, resets_at: null },
|
|
3586
|
+
lastPollAt: 0,
|
|
3587
|
+
},
|
|
3588
|
+
lastStopReason: null, // tracks most recent stop_reason for output cap escalation
|
|
3589
|
+
perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
|
|
3590
|
+
lastModelId: null,
|
|
3591
|
+
lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
|
|
3592
|
+
/** Token budget tracking (A9) */
|
|
3593
|
+
tokenBudget: {
|
|
3594
|
+
limit: 0, // 0 = unset
|
|
3595
|
+
used: 0, // accumulated output tokens
|
|
3596
|
+
continuations: 0,
|
|
3597
|
+
outputHistory: [], // last 5 output token deltas
|
|
3598
|
+
},
|
|
3222
3599
|
};
|
|
3223
3600
|
|
|
3224
3601
|
// ---------------------------------------------------------------------------
|
|
@@ -3241,6 +3618,495 @@ const adaptiveContextState = {
|
|
|
3241
3618
|
escalatedByError: false,
|
|
3242
3619
|
};
|
|
3243
3620
|
|
|
3621
|
+
// ---------------------------------------------------------------------------
|
|
3622
|
+
// Cache break detection state (Phase 2, Task 2.3)
|
|
3623
|
+
// ---------------------------------------------------------------------------
|
|
3624
|
+
|
|
3625
|
+
/**
|
|
3626
|
+
* Tracks cache source hashes and previous cache_read_input_tokens to detect
|
|
3627
|
+
* cache breaks (e.g. system prompt or tool schema changes).
|
|
3628
|
+
*
|
|
3629
|
+
* @type {{ prevCacheRead: number, sourceHashes: Map<string, string>, lastAlertTurn: number }}
|
|
3630
|
+
*/
|
|
3631
|
+
const cacheBreakState = {
|
|
3632
|
+
prevCacheRead: 0,
|
|
3633
|
+
sourceHashes: new Map(),
|
|
3634
|
+
lastAlertTurn: 0,
|
|
3635
|
+
};
|
|
3636
|
+
|
|
3637
|
+
// ---------------------------------------------------------------------------
|
|
3638
|
+
// Microcompact state (Phase 3, Task 3.4)
|
|
3639
|
+
// ---------------------------------------------------------------------------
|
|
3640
|
+
|
|
3641
|
+
/**
|
|
3642
|
+
* Tracks whether microcompact betas are currently active.
|
|
3643
|
+
* @type {{ active: boolean, lastActivatedTurn: number }}
|
|
3644
|
+
*/
|
|
3645
|
+
const microcompactState = {
|
|
3646
|
+
active: false,
|
|
3647
|
+
lastActivatedTurn: 0,
|
|
3648
|
+
};
|
|
3649
|
+
|
|
3650
|
+
/**
|
|
3651
|
+
* Determine if microcompact betas should be injected based on estimated token usage.
|
|
3652
|
+
* @param {number} estimatedTokens - Estimated prompt token count
|
|
3653
|
+
* @param {object} config - Plugin config
|
|
3654
|
+
* @returns {boolean}
|
|
3655
|
+
*/
|
|
3656
|
+
function shouldMicrocompact(estimatedTokens, config) {
|
|
3657
|
+
if (!config.microcompact?.enabled) return false;
|
|
3658
|
+
const thresholdPct = config.microcompact.threshold_percent ?? 80;
|
|
3659
|
+
// Use the model's context window. Default to 200K if unknown.
|
|
3660
|
+
// Adaptive context may escalate to 1M, but we use the base 200K for threshold
|
|
3661
|
+
// to be conservative (microcompact at 160K tokens is still valuable).
|
|
3662
|
+
const contextWindow = 200_000;
|
|
3663
|
+
const threshold = contextWindow * (thresholdPct / 100);
|
|
3664
|
+
return estimatedTokens >= threshold;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/**
|
|
3668
|
+
* Build the list of microcompact betas to inject.
|
|
3669
|
+
* @returns {string[]} Array of beta flag strings
|
|
3670
|
+
*/
|
|
3671
|
+
function buildMicrocompactBetas() {
|
|
3672
|
+
return ["clear_tool_uses_20250919", "clear_thinking_20251015"];
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
/**
|
|
3676
|
+
* Hash a string for cache source fingerprinting.
|
|
3677
|
+
* @param {string} content
|
|
3678
|
+
* @returns {string} 16-char hex hash
|
|
3679
|
+
*/
|
|
3680
|
+
function hashCacheSource(content) {
|
|
3681
|
+
return createHashCrypto("sha256").update(content).digest("hex").slice(0, 16);
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
// ---------------------------------------------------------------------------
|
|
3685
|
+
// OAuth usage endpoint polling (A6)
|
|
3686
|
+
// ---------------------------------------------------------------------------
|
|
3687
|
+
|
|
3688
|
+
/**
|
|
3689
|
+
* Poll the /api/oauth/usage endpoint for session/weekly utilization.
|
|
3690
|
+
* Fire-and-forget: non-2xx responses are silently ignored.
|
|
3691
|
+
* @param {object} config
|
|
3692
|
+
* @param {string} accessToken
|
|
3693
|
+
*/
|
|
3694
|
+
async function pollOAuthUsage(config, accessToken) {
|
|
3695
|
+
try {
|
|
3696
|
+
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
3697
|
+
method: "GET",
|
|
3698
|
+
headers: {
|
|
3699
|
+
authorization: `Bearer ${accessToken}`,
|
|
3700
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
3701
|
+
accept: "application/json",
|
|
3702
|
+
},
|
|
3703
|
+
signal: AbortSignal.timeout(5_000),
|
|
3704
|
+
});
|
|
3705
|
+
if (!resp.ok) {
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
const data = await resp.json();
|
|
3709
|
+
if (data.five_hour) {
|
|
3710
|
+
sessionMetrics.lastQuota.fiveHour = {
|
|
3711
|
+
utilization: data.five_hour.utilization ?? 0,
|
|
3712
|
+
resets_at: data.five_hour.resets_at ?? null,
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
if (data.seven_day) {
|
|
3716
|
+
sessionMetrics.lastQuota.sevenDay = {
|
|
3717
|
+
utilization: data.seven_day.utilization ?? 0,
|
|
3718
|
+
resets_at: data.seven_day.resets_at ?? null,
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
3721
|
+
sessionMetrics.lastQuota.lastPollAt = Date.now();
|
|
3722
|
+
} catch {
|
|
3723
|
+
// Polling is fire-and-forget; errors are silently swallowed.
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/** @type {{ cautionShown: boolean }} */
|
|
3728
|
+
const quotaWarningState = { cautionShown: false };
|
|
3729
|
+
|
|
3730
|
+
/**
|
|
3731
|
+
* Compute warning level based on utilization percentage.
|
|
3732
|
+
* @param {{ utilization: number }} quota - utilization is 0-100
|
|
3733
|
+
* @returns {"danger" | "warning" | "caution" | null}
|
|
3734
|
+
*/
|
|
3735
|
+
function computeQuotaWarningLevel(quota) {
|
|
3736
|
+
if (!quota || typeof quota.utilization !== "number") return null;
|
|
3737
|
+
const remaining = 100 - quota.utilization;
|
|
3738
|
+
if (remaining <= 25) return "danger";
|
|
3739
|
+
if (remaining <= 50) return "warning";
|
|
3740
|
+
if (remaining <= 75) return "caution";
|
|
3741
|
+
return null;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
// ---------------------------------------------------------------------------
|
|
3745
|
+
// Overload recovery: quota-aware account switching on 529 exhaustion (3.5)
|
|
3746
|
+
// ---------------------------------------------------------------------------
|
|
3747
|
+
|
|
3748
|
+
/**
|
|
3749
|
+
* Build a comprehensive error message for 529/overloaded errors.
|
|
3750
|
+
* Includes quota info, account status, and reset times.
|
|
3751
|
+
*
|
|
3752
|
+
* @param {object} account - Current account
|
|
3753
|
+
* @param {object} accountManager - Account manager instance
|
|
3754
|
+
* @param {number} serviceWideRetryCount - How many 529 retries were attempted
|
|
3755
|
+
* @param {number} maxServiceRetries - Max allowed retries
|
|
3756
|
+
* @returns {string}
|
|
3757
|
+
*/
|
|
3758
|
+
function buildOverloadErrorMessage(account, accountManager, serviceWideRetryCount, maxServiceRetries) {
|
|
3759
|
+
const accountName = account?.email || `Account ${(account?.index ?? 0) + 1}`;
|
|
3760
|
+
const totalAccounts = accountManager?.getAccountCount() ?? 1;
|
|
3761
|
+
const parts = [
|
|
3762
|
+
`Anthropic API overloaded (529).`,
|
|
3763
|
+
`Retried ${serviceWideRetryCount}/${maxServiceRetries} times on ${accountName}.`,
|
|
3764
|
+
];
|
|
3765
|
+
|
|
3766
|
+
// Add quota information if available
|
|
3767
|
+
const fh = sessionMetrics.lastQuota.fiveHour;
|
|
3768
|
+
const sd = sessionMetrics.lastQuota.sevenDay;
|
|
3769
|
+
if (fh?.utilization > 0 || sd?.utilization > 0) {
|
|
3770
|
+
parts.push(
|
|
3771
|
+
`Quota: 5h=${fh?.utilization?.toFixed(0) ?? "?"}%` +
|
|
3772
|
+
(fh?.resets_at ? ` (resets ${formatResetTime(fh.resets_at)})` : "") +
|
|
3773
|
+
`, 7d=${sd?.utilization?.toFixed(0) ?? "?"}%` +
|
|
3774
|
+
(sd?.resets_at ? ` (resets ${formatResetTime(sd.resets_at)})` : ""),
|
|
3775
|
+
);
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
if (totalAccounts > 1) {
|
|
3779
|
+
parts.push(`Tried switching across ${totalAccounts} accounts — all exhausted or overloaded.`);
|
|
3780
|
+
} else {
|
|
3781
|
+
parts.push(`Only 1 account configured. Add more accounts with '/anthropic login' for automatic failover.`);
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
parts.push(`Wait a few minutes or switch models with a smaller context window.`);
|
|
3785
|
+
return parts.join(" ");
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
/**
|
|
3789
|
+
* Format a reset timestamp into a human-readable relative string.
|
|
3790
|
+
* @param {string | null} isoTimestamp
|
|
3791
|
+
* @returns {string}
|
|
3792
|
+
*/
|
|
3793
|
+
function formatResetTime(isoTimestamp) {
|
|
3794
|
+
if (!isoTimestamp) return "unknown";
|
|
3795
|
+
try {
|
|
3796
|
+
const resetMs = new Date(isoTimestamp).getTime();
|
|
3797
|
+
if (isNaN(resetMs)) return "unknown";
|
|
3798
|
+
const diffMs = resetMs - Date.now();
|
|
3799
|
+
if (diffMs <= 0) return "now";
|
|
3800
|
+
const mins = Math.ceil(diffMs / 60_000);
|
|
3801
|
+
if (mins < 60) return `~${mins}m`;
|
|
3802
|
+
const hours = Math.round(mins / 60);
|
|
3803
|
+
return `~${hours}h`;
|
|
3804
|
+
} catch {
|
|
3805
|
+
return "unknown";
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
/**
|
|
3810
|
+
* Attempt quota-aware account switch after 529 retries are exhausted.
|
|
3811
|
+
* Polls quota, marks current account with cooldown, tries to switch.
|
|
3812
|
+
*
|
|
3813
|
+
* @param {object} account - Current (overloaded) account
|
|
3814
|
+
* @param {object} accountManager - Account manager
|
|
3815
|
+
* @param {object} config - Plugin config
|
|
3816
|
+
* @returns {{ switched: boolean, nextAccount: object | null, cooldownMs: number }}
|
|
3817
|
+
*/
|
|
3818
|
+
function tryQuotaAwareAccountSwitch(account, accountManager, config) {
|
|
3819
|
+
const result = { switched: false, nextAccount: null, cooldownMs: 0 };
|
|
3820
|
+
if (!config.overload_recovery?.enabled) return result;
|
|
3821
|
+
|
|
3822
|
+
const defaultCooldown = config.overload_recovery.default_cooldown_ms ?? 60_000;
|
|
3823
|
+
|
|
3824
|
+
// Use cached quota data for smarter cooldown (no HTTP calls in retry path)
|
|
3825
|
+
let cooldownMs = defaultCooldown;
|
|
3826
|
+
const fh = sessionMetrics.lastQuota.fiveHour;
|
|
3827
|
+
if (fh?.resets_at) {
|
|
3828
|
+
try {
|
|
3829
|
+
const resetMs = new Date(fh.resets_at).getTime();
|
|
3830
|
+
if (!isNaN(resetMs) && resetMs > Date.now()) {
|
|
3831
|
+
// Set cooldown to last until quota resets (capped at 30 min)
|
|
3832
|
+
cooldownMs = Math.min(resetMs - Date.now(), 30 * 60_000);
|
|
3833
|
+
}
|
|
3834
|
+
} catch {
|
|
3835
|
+
// Date parse failed, use default cooldown
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
// Mark current account with cooldown
|
|
3840
|
+
if (account && accountManager) {
|
|
3841
|
+
accountManager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", cooldownMs);
|
|
3842
|
+
result.cooldownMs = cooldownMs;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// Try to get a different account
|
|
3846
|
+
if (accountManager && accountManager.getAccountCount() > 0) {
|
|
3847
|
+
const nextAccount = accountManager.getCurrentAccount();
|
|
3848
|
+
if (nextAccount && nextAccount.index !== account?.index) {
|
|
3849
|
+
result.switched = true;
|
|
3850
|
+
result.nextAccount = nextAccount;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
return result;
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
/**
|
|
3858
|
+
* Extract cache source hashes from a request body.
|
|
3859
|
+
* Hashes system prompt blocks and tool schemas to identify what changed.
|
|
3860
|
+
*
|
|
3861
|
+
* @param {string} bodyStr - JSON request body
|
|
3862
|
+
* @returns {Map<string, string>} source_id → hash
|
|
3863
|
+
*/
|
|
3864
|
+
function extractCacheSourceHashes(bodyStr) {
|
|
3865
|
+
const hashes = new Map();
|
|
3866
|
+
try {
|
|
3867
|
+
const parsed = JSON.parse(bodyStr);
|
|
3868
|
+
|
|
3869
|
+
// Hash system prompt (excluding token budget blocks injected by injectTokenBudgetBlock)
|
|
3870
|
+
if (Array.isArray(parsed.system)) {
|
|
3871
|
+
const systemText = parsed.system
|
|
3872
|
+
.filter((b) => !(b.text && b.text.startsWith("Token budget:")))
|
|
3873
|
+
.map((b) => b.text || "")
|
|
3874
|
+
.join("");
|
|
3875
|
+
if (systemText) hashes.set("system_prompt", hashCacheSource(systemText));
|
|
3876
|
+
} else if (typeof parsed.system === "string" && parsed.system) {
|
|
3877
|
+
hashes.set("system_prompt", hashCacheSource(parsed.system));
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
// Hash tool schemas (by name)
|
|
3881
|
+
if (Array.isArray(parsed.tools)) {
|
|
3882
|
+
for (const tool of parsed.tools) {
|
|
3883
|
+
if (tool.name) {
|
|
3884
|
+
hashes.set(`tool:${tool.name}`, hashCacheSource(JSON.stringify(tool)));
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
} catch {
|
|
3889
|
+
// Ignore parse errors
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
// LRU eviction: cap at 10 entries
|
|
3893
|
+
if (hashes.size > 10) {
|
|
3894
|
+
const entries = [...hashes.entries()];
|
|
3895
|
+
return new Map(entries.slice(entries.length - 10));
|
|
3896
|
+
}
|
|
3897
|
+
return hashes;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
/**
|
|
3901
|
+
* Detect cache break by comparing current vs previous source hashes.
|
|
3902
|
+
* @param {Map<string, string>} currentHashes
|
|
3903
|
+
* @param {Map<string, string>} previousHashes
|
|
3904
|
+
* @returns {string[]} Names of changed sources, or empty array
|
|
3905
|
+
*/
|
|
3906
|
+
function detectCacheBreakSources(currentHashes, previousHashes) {
|
|
3907
|
+
if (previousHashes.size === 0) return []; // No baseline yet
|
|
3908
|
+
const changed = [];
|
|
3909
|
+
for (const [key, hash] of currentHashes) {
|
|
3910
|
+
const prev = previousHashes.get(key);
|
|
3911
|
+
if (prev && prev !== hash) {
|
|
3912
|
+
changed.push(key);
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
// Check for removed sources
|
|
3916
|
+
for (const key of previousHashes.keys()) {
|
|
3917
|
+
if (!currentHashes.has(key)) {
|
|
3918
|
+
changed.push(key);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
return changed;
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
/**
|
|
3925
|
+
* Parse the structured context limit error message from the Anthropic API.
|
|
3926
|
+
* @param {string | null | undefined} msg - Error body text
|
|
3927
|
+
* @returns {{ input: number, maxTokens: number, limit: number } | null}
|
|
3928
|
+
*/
|
|
3929
|
+
function parseContextLimitError(msg) {
|
|
3930
|
+
if (!msg || typeof msg !== "string") return null;
|
|
3931
|
+
const m = msg.match(/input length and `max_tokens` exceed context limit:\s*(\d+)\s*\+\s*(\d+)\s*>\s*(\d+)/);
|
|
3932
|
+
if (!m) return null;
|
|
3933
|
+
return { input: +m[1], maxTokens: +m[2], limit: +m[3] };
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
/**
|
|
3937
|
+
* Compute a safe max_tokens value that fits within the context limit.
|
|
3938
|
+
* @param {number} input - Input token count from error
|
|
3939
|
+
* @param {number} limit - Context window limit from error
|
|
3940
|
+
* @param {number} [margin=1000] - Safety margin to subtract
|
|
3941
|
+
* @returns {number}
|
|
3942
|
+
*/
|
|
3943
|
+
function computeSafeMaxTokens(input, limit, margin = 1000) {
|
|
3944
|
+
return Math.max(1, limit - input - margin);
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
/**
|
|
3948
|
+
* Detect whether the environment uses a proxy or custom mTLS configuration.
|
|
3949
|
+
* Pure predicate — no side effects.
|
|
3950
|
+
* @returns {boolean}
|
|
3951
|
+
*/
|
|
3952
|
+
function isProxyOrMtlsEnvironment() {
|
|
3953
|
+
const proxyVars = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY"];
|
|
3954
|
+
const mtlsVars = ["NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE"];
|
|
3955
|
+
for (const v of proxyVars) {
|
|
3956
|
+
if (process.env[v]) return true;
|
|
3957
|
+
}
|
|
3958
|
+
for (const v of mtlsVars) {
|
|
3959
|
+
if (process.env[v]) return true;
|
|
3960
|
+
}
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
/**
|
|
3965
|
+
* Fire-and-forget HEAD request to pre-warm TCP+TLS connection pool.
|
|
3966
|
+
* Skips in proxy/mTLS environments where the HEAD may be intercepted.
|
|
3967
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
3968
|
+
*/
|
|
3969
|
+
async function preconnectApi(config) {
|
|
3970
|
+
if (!config.preconnect?.enabled) return;
|
|
3971
|
+
if (isProxyOrMtlsEnvironment()) return;
|
|
3972
|
+
try {
|
|
3973
|
+
await Promise.race([
|
|
3974
|
+
globalThis.fetch("https://api.anthropic.com", { method: "HEAD" }),
|
|
3975
|
+
new Promise((_, r) =>
|
|
3976
|
+
setTimeout(() => r(new Error("preconnect timeout")), config.preconnect.timeout_ms ?? 10_000),
|
|
3977
|
+
),
|
|
3978
|
+
]);
|
|
3979
|
+
} catch {
|
|
3980
|
+
/* fire-and-forget — never throws */
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/**
|
|
3985
|
+
* Classify an API request as foreground (user-initiated) or background
|
|
3986
|
+
* (title generation, speculation). Background requests receive a reduced
|
|
3987
|
+
* retry budget to preserve quota for user-facing work.
|
|
3988
|
+
*
|
|
3989
|
+
* @param {object|string} body - Parsed request body (or raw string to parse)
|
|
3990
|
+
* @returns {"foreground" | "background"}
|
|
3991
|
+
*/
|
|
3992
|
+
function classifyApiRequest(body) {
|
|
3993
|
+
try {
|
|
3994
|
+
const parsed = typeof body === "string" ? JSON.parse(body) : body;
|
|
3995
|
+
if (!parsed || typeof parsed !== "object") return "foreground";
|
|
3996
|
+
|
|
3997
|
+
const msgCount = parsed.messages?.length ?? 0;
|
|
3998
|
+
const maxToks = parsed.max_tokens ?? 99999;
|
|
3999
|
+
|
|
4000
|
+
// Title generation signal: system prompt contains "Generate a short title"
|
|
4001
|
+
const systemBlocks = Array.isArray(parsed.system) ? parsed.system : [];
|
|
4002
|
+
const hasTitleSignal = systemBlocks.some(
|
|
4003
|
+
(b) => typeof b.text === "string" && b.text.includes("Generate a short title"),
|
|
4004
|
+
);
|
|
4005
|
+
|
|
4006
|
+
// Background: title generation OR very short context with tiny output
|
|
4007
|
+
if (hasTitleSignal) return "background";
|
|
4008
|
+
if (msgCount <= 2 && maxToks <= 256) return "background";
|
|
4009
|
+
|
|
4010
|
+
return "foreground";
|
|
4011
|
+
} catch {
|
|
4012
|
+
return "foreground"; // Parse error → safe default
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// ---------------------------------------------------------------------------
|
|
4017
|
+
// Token Budget Parsing & Enforcement (A9)
|
|
4018
|
+
// ---------------------------------------------------------------------------
|
|
4019
|
+
|
|
4020
|
+
/**
|
|
4021
|
+
* Parse natural-language budget expressions from user messages.
|
|
4022
|
+
* Supports: +500k, 500,000, 2M, 2 million, "spend 500k", "use 2M tokens", "budget: 1M".
|
|
4023
|
+
* Only scans the last user message to avoid re-triggering from history.
|
|
4024
|
+
*
|
|
4025
|
+
* @param {Array<{role: string, content: string | Array<{type: string, text?: string}>}>} messages
|
|
4026
|
+
* @returns {number} Parsed token count, or 0 if no budget expression found
|
|
4027
|
+
*/
|
|
4028
|
+
function parseNaturalLanguageBudget(messages) {
|
|
4029
|
+
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
|
4030
|
+
|
|
4031
|
+
// Find the last user message
|
|
4032
|
+
let lastUserText = "";
|
|
4033
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4034
|
+
if (messages[i].role === "user") {
|
|
4035
|
+
const content = messages[i].content;
|
|
4036
|
+
if (typeof content === "string") {
|
|
4037
|
+
lastUserText = content;
|
|
4038
|
+
} else if (Array.isArray(content)) {
|
|
4039
|
+
lastUserText = content
|
|
4040
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
4041
|
+
.map((b) => b.text)
|
|
4042
|
+
.join(" ");
|
|
4043
|
+
}
|
|
4044
|
+
break;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
if (!lastUserText) return 0;
|
|
4048
|
+
|
|
4049
|
+
// Patterns ordered from most specific to least
|
|
4050
|
+
const patterns = [
|
|
4051
|
+
/\buse\s+(\d[\d,]*)\s*([mk])\s*tokens?\b/i,
|
|
4052
|
+
/\bspend\s+(\d[\d,]*)\s*([mk])?\b/i,
|
|
4053
|
+
/\bbudget[:\s]+(\d[\d,]*)\s*([mk])?\b/i,
|
|
4054
|
+
/\+(\d[\d,]*)\s*([mk])\b/i,
|
|
4055
|
+
/\b(\d[\d,]*)\s*million\s*tokens?\b/i,
|
|
4056
|
+
];
|
|
4057
|
+
|
|
4058
|
+
for (const re of patterns) {
|
|
4059
|
+
const m = lastUserText.match(re);
|
|
4060
|
+
if (m) {
|
|
4061
|
+
const num = parseFloat(m[1].replace(/,/g, ""));
|
|
4062
|
+
if (isNaN(num) || num <= 0) continue;
|
|
4063
|
+
const suffix = (m[2] || "").toLowerCase();
|
|
4064
|
+
if (re === patterns[4]) {
|
|
4065
|
+
// "N million tokens" — the regex has no suffix group
|
|
4066
|
+
return num * 1_000_000;
|
|
4067
|
+
}
|
|
4068
|
+
if (suffix === "m") return num * 1_000_000;
|
|
4069
|
+
if (suffix === "k") return num * 1_000;
|
|
4070
|
+
// No suffix — treat as absolute count
|
|
4071
|
+
return num;
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
return 0;
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
/**
|
|
4078
|
+
* Inject a token budget status block into the system prompt.
|
|
4079
|
+
* Prepends a text block with budget progress and threshold info.
|
|
4080
|
+
*
|
|
4081
|
+
* @param {Array<{type: string, text?: string, [k: string]: any}>} systemBlocks
|
|
4082
|
+
* @param {{limit: number, used: number, continuations: number}} budget
|
|
4083
|
+
* @param {number} threshold - Completion threshold (0-1, e.g. 0.9)
|
|
4084
|
+
* @returns {Array<{type: string, text?: string, [k: string]: any}>}
|
|
4085
|
+
*/
|
|
4086
|
+
function injectTokenBudgetBlock(systemBlocks, budget, threshold) {
|
|
4087
|
+
if (!budget || budget.limit <= 0) return systemBlocks;
|
|
4088
|
+
const pct = ((budget.used / budget.limit) * 100).toFixed(0);
|
|
4089
|
+
const thresholdTokens = Math.round(budget.limit * threshold);
|
|
4090
|
+
const remaining = Math.max(0, budget.limit - budget.used);
|
|
4091
|
+
const block = {
|
|
4092
|
+
type: "text",
|
|
4093
|
+
text: `Token budget: ${budget.used.toLocaleString()}/${budget.limit.toLocaleString()} tokens used (${pct}%). Stop generating at ${thresholdTokens.toLocaleString()} tokens. Remaining: ${remaining.toLocaleString()} tokens.`,
|
|
4094
|
+
};
|
|
4095
|
+
return [block, ...(systemBlocks || [])];
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
/**
|
|
4099
|
+
* Detect diminishing returns: ≥3 continuations AND last 3 output deltas all < 500 tokens.
|
|
4100
|
+
*
|
|
4101
|
+
* @param {number[]} outputHistory - Recent output token deltas
|
|
4102
|
+
* @returns {boolean}
|
|
4103
|
+
*/
|
|
4104
|
+
function detectDiminishingReturns(outputHistory) {
|
|
4105
|
+
if (!Array.isArray(outputHistory) || outputHistory.length < 3) return false;
|
|
4106
|
+
const last3 = outputHistory.slice(-3);
|
|
4107
|
+
return last3.every((d) => d < 500);
|
|
4108
|
+
}
|
|
4109
|
+
|
|
3244
4110
|
/**
|
|
3245
4111
|
* Estimate prompt token count from the raw request body string.
|
|
3246
4112
|
* Uses a 4-character-per-token heuristic (conservative for English + code).
|
|
@@ -3295,6 +4161,108 @@ function estimatePromptTokens(bodyString) {
|
|
|
3295
4161
|
}
|
|
3296
4162
|
}
|
|
3297
4163
|
|
|
4164
|
+
/**
|
|
4165
|
+
* Analyze a request body to produce a token breakdown by role and tool.
|
|
4166
|
+
* Used by `/anthropic context` command.
|
|
4167
|
+
*
|
|
4168
|
+
* @param {string} bodyStr - JSON request body string
|
|
4169
|
+
* @returns {{ systemTokens: number, userTokens: number, assistantTokens: number, toolResultTokens: number, toolBreakdown: Record<string, { tokens: number, count: number }>, totalTokens: number, duplicates: { count: number, wastedTokens: number } }}
|
|
4170
|
+
*/
|
|
4171
|
+
function analyzeRequestContext(bodyStr) {
|
|
4172
|
+
const result = {
|
|
4173
|
+
systemTokens: 0,
|
|
4174
|
+
userTokens: 0,
|
|
4175
|
+
assistantTokens: 0,
|
|
4176
|
+
toolResultTokens: 0,
|
|
4177
|
+
toolBreakdown: /** @type {Record<string, { tokens: number, count: number }>} */ ({}),
|
|
4178
|
+
totalTokens: 0,
|
|
4179
|
+
duplicates: { count: 0, wastedTokens: 0 },
|
|
4180
|
+
};
|
|
4181
|
+
|
|
4182
|
+
if (!bodyStr || typeof bodyStr !== "string") return result;
|
|
4183
|
+
|
|
4184
|
+
try {
|
|
4185
|
+
const parsed = JSON.parse(bodyStr);
|
|
4186
|
+
const contentHashes = new Map(); // hash → { tokens, count }
|
|
4187
|
+
|
|
4188
|
+
// Estimate tokens from a string (4 chars/token heuristic)
|
|
4189
|
+
const estimateTokens = (/** @type {string} */ s) => Math.ceil((s || "").length / 4);
|
|
4190
|
+
|
|
4191
|
+
// System prompt
|
|
4192
|
+
if (Array.isArray(parsed.system)) {
|
|
4193
|
+
for (const block of parsed.system) {
|
|
4194
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4195
|
+
result.systemTokens += estimateTokens(block.text);
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
} else if (typeof parsed.system === "string") {
|
|
4199
|
+
result.systemTokens += estimateTokens(parsed.system);
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
// Messages
|
|
4203
|
+
if (Array.isArray(parsed.messages)) {
|
|
4204
|
+
for (const msg of parsed.messages) {
|
|
4205
|
+
const role = msg.role || "unknown";
|
|
4206
|
+
const blocks =
|
|
4207
|
+
typeof msg.content === "string"
|
|
4208
|
+
? [{ type: "text", text: msg.content }]
|
|
4209
|
+
: Array.isArray(msg.content)
|
|
4210
|
+
? msg.content
|
|
4211
|
+
: [];
|
|
4212
|
+
|
|
4213
|
+
for (const block of blocks) {
|
|
4214
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
4215
|
+
const tokens = estimateTokens(block.text);
|
|
4216
|
+
if (role === "user") result.userTokens += tokens;
|
|
4217
|
+
else if (role === "assistant") result.assistantTokens += tokens;
|
|
4218
|
+
} else if (block.type === "tool_result") {
|
|
4219
|
+
// tool_result content can be string or array of content blocks
|
|
4220
|
+
let content = "";
|
|
4221
|
+
if (typeof block.content === "string") {
|
|
4222
|
+
content = block.content;
|
|
4223
|
+
} else if (Array.isArray(block.content)) {
|
|
4224
|
+
content = block.content.map((b) => b.text || "").join("");
|
|
4225
|
+
}
|
|
4226
|
+
const tokens = estimateTokens(content);
|
|
4227
|
+
result.toolResultTokens += tokens;
|
|
4228
|
+
result.userTokens += tokens; // tool_result is part of user turn
|
|
4229
|
+
|
|
4230
|
+
// Group by tool_name (may be on the block or need to look up from tool_use_id)
|
|
4231
|
+
const toolName = block.tool_name || block.name || "unknown_tool";
|
|
4232
|
+
if (!result.toolBreakdown[toolName]) {
|
|
4233
|
+
result.toolBreakdown[toolName] = { tokens: 0, count: 0 };
|
|
4234
|
+
}
|
|
4235
|
+
result.toolBreakdown[toolName].tokens += tokens;
|
|
4236
|
+
result.toolBreakdown[toolName].count += 1;
|
|
4237
|
+
|
|
4238
|
+
// Duplicate detection via content hash
|
|
4239
|
+
if (content.length > 0) {
|
|
4240
|
+
const hash = createHashCrypto("sha256").update(content).digest("hex").slice(0, 16);
|
|
4241
|
+
const existing = contentHashes.get(hash);
|
|
4242
|
+
if (existing) {
|
|
4243
|
+
existing.count += 1;
|
|
4244
|
+
result.duplicates.count += 1;
|
|
4245
|
+
result.duplicates.wastedTokens += tokens;
|
|
4246
|
+
} else {
|
|
4247
|
+
contentHashes.set(hash, { tokens, count: 1 });
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
} else if (block.type === "tool_use") {
|
|
4251
|
+
const tokens = estimateTokens(JSON.stringify(block.input || {}));
|
|
4252
|
+
if (role === "assistant") result.assistantTokens += tokens;
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
result.totalTokens = result.systemTokens + result.userTokens + result.assistantTokens;
|
|
4259
|
+
} catch {
|
|
4260
|
+
// Malformed JSON — return zeroes
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
return result;
|
|
4264
|
+
}
|
|
4265
|
+
|
|
3298
4266
|
/**
|
|
3299
4267
|
* Decide whether to include the context-1m beta for this request.
|
|
3300
4268
|
* Returns true if 1M context should be activated.
|
|
@@ -3478,6 +4446,31 @@ function updateSessionMetrics(usage, model) {
|
|
|
3478
4446
|
|
|
3479
4447
|
// Total cost
|
|
3480
4448
|
sessionMetrics.sessionCostUsd += calculateCostUsd(usage, model);
|
|
4449
|
+
|
|
4450
|
+
// Per-model breakdown
|
|
4451
|
+
if (model) {
|
|
4452
|
+
if (!sessionMetrics.perModel[model]) {
|
|
4453
|
+
sessionMetrics.perModel[model] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, turns: 0 };
|
|
4454
|
+
}
|
|
4455
|
+
const pm = sessionMetrics.perModel[model];
|
|
4456
|
+
pm.input += usage.inputTokens;
|
|
4457
|
+
pm.output += usage.outputTokens;
|
|
4458
|
+
pm.cacheRead += usage.cacheReadTokens;
|
|
4459
|
+
pm.cacheWrite += usage.cacheWriteTokens;
|
|
4460
|
+
pm.costUsd += calculateCostUsd(usage, model);
|
|
4461
|
+
pm.turns += 1;
|
|
4462
|
+
sessionMetrics.lastModelId = model;
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
// Token budget tracking (A9)
|
|
4466
|
+
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
4467
|
+
sessionMetrics.tokenBudget.used += usage.outputTokens;
|
|
4468
|
+
sessionMetrics.tokenBudget.continuations += 1;
|
|
4469
|
+
sessionMetrics.tokenBudget.outputHistory.push(usage.outputTokens);
|
|
4470
|
+
if (sessionMetrics.tokenBudget.outputHistory.length > 5) {
|
|
4471
|
+
sessionMetrics.tokenBudget.outputHistory.shift();
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
3481
4474
|
}
|
|
3482
4475
|
|
|
3483
4476
|
/**
|
|
@@ -3674,17 +4667,19 @@ process.once("beforeExit", () => {
|
|
|
3674
4667
|
// Request building helpers (extracted from original fetch interceptor)
|
|
3675
4668
|
// ---------------------------------------------------------------------------
|
|
3676
4669
|
|
|
3677
|
-
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.
|
|
4670
|
+
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.90";
|
|
3678
4671
|
const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
3679
|
-
const CLAUDE_CODE_BUILD_TIME = "2026-
|
|
4672
|
+
const CLAUDE_CODE_BUILD_TIME = "2026-04-01T22:53:10Z";
|
|
3680
4673
|
|
|
3681
|
-
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.
|
|
4674
|
+
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.90.
|
|
3682
4675
|
// 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 .
|
|
4676
|
+
// Verified by extracting VERSION="0.208.0" from the bundled cli.js of all versions .80-.90.
|
|
3684
4677
|
const ANTHROPIC_SDK_VERSION = "0.208.0";
|
|
3685
4678
|
|
|
3686
4679
|
// Map of CLI version → bundled SDK version (update when CLI version changes)
|
|
3687
4680
|
const CLI_TO_SDK_VERSION = new Map([
|
|
4681
|
+
["2.1.90", "0.208.0"],
|
|
4682
|
+
["2.1.89", "0.208.0"],
|
|
3688
4683
|
["2.1.88", "0.208.0"],
|
|
3689
4684
|
["2.1.87", "0.208.0"],
|
|
3690
4685
|
["2.1.86", "0.208.0"],
|
|
@@ -3725,6 +4720,96 @@ function computeBillingCacheHash(firstUserMessage, version) {
|
|
|
3725
4720
|
* @param {any[] | undefined} messages
|
|
3726
4721
|
* @returns {string}
|
|
3727
4722
|
*/
|
|
4723
|
+
/**
|
|
4724
|
+
* Strip leaked /anthropic slash command messages from conversation history.
|
|
4725
|
+
*
|
|
4726
|
+
* When a user runs `/anthropic <subcommand>`, OpenCode may still include the
|
|
4727
|
+
* command text as a user message and the sendCommandMessage output as an
|
|
4728
|
+
* assistant message in the API request. This function removes those messages
|
|
4729
|
+
* so the model never sees internal plugin commands in its context.
|
|
4730
|
+
*
|
|
4731
|
+
* Detection heuristics:
|
|
4732
|
+
* - User messages that start with `/anthropic` (with optional leading whitespace)
|
|
4733
|
+
* - User messages where the ONLY text content is a `/anthropic` command
|
|
4734
|
+
* - Assistant messages that start with the `▣ Anthropic` prefix used by sendCommandMessage
|
|
4735
|
+
*
|
|
4736
|
+
* After filtering, if the last remaining message is an assistant message, drop it
|
|
4737
|
+
* to maintain the user→assistant alternation required by the API.
|
|
4738
|
+
*
|
|
4739
|
+
* @param {Array} messages — The messages array from the parsed request body
|
|
4740
|
+
* @returns {Array} — Filtered messages array
|
|
4741
|
+
*/
|
|
4742
|
+
function stripSlashCommandMessages(messages) {
|
|
4743
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
4744
|
+
|
|
4745
|
+
// Pattern: /anthropic followed by optional subcommand
|
|
4746
|
+
const CMD_RE = /^\s*\/anthropic\b/i;
|
|
4747
|
+
// Pattern: ▣ Anthropic — prefix used by all sendCommandMessage outputs
|
|
4748
|
+
const RESP_RE = /^▣\s*Anthropic/;
|
|
4749
|
+
|
|
4750
|
+
/**
|
|
4751
|
+
* Extract the first text content from a message's content field.
|
|
4752
|
+
* Handles both string content and array-of-blocks content.
|
|
4753
|
+
*/
|
|
4754
|
+
function getFirstText(msg) {
|
|
4755
|
+
if (typeof msg.content === "string") return msg.content;
|
|
4756
|
+
if (Array.isArray(msg.content)) {
|
|
4757
|
+
for (const block of msg.content) {
|
|
4758
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return "";
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/**
|
|
4765
|
+
* Check if a user message is a /anthropic command.
|
|
4766
|
+
* A message is a command if its text content starts with /anthropic.
|
|
4767
|
+
*/
|
|
4768
|
+
function isCommandMessage(msg) {
|
|
4769
|
+
if (msg.role !== "user") return false;
|
|
4770
|
+
const text = getFirstText(msg);
|
|
4771
|
+
return CMD_RE.test(text);
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
/**
|
|
4775
|
+
* Check if an assistant message is a sendCommandMessage response.
|
|
4776
|
+
* These always start with ▣ Anthropic.
|
|
4777
|
+
*/
|
|
4778
|
+
function isCommandResponse(msg) {
|
|
4779
|
+
if (msg.role !== "assistant") return false;
|
|
4780
|
+
const text = getFirstText(msg);
|
|
4781
|
+
return RESP_RE.test(text);
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
const filtered = [];
|
|
4785
|
+
for (let i = 0; i < messages.length; i++) {
|
|
4786
|
+
const msg = messages[i];
|
|
4787
|
+
|
|
4788
|
+
// Drop /anthropic command messages
|
|
4789
|
+
if (isCommandMessage(msg)) {
|
|
4790
|
+
// Also drop the immediately following assistant response if it's a command response
|
|
4791
|
+
if (i + 1 < messages.length && isCommandResponse(messages[i + 1])) {
|
|
4792
|
+
i++; // Skip the response too
|
|
4793
|
+
}
|
|
4794
|
+
continue;
|
|
4795
|
+
}
|
|
4796
|
+
|
|
4797
|
+
// Drop orphaned command responses (in case the command message was already removed
|
|
4798
|
+
// or the ordering is different)
|
|
4799
|
+
if (isCommandResponse(msg)) {
|
|
4800
|
+
continue;
|
|
4801
|
+
}
|
|
4802
|
+
|
|
4803
|
+
filtered.push(msg);
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
// Safety: if filtering removed ALL messages, return the original to avoid sending
|
|
4807
|
+
// an empty messages array to the API.
|
|
4808
|
+
if (filtered.length === 0) return messages;
|
|
4809
|
+
|
|
4810
|
+
return filtered;
|
|
4811
|
+
}
|
|
4812
|
+
|
|
3728
4813
|
function extractFirstUserMessageText(messages) {
|
|
3729
4814
|
if (!Array.isArray(messages)) return "";
|
|
3730
4815
|
for (const msg of messages) {
|
|
@@ -3755,7 +4840,6 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
|
|
|
3755
4840
|
"interleaved-thinking-2025-05-14",
|
|
3756
4841
|
"context-1m-2025-08-07",
|
|
3757
4842
|
"tool-search-tool-2025-10-19",
|
|
3758
|
-
"tool-examples-2025-10-29",
|
|
3759
4843
|
"code-execution-2025-08-25",
|
|
3760
4844
|
"files-api-2025-04-14",
|
|
3761
4845
|
"fine-grained-tool-streaming-2025-05-14",
|
|
@@ -3774,9 +4858,6 @@ const EXPERIMENTAL_BETA_FLAGS = new Set([
|
|
|
3774
4858
|
"prompt-caching-scope-2026-01-05",
|
|
3775
4859
|
"redact-thinking-2026-02-12",
|
|
3776
4860
|
"structured-outputs-2025-12-15",
|
|
3777
|
-
"summarize-connector-text-2026-03-13",
|
|
3778
|
-
"token-efficient-tools-2026-03-28",
|
|
3779
|
-
"tool-examples-2025-10-29",
|
|
3780
4861
|
"tool-search-tool-2025-10-19",
|
|
3781
4862
|
"web-search-2025-03-05",
|
|
3782
4863
|
]);
|
|
@@ -3789,11 +4870,7 @@ const BETA_SHORTCUTS = new Map([
|
|
|
3789
4870
|
["opus-fast", "fast-mode-2026-02-01"],
|
|
3790
4871
|
["task-budgets", "task-budgets-2026-03-13"],
|
|
3791
4872
|
["budgets", "task-budgets-2026-03-13"],
|
|
3792
|
-
["efficient-tools", "token-efficient-tools-2026-03-28"],
|
|
3793
|
-
["token-efficient", "token-efficient-tools-2026-03-28"],
|
|
3794
4873
|
["redact-thinking", "redact-thinking-2026-02-12"],
|
|
3795
|
-
["connector-text", "summarize-connector-text-2026-03-13"],
|
|
3796
|
-
["anti-distill", "summarize-connector-text-2026-03-13"],
|
|
3797
4874
|
]);
|
|
3798
4875
|
const STAINLESS_HELPER_KEYS = [
|
|
3799
4876
|
"x_stainless_helper",
|
|
@@ -4310,12 +5387,11 @@ function normalizeSystemTextBlocks(system) {
|
|
|
4310
5387
|
text: item.text,
|
|
4311
5388
|
};
|
|
4312
5389
|
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
}
|
|
5390
|
+
// Intentionally strip cache_control from incoming system blocks.
|
|
5391
|
+
// The plugin controls cache placement: only the identity block and
|
|
5392
|
+
// boundary-split blocks get cache_control (added in buildSystemPromptBlocks).
|
|
5393
|
+
// Passing through upstream markers can cause "maximum of 4 blocks with
|
|
5394
|
+
// cache_control" API errors when combined with our own markers.
|
|
4319
5395
|
|
|
4320
5396
|
output.push(normalized);
|
|
4321
5397
|
}
|
|
@@ -4378,14 +5454,21 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4378
5454
|
// TTL must be non-decreasing across tools → system → messages, so all cached
|
|
4379
5455
|
// system blocks must share the same TTL to avoid "1h after 5m" API rejection.
|
|
4380
5456
|
const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
4381
|
-
const
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
//
|
|
4387
|
-
|
|
4388
|
-
|
|
5457
|
+
const hasTtl = effectiveCachePolicy.ttl !== "off" && effectiveCachePolicy.ttl_supported !== false;
|
|
5458
|
+
|
|
5459
|
+
// CC v2.1.90 cache_control scoping (verified against bundle WQ() function):
|
|
5460
|
+
// - scope "global" is only emitted for static pre-boundary blocks
|
|
5461
|
+
// - scope "org" is internal-only, NEVER emitted on the wire
|
|
5462
|
+
// - identity block gets no cache_control in boundary mode, or {type:"ephemeral"} in fallback
|
|
5463
|
+
const baseCacheControl = hasTtl ? { type: "ephemeral", ttl: effectiveCachePolicy.ttl } : { type: "ephemeral" };
|
|
5464
|
+
const globalCacheControl = hasTtl
|
|
5465
|
+
? { type: "ephemeral", scope: "global", ttl: effectiveCachePolicy.ttl }
|
|
5466
|
+
: { type: "ephemeral", scope: "global" };
|
|
5467
|
+
|
|
5468
|
+
// Identity block: per CC v2.1.90, gets {type:"ephemeral"} with NO scope field.
|
|
5469
|
+
// In boundary mode CC assigns cacheScope:null (no cache_control at all), but
|
|
5470
|
+
// we always include it for better cache hit rates on the proxy side.
|
|
5471
|
+
blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: baseCacheControl });
|
|
4389
5472
|
|
|
4390
5473
|
// Filtered blocks: keep as-is, with optional static/dynamic boundary marker
|
|
4391
5474
|
if (filtered.length > 0) {
|
|
@@ -4410,9 +5493,9 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4410
5493
|
|
|
4411
5494
|
const effectiveSplit = splitIndex > 0 ? splitIndex : Math.ceil(filtered.length / 2);
|
|
4412
5495
|
|
|
4413
|
-
// Static blocks (before boundary) get cache_control
|
|
5496
|
+
// Static blocks (before boundary) get global-scope cache_control
|
|
4414
5497
|
for (let i = 0; i < effectiveSplit; i++) {
|
|
4415
|
-
blocks.push({ ...filtered[i], cache_control:
|
|
5498
|
+
blocks.push({ ...filtered[i], cache_control: globalCacheControl });
|
|
4416
5499
|
}
|
|
4417
5500
|
|
|
4418
5501
|
// Boundary marker
|
|
@@ -4432,7 +5515,7 @@ function buildSystemPromptBlocks(system, signature) {
|
|
|
4432
5515
|
blocks.push(rest);
|
|
4433
5516
|
}
|
|
4434
5517
|
const lastFiltered = filtered[filtered.length - 1];
|
|
4435
|
-
blocks.push({ ...lastFiltered, cache_control:
|
|
5518
|
+
blocks.push({ ...lastFiltered, cache_control: baseCacheControl });
|
|
4436
5519
|
}
|
|
4437
5520
|
}
|
|
4438
5521
|
|
|
@@ -4462,6 +5545,7 @@ function buildAnthropicBetaHeader(
|
|
|
4462
5545
|
hasFileReferences,
|
|
4463
5546
|
adaptiveOverride,
|
|
4464
5547
|
tokenEconomy,
|
|
5548
|
+
microcompactBetas, // NEW 11th param
|
|
4465
5549
|
) {
|
|
4466
5550
|
const incomingBetasList = incomingBeta
|
|
4467
5551
|
.split(",")
|
|
@@ -4490,15 +5574,15 @@ function buildAnthropicBetaHeader(
|
|
|
4490
5574
|
const isRoundRobin = strategy === "round-robin";
|
|
4491
5575
|
const te = tokenEconomy || {};
|
|
4492
5576
|
|
|
4493
|
-
// === ALWAYS-ON BETAS (Claude Code v2.1.
|
|
5577
|
+
// === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
|
|
4494
5578
|
// These are ALWAYS included regardless of env vars or feature flags.
|
|
4495
5579
|
if (!haiku) {
|
|
4496
5580
|
betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
|
|
4497
5581
|
}
|
|
4498
5582
|
|
|
4499
5583
|
// Tool search: use provider-aware header.
|
|
4500
|
-
// 1P/Foundry
|
|
4501
|
-
// Vertex/Bedrock
|
|
5584
|
+
// 1P/Foundry u2192 advanced-tool-use-2025-11-20 (enables broader tool capabilities)
|
|
5585
|
+
// Vertex/Bedrock u2192 tool-search-tool-2025-10-19 (3P-compatible subset)
|
|
4502
5586
|
if (provider === "vertex" || provider === "bedrock") {
|
|
4503
5587
|
betas.push("tool-search-tool-2025-10-19");
|
|
4504
5588
|
} else {
|
|
@@ -4531,17 +5615,15 @@ function buildAnthropicBetaHeader(
|
|
|
4531
5615
|
|
|
4532
5616
|
// === CONDITIONAL BETAS (model/context-dependent) ===
|
|
4533
5617
|
|
|
4534
|
-
// Context management —
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
//
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
// Skip structured-outputs — API rejects both together
|
|
4544
|
-
} else if (supportsStructuredOutputs(model)) {
|
|
5618
|
+
// Context management — gated to Claude 4+ models in CC v2.1.90.
|
|
5619
|
+
// Excluded for Claude 3.x (not supported). Always-on for Claude 4+ on 1P/Foundry.
|
|
5620
|
+
if (!/claude-3-/i.test(model)) {
|
|
5621
|
+
betas.push("context-management-2025-06-27");
|
|
5622
|
+
}
|
|
5623
|
+
|
|
5624
|
+
// Structured outputs: only -2025-12-15 is active in v2.1.90 runtime.
|
|
5625
|
+
// token-efficient-tools-2026-03-28 was fully removed from v90 bundle.
|
|
5626
|
+
if (supportsStructuredOutputs(model)) {
|
|
4545
5627
|
betas.push("structured-outputs-2025-12-15");
|
|
4546
5628
|
}
|
|
4547
5629
|
|
|
@@ -4569,15 +5651,19 @@ function buildAnthropicBetaHeader(
|
|
|
4569
5651
|
betas.push("redact-thinking-2026-02-12");
|
|
4570
5652
|
}
|
|
4571
5653
|
|
|
4572
|
-
// summarize-connector-text
|
|
4573
|
-
//
|
|
4574
|
-
if (te.connector_text_summarization && !disableExperimentalBetas) {
|
|
4575
|
-
betas.push("summarize-connector-text-2026-03-13");
|
|
4576
|
-
}
|
|
5654
|
+
// summarize-connector-text-2026-03-13 was removed in v2.1.90 (dead slot njq="").
|
|
5655
|
+
// compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
|
|
4577
5656
|
|
|
4578
5657
|
// afk-mode — NOT auto-included (requires user opt-in)
|
|
4579
5658
|
// Available via: /anthropic betas add afk-mode-2026-01-31
|
|
4580
5659
|
|
|
5660
|
+
// === MICROCOMPACT BETAS (context-aware, Phase 3 Task 3.4) ===
|
|
5661
|
+
if (microcompactBetas?.length) {
|
|
5662
|
+
for (const mb of microcompactBetas) {
|
|
5663
|
+
if (!betas.includes(mb)) betas.push(mb);
|
|
5664
|
+
}
|
|
5665
|
+
}
|
|
5666
|
+
|
|
4581
5667
|
// Merge incoming betas from the original request
|
|
4582
5668
|
let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
|
|
4583
5669
|
|
|
@@ -4838,6 +5924,29 @@ function buildRequestHeaders(
|
|
|
4838
5924
|
return requestHeaders;
|
|
4839
5925
|
}
|
|
4840
5926
|
|
|
5927
|
+
/**
|
|
5928
|
+
* Resolve max_tokens for a request based on output cap configuration.
|
|
5929
|
+
* If the caller specified max_tokens, it is preserved. Otherwise, defaults
|
|
5930
|
+
* to 8K and escalates to 64K after an output truncation (stop_reason: "max_tokens").
|
|
5931
|
+
*
|
|
5932
|
+
* @param {Record<string, any>} body - Parsed request body
|
|
5933
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} config
|
|
5934
|
+
* @returns {number | undefined} Resolved max_tokens value, or undefined for passthrough
|
|
5935
|
+
*/
|
|
5936
|
+
function resolveMaxTokens(body, config) {
|
|
5937
|
+
if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
|
|
5938
|
+
if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
|
|
5939
|
+
const escalated = sessionMetrics.lastStopReason === "max_tokens";
|
|
5940
|
+
const result = escalated
|
|
5941
|
+
? (config.output_cap.escalated_max_tokens ?? 64_000)
|
|
5942
|
+
: (config.output_cap.default_max_tokens ?? 8_000);
|
|
5943
|
+
// Reset after escalation is consumed (sticky for exactly one turn)
|
|
5944
|
+
if (escalated) {
|
|
5945
|
+
sessionMetrics.lastStopReason = null;
|
|
5946
|
+
}
|
|
5947
|
+
return result;
|
|
5948
|
+
}
|
|
5949
|
+
|
|
4841
5950
|
/**
|
|
4842
5951
|
* Transform the request body: system prompt sanitization and tool prefixing.
|
|
4843
5952
|
* Preserves behaviors E1-E7.
|
|
@@ -4846,15 +5955,20 @@ function buildRequestHeaders(
|
|
|
4846
5955
|
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
|
|
4847
5956
|
* @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
|
|
4848
5957
|
* @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
|
|
5958
|
+
* @param {import('./lib/config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
|
|
4849
5959
|
* @returns {string | undefined}
|
|
4850
5960
|
*/
|
|
4851
|
-
function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
5961
|
+
function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
4852
5962
|
if (!body || typeof body !== "string") return body;
|
|
4853
5963
|
|
|
4854
5964
|
const TOOL_PREFIX = "mcp_";
|
|
4855
5965
|
|
|
4856
5966
|
try {
|
|
4857
5967
|
const parsed = JSON.parse(body);
|
|
5968
|
+
// Output cap: resolve max_tokens before any other body transforms
|
|
5969
|
+
if (config?.output_cap?.enabled) {
|
|
5970
|
+
parsed.max_tokens = resolveMaxTokens(parsed, config);
|
|
5971
|
+
}
|
|
4858
5972
|
// Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
|
|
4859
5973
|
// doesn't forward custom HTTP headers. First-party API rejects "betas" in body
|
|
4860
5974
|
// with "Extra inputs are not permitted" — betas are header-only for first-party.
|
|
@@ -4898,6 +6012,14 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4898
6012
|
parsed.temperature = 1;
|
|
4899
6013
|
}
|
|
4900
6014
|
|
|
6015
|
+
// Strip leaked /anthropic slash command messages from conversation history.
|
|
6016
|
+
// OpenCode may include command text and sendCommandMessage output as regular
|
|
6017
|
+
// user messages even when output.noReply = true was set. Filter them out
|
|
6018
|
+
// so the agent never sees /anthropic commands in its context.
|
|
6019
|
+
if (Array.isArray(parsed.messages)) {
|
|
6020
|
+
parsed.messages = stripSlashCommandMessages(parsed.messages);
|
|
6021
|
+
}
|
|
6022
|
+
|
|
4901
6023
|
// QA fix H2: avoid mutating the signature parameter; capture modelId locally
|
|
4902
6024
|
const modelId = parsed.model || "";
|
|
4903
6025
|
// Extract first user message text for billing hash computation (cch)
|
|
@@ -4906,6 +6028,25 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4906
6028
|
// Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
|
|
4907
6029
|
parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
|
|
4908
6030
|
|
|
6031
|
+
// Token budget (A9): parse NL budget from last user message, inject status block
|
|
6032
|
+
if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
|
|
6033
|
+
const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
|
|
6034
|
+
if (budgetExpr > 0) {
|
|
6035
|
+
sessionMetrics.tokenBudget.limit = budgetExpr;
|
|
6036
|
+
} else if (config.token_budget.default > 0 && sessionMetrics.tokenBudget.limit === 0) {
|
|
6037
|
+
sessionMetrics.tokenBudget.limit = config.token_budget.default;
|
|
6038
|
+
}
|
|
6039
|
+
// If budget is active, inject status into system prompt
|
|
6040
|
+
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
6041
|
+
const threshold = config.token_budget.completion_threshold ?? 0.9;
|
|
6042
|
+
parsed.system = injectTokenBudgetBlock(parsed.system, sessionMetrics.tokenBudget, threshold);
|
|
6043
|
+
// Soft stop: if we've exceeded the threshold, cap max_tokens to 1
|
|
6044
|
+
if (sessionMetrics.tokenBudget.used >= sessionMetrics.tokenBudget.limit * threshold) {
|
|
6045
|
+
parsed.max_tokens = 1;
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
6049
|
+
|
|
4909
6050
|
if (signature.enabled) {
|
|
4910
6051
|
const currentMetadata =
|
|
4911
6052
|
parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
|
|
@@ -5082,6 +6223,10 @@ function extractUsageFromSSEEvent(parsed, stats) {
|
|
|
5082
6223
|
if (typeof u.server_tool_use?.web_search_requests === "number") {
|
|
5083
6224
|
stats.webSearchRequests = u.server_tool_use.web_search_requests;
|
|
5084
6225
|
}
|
|
6226
|
+
// Capture stop_reason from message_delta for output cap escalation
|
|
6227
|
+
if (parsed.delta?.stop_reason) {
|
|
6228
|
+
sessionMetrics.lastStopReason = parsed.delta.stop_reason;
|
|
6229
|
+
}
|
|
5085
6230
|
return;
|
|
5086
6231
|
}
|
|
5087
6232
|
|