opencode-anthropic-fix 0.0.39 → 0.0.42

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