opencode-anthropic-fix 0.0.39 → 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/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 3 retries to prevent infinite loops (QA fix C1)
2830
- if (shouldRetry === true && !accountSpecific && shouldRetryCount < 3) {
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 §5.5 (Stainless SDK retries 500+ codes up to 2 times)
2886
- if ((response.status === 529 || response.status === 503) && serviceWideRetryCount < 2) {
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
- debugLog("service-wide retryable error, sleeping before retry", {
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 — return to caller
2940
- debugLog("service-wide response error, returning directly", {
2941
- status: response.status,
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: { tokens: 0, requests: 0, inputTokens: 0, updatedAt: 0 },
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.88";
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-03-30T21:59:52Z";
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.88.
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 .83-.88.
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"],
@@ -3845,7 +4840,6 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
3845
4840
  "interleaved-thinking-2025-05-14",
3846
4841
  "context-1m-2025-08-07",
3847
4842
  "tool-search-tool-2025-10-19",
3848
- "tool-examples-2025-10-29",
3849
4843
  "code-execution-2025-08-25",
3850
4844
  "files-api-2025-04-14",
3851
4845
  "fine-grained-tool-streaming-2025-05-14",
@@ -3864,9 +4858,6 @@ const EXPERIMENTAL_BETA_FLAGS = new Set([
3864
4858
  "prompt-caching-scope-2026-01-05",
3865
4859
  "redact-thinking-2026-02-12",
3866
4860
  "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
4861
  "tool-search-tool-2025-10-19",
3871
4862
  "web-search-2025-03-05",
3872
4863
  ]);
@@ -3879,11 +4870,7 @@ const BETA_SHORTCUTS = new Map([
3879
4870
  ["opus-fast", "fast-mode-2026-02-01"],
3880
4871
  ["task-budgets", "task-budgets-2026-03-13"],
3881
4872
  ["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
4873
  ["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
4874
  ]);
3888
4875
  const STAINLESS_HELPER_KEYS = [
3889
4876
  "x_stainless_helper",
@@ -4400,12 +5387,11 @@ function normalizeSystemTextBlocks(system) {
4400
5387
  text: item.text,
4401
5388
  };
4402
5389
 
4403
- if (item.cache_control && typeof item.cache_control === "object" && !Array.isArray(item.cache_control)) {
4404
- normalized.cache_control = item.cache_control;
4405
- } else if (typeof item.cacheScope === "string" && item.cacheScope) {
4406
- // Backward compatibility for older shape used by this plugin.
4407
- normalized.cache_control = { type: "ephemeral" };
4408
- }
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.
4409
5395
 
4410
5396
  output.push(normalized);
4411
5397
  }
@@ -4468,14 +5454,21 @@ function buildSystemPromptBlocks(system, signature) {
4468
5454
  // TTL must be non-decreasing across tools → system → messages, so all cached
4469
5455
  // system blocks must share the same TTL to avoid "1h after 5m" API rejection.
4470
5456
  const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
4471
- const cacheControl =
4472
- effectiveCachePolicy.ttl !== "off" && effectiveCachePolicy.ttl_supported !== false
4473
- ? { type: "ephemeral", ttl: effectiveCachePolicy.ttl }
4474
- : { type: "ephemeral" };
4475
-
4476
- // Identity block: org-scope cached per RE doc §14.1, §15.17
4477
- // Uses same TTL as other cached blocks to satisfy API ordering constraint.
4478
- blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY_STRING, cache_control: cacheControl });
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 });
4479
5472
 
4480
5473
  // Filtered blocks: keep as-is, with optional static/dynamic boundary marker
4481
5474
  if (filtered.length > 0) {
@@ -4500,9 +5493,9 @@ function buildSystemPromptBlocks(system, signature) {
4500
5493
 
4501
5494
  const effectiveSplit = splitIndex > 0 ? splitIndex : Math.ceil(filtered.length / 2);
4502
5495
 
4503
- // Static blocks (before boundary) get cache_control
5496
+ // Static blocks (before boundary) get global-scope cache_control
4504
5497
  for (let i = 0; i < effectiveSplit; i++) {
4505
- blocks.push({ ...filtered[i], cache_control: cacheControl });
5498
+ blocks.push({ ...filtered[i], cache_control: globalCacheControl });
4506
5499
  }
4507
5500
 
4508
5501
  // Boundary marker
@@ -4522,7 +5515,7 @@ function buildSystemPromptBlocks(system, signature) {
4522
5515
  blocks.push(rest);
4523
5516
  }
4524
5517
  const lastFiltered = filtered[filtered.length - 1];
4525
- blocks.push({ ...lastFiltered, cache_control: cacheControl });
5518
+ blocks.push({ ...lastFiltered, cache_control: baseCacheControl });
4526
5519
  }
4527
5520
  }
4528
5521
 
@@ -4552,6 +5545,7 @@ function buildAnthropicBetaHeader(
4552
5545
  hasFileReferences,
4553
5546
  adaptiveOverride,
4554
5547
  tokenEconomy,
5548
+ microcompactBetas, // NEW 11th param
4555
5549
  ) {
4556
5550
  const incomingBetasList = incomingBeta
4557
5551
  .split(",")
@@ -4580,15 +5574,15 @@ function buildAnthropicBetaHeader(
4580
5574
  const isRoundRobin = strategy === "round-robin";
4581
5575
  const te = tokenEconomy || {};
4582
5576
 
4583
- // === ALWAYS-ON BETAS (Claude Code v2.1.84 base set) ===
5577
+ // === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
4584
5578
  // These are ALWAYS included regardless of env vars or feature flags.
4585
5579
  if (!haiku) {
4586
5580
  betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
4587
5581
  }
4588
5582
 
4589
5583
  // Tool search: use provider-aware header.
4590
- // 1P/Foundry → advanced-tool-use-2025-11-20 (enables broader tool capabilities)
4591
- // Vertex/Bedrock → tool-search-tool-2025-10-19 (3P-compatible subset)
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)
4592
5586
  if (provider === "vertex" || provider === "bedrock") {
4593
5587
  betas.push("tool-search-tool-2025-10-19");
4594
5588
  } else {
@@ -4621,17 +5615,15 @@ function buildAnthropicBetaHeader(
4621
5615
 
4622
5616
  // === CONDITIONAL BETAS (model/context-dependent) ===
4623
5617
 
4624
- // Context management — always-on in Claude Code (not env-gated)
4625
- betas.push("context-management-2025-06-27");
4626
-
4627
- // Structured outputs vs token-efficient-tools: mutually exclusive.
4628
- // token-efficient-tools gives ~4.5% output token reduction (JSON FC v3).
4629
- // When both are configured, token-efficient-tools wins (per CC source:
4630
- // "strict wins" internally, but we prefer token savings for proxy users).
4631
- if (te.token_efficient_tools && !disableExperimentalBetas) {
4632
- betas.push("token-efficient-tools-2026-03-28");
4633
- // Skip structured-outputs — API rejects both together
4634
- } 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)) {
4635
5627
  betas.push("structured-outputs-2025-12-15");
4636
5628
  }
4637
5629
 
@@ -4659,15 +5651,19 @@ function buildAnthropicBetaHeader(
4659
5651
  betas.push("redact-thinking-2026-02-12");
4660
5652
  }
4661
5653
 
4662
- // summarize-connector-text: API summarizes assistant text between tool calls.
4663
- // Anti-distillation measure — same mechanism as thinking blocks (summary + signature).
4664
- if (te.connector_text_summarization && !disableExperimentalBetas) {
4665
- betas.push("summarize-connector-text-2026-03-13");
4666
- }
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.
4667
5656
 
4668
5657
  // afk-mode — NOT auto-included (requires user opt-in)
4669
5658
  // Available via: /anthropic betas add afk-mode-2026-01-31
4670
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
+
4671
5667
  // Merge incoming betas from the original request
4672
5668
  let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
4673
5669
 
@@ -4928,6 +5924,29 @@ function buildRequestHeaders(
4928
5924
  return requestHeaders;
4929
5925
  }
4930
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
+
4931
5950
  /**
4932
5951
  * Transform the request body: system prompt sanitization and tool prefixing.
4933
5952
  * Preserves behaviors E1-E7.
@@ -4936,15 +5955,20 @@ function buildRequestHeaders(
4936
5955
  * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
4937
5956
  * @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
4938
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
4939
5959
  * @returns {string | undefined}
4940
5960
  */
4941
- function transformRequestBody(body, signature, runtime, betaHeader) {
5961
+ function transformRequestBody(body, signature, runtime, betaHeader, config) {
4942
5962
  if (!body || typeof body !== "string") return body;
4943
5963
 
4944
5964
  const TOOL_PREFIX = "mcp_";
4945
5965
 
4946
5966
  try {
4947
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
+ }
4948
5972
  // Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
4949
5973
  // doesn't forward custom HTTP headers. First-party API rejects "betas" in body
4950
5974
  // with "Extra inputs are not permitted" — betas are header-only for first-party.
@@ -5004,6 +6028,25 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
5004
6028
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
5005
6029
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
5006
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
+
5007
6050
  if (signature.enabled) {
5008
6051
  const currentMetadata =
5009
6052
  parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
@@ -5180,6 +6223,10 @@ function extractUsageFromSSEEvent(parsed, stats) {
5180
6223
  if (typeof u.server_tool_use?.web_search_requests === "number") {
5181
6224
  stats.webSearchRequests = u.server_tool_use.web_search_requests;
5182
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
+ }
5183
6230
  return;
5184
6231
  }
5185
6232