opencode-anthropic-fix 0.1.28 → 0.1.30

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
@@ -4,7 +4,7 @@ import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:cr
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
6
  import xxhashInit from "xxhash-wasm";
7
- import { AccountManager } from "./lib/accounts.mjs";
7
+ import { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
8
8
  import {
9
9
  authorize as oauthAuthorize,
10
10
  exchange as oauthExchange,
@@ -37,6 +37,11 @@ import { callHaiku } from "./lib/haiku-call.mjs";
37
37
  import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
38
38
  import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
39
39
 
40
+ // Max times a single logical request may fall back from fast->standard speed on
41
+ // the same account before giving up the fast attempt entirely. 1 is enough: one
42
+ // fast 429 => one standard retry on the same account.
43
+ const MAX_FAST_FALLBACKS = 1;
44
+
40
45
  // ---------------------------------------------------------------------------
41
46
  // Account management CLI prompts
42
47
  // ---------------------------------------------------------------------------
@@ -2461,7 +2466,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2461
2466
  if (
2462
2467
  config.override_model_limits.enabled &&
2463
2468
  !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
2464
- (hasOneMillionContext(model.id) || isOpus46Model(model.id) || isOpus47Model(model.id))
2469
+ (hasOneMillionContext(model.id) ||
2470
+ isOpus46Model(model.id) ||
2471
+ isOpus47Model(model.id) ||
2472
+ isOpus48Model(model.id))
2465
2473
  ) {
2466
2474
  model.limit = {
2467
2475
  ...(model.limit ?? {}),
@@ -2642,6 +2650,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2642
2650
  let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
2643
2651
  let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
2644
2652
  let consecutive529Count = 0;
2653
+ let fastFallbackCount = 0; // Track fast->standard same-account fallbacks (cap below)
2645
2654
  // Classify request for retry budget (A8)
2646
2655
  const requestClass =
2647
2656
  config.request_classification?.enabled !== false ? classifyApiRequest(requestInit.body) : "foreground";
@@ -2682,7 +2691,17 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2682
2691
  "No enabled Anthropic accounts available. Enable one with 'opencode-anthropic-auth enable <N>'.",
2683
2692
  );
2684
2693
  }
2685
- // All accounts excluded (transient refresh failures) — give up
2694
+ // All accounts excluded (transient refresh failures) — give up.
2695
+ // Diagnostic: surface WHICH accounts were skipped and why, so the
2696
+ // "No available account" symptom is debuggable (e.g. it can be a
2697
+ // downstream effect of an OAuth refresh failure, not a true
2698
+ // exhaustion). transientRefreshSkips holds account indices that
2699
+ // failed/were-rate-limited during refresh this request.
2700
+ debugLog("No available account — all candidates transiently skipped", {
2701
+ enabledCount,
2702
+ transientRefreshSkips: Array.from(transientRefreshSkips),
2703
+ attempt,
2704
+ });
2686
2705
  throw new Error("No available Anthropic account for request.");
2687
2706
  }
2688
2707
 
@@ -2847,6 +2866,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2847
2866
  // suppress context-hint for non-main-thread requests so we don't
2848
2867
  // opt subagent/title/tool one-shots into a server retry loop.
2849
2868
  const _requestRole = classifyRequestRole(_parsedBodyOnce);
2869
+ // opencode tags subagent requests with the x-parent-session-id header
2870
+ // (set in its request builder only when a parent session exists).
2871
+ // This is a reliable subagent signal; body-shape classification alone
2872
+ // sees subagents as "main" (real messages + large max_tokens). Used
2873
+ // only to pick the cheaper 5m cache tier for one-shot subagents.
2874
+ const _isSubagent = getIncomingHeader(input, requestInit, "x-parent-session-id") != null;
2850
2875
  const _baseTE = config.token_economy || {};
2851
2876
  const _disableCtxHint = contextHintState.disabled || _requestRole !== "main";
2852
2877
  const _tokenEconomy = _disableCtxHint
@@ -2979,6 +3004,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2979
3004
  provider: _reqProvider,
2980
3005
  cachePolicy: effectiveCachePolicy,
2981
3006
  fastMode: config.fast_mode || false,
3007
+ // When the account's fast pool is cooling down, suppress
3008
+ // speed:"fast" so this turn runs at standard speed on the same
3009
+ // account instead of re-hitting the exhausted fast pool.
3010
+ fastRateLimited: accountManager.isFastRateLimited(account),
2982
3011
  strategy: getEffectiveStrategy(),
2983
3012
  toolDeferral: config.token_economy_strategies?.tool_deferral,
2984
3013
  toolDescriptionCompaction: config.token_economy_strategies?.tool_description_compaction,
@@ -2995,6 +3024,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2995
3024
  usedTools: sessionMetrics.usedTools,
2996
3025
  tokenEconomySession,
2997
3026
  requestRole: _requestRole,
3027
+ isSubagent: _isSubagent,
3028
+ // Adaptive cache-breakpoint placement: pass the per-boundary
3029
+ // stability snapshot so transformRequestBody can anchor the
3030
+ // cache_control marker on the most-stable boundary instead of
3031
+ // the (possibly thrashing) last tool. Only populated once the
3032
+ // detector is enabled and has a baseline.
3033
+ cacheBoundaryStability: config.cache_break_detection?.adaptive_breakpoint
3034
+ ? cacheBreakState.boundaryStability
3035
+ : null,
2998
3036
  },
2999
3037
  computedBetaHeader,
3000
3038
  config,
@@ -3018,6 +3056,16 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3018
3056
  if (config.cache_break_detection?.enabled && typeof body === "string") {
3019
3057
  const currentHashes = extractCacheSourceHashes(body);
3020
3058
  if (currentHashes.size > 0) {
3059
+ // Update per-boundary structural stability BEFORE staging the
3060
+ // new hashes, diffing against the prior turn's baseline. This
3061
+ // feeds the adaptive breakpoint placer on the NEXT turn.
3062
+ if (cacheBreakState.sourceHashes.size > 0) {
3063
+ updateBoundaryStability(
3064
+ currentHashes,
3065
+ cacheBreakState.sourceHashes,
3066
+ cacheBreakState.boundaryStability,
3067
+ );
3068
+ }
3021
3069
  cacheBreakState._pendingHashes = currentHashes;
3022
3070
  }
3023
3071
  }
@@ -3665,8 +3713,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3665
3713
  // Check x-should-retry header first — server override
3666
3714
  const shouldRetry = parseShouldRetryHeader(response);
3667
3715
  if (shouldRetry === false) {
3668
- // Server says DO NOT retry — return error directly
3669
- debugLog("x-should-retry: false — not retrying", { status: response.status });
3716
+ // Server says DO NOT retry — return error directly.
3717
+ // Include a snippet of the error body so 4xx causes (e.g. the
3718
+ // thinking-block "cannot be modified" 400, or an unsupported
3719
+ // manual-thinking 400 on adaptive models) are visible in debug.
3720
+ debugLog("x-should-retry: false — not retrying", {
3721
+ status: response.status,
3722
+ errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
3723
+ });
3670
3724
  return transformResponse(response);
3671
3725
  }
3672
3726
 
@@ -3713,6 +3767,44 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3713
3767
  continue;
3714
3768
  }
3715
3769
 
3770
+ // Fast-mode pool isolation: if THIS request used speed:"fast"
3771
+ // and the limit is a plain rate limit, the FAST pool was
3772
+ // exhausted — not the account's standard quota. Per Anthropic,
3773
+ // fast has a separate pool and should fall back to standard
3774
+ // speed, NOT block the account. So: cool down only the fast
3775
+ // bucket, then retry on the SAME account at standard speed
3776
+ // (the next transformRequestBody omits speed:"fast" because
3777
+ // isFastRateLimited(account) is now true). We do NOT flip the
3778
+ // global config.fast_mode flag — other accounts/turns can still
3779
+ // use fast mode.
3780
+ const requestWasFast = typeof body === "string" && body.includes('"speed":"fast"');
3781
+ if (
3782
+ requestWasFast &&
3783
+ response.status === 429 &&
3784
+ reason === "RATE_LIMIT_EXCEEDED" &&
3785
+ fastFallbackCount < MAX_FAST_FALLBACKS
3786
+ ) {
3787
+ fastFallbackCount++;
3788
+ const fastBackoff = accountManager.markRateLimited(
3789
+ account,
3790
+ reason,
3791
+ retryAfterMs,
3792
+ RATE_LIMIT_KEY_FAST,
3793
+ );
3794
+ _fastModeAppliedToast = false;
3795
+ toast("⚡ Fast pool limited — falling back to standard speed", "info", {
3796
+ debounceKey: "fast-fallback",
3797
+ }).catch(() => {});
3798
+ debugLog("fast pool rate-limited; falling back to standard on same account", {
3799
+ account: account.email || `Account ${account.index + 1}`,
3800
+ fastBackoffMs: fastBackoff,
3801
+ fastFallbackCount,
3802
+ });
3803
+ // Retry same account at standard speed without consuming a slot.
3804
+ attempt--;
3805
+ continue;
3806
+ }
3807
+
3716
3808
  accountManager.markRateLimited(account, reason, retryAfterMs);
3717
3809
 
3718
3810
  // On auth failures, clear token so next selection forces refresh
@@ -3724,7 +3816,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3724
3816
  // Strategy adaptation: record account-specific throttling signal
3725
3817
  recordRateLimitForStrategy();
3726
3818
 
3727
- // Graceful degradation: disable fast mode on rate limits
3819
+ // Graceful degradation: disable fast mode on rate limits.
3820
+ // Note: a fast-pool-only 429 is handled by the fast-fallback
3821
+ // branch above and never reaches here, so this global disable
3822
+ // only fires for standard-pool limits or 529 overloads.
3728
3823
  if (config.fast_mode && (response.status === 429 || response.status === 529)) {
3729
3824
  config.fast_mode = false;
3730
3825
  _fastModeAppliedToast = false;
@@ -4372,8 +4467,40 @@ const cacheBreakState = {
4372
4467
  prevCacheRead: 0,
4373
4468
  sourceHashes: new Map(),
4374
4469
  lastAlertTurn: 0,
4470
+ /**
4471
+ * Per-boundary structural stability: how many consecutive turns each cache
4472
+ * source ("system_prompt", "tools", "messages_prefix") has been unchanged.
4473
+ * Used by the adaptive breakpoint placer to anchor the cache_control marker
4474
+ * on the most-stable boundary instead of blindly on the last tool (which
4475
+ * thrashes when the host reorders/adds tools between turns).
4476
+ * @type {Map<string, number>}
4477
+ */
4478
+ boundaryStability: new Map(),
4375
4479
  };
4376
4480
 
4481
+ /**
4482
+ * Update per-boundary stability counters by diffing the current request's cache
4483
+ * source hashes against the previous turn's. A boundary that is unchanged gets
4484
+ * its counter incremented; a changed (or newly-appeared) boundary resets to 0.
4485
+ *
4486
+ * @param {Map<string,string>} current - hashes for this turn (source -> hash)
4487
+ * @param {Map<string,string>} previous - hashes from the prior turn
4488
+ * @param {Map<string,number>} stability - mutated in place
4489
+ */
4490
+ function updateBoundaryStability(current, previous, stability) {
4491
+ for (const [source, hash] of current) {
4492
+ if (previous.get(source) === hash) {
4493
+ stability.set(source, (stability.get(source) || 0) + 1);
4494
+ } else {
4495
+ stability.set(source, 0);
4496
+ }
4497
+ }
4498
+ // Drop boundaries that no longer appear so stale entries don't linger.
4499
+ for (const source of [...stability.keys()]) {
4500
+ if (!current.has(source)) stability.delete(source);
4501
+ }
4502
+ }
4503
+
4377
4504
  // ---------------------------------------------------------------------------
4378
4505
  // Microcompact state (Phase 3, Task 3.4)
4379
4506
  // ---------------------------------------------------------------------------
@@ -4603,6 +4730,116 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
4603
4730
  * @param {string} bodyStr - JSON request body
4604
4731
  * @returns {Map<string, string>} source_id → hash
4605
4732
  */
4733
+ /**
4734
+ * Resolve the prompt-cache TTL for tool/message breakpoints, mirroring the
4735
+ * decompiled Claude Code 2.1.154 `REH(querySource)` precedence.
4736
+ *
4737
+ * Precedence (highest first):
4738
+ * 1. FORCE_PROMPT_CACHING_5M truthy -> "5m"
4739
+ * 2. ENABLE_PROMPT_CACHING_1H truthy -> "1h"
4740
+ * 3. role-scoping on AND not the main role -> "5m" (side-queries are cheap)
4741
+ * 4. otherwise -> configuredTtl (main interactive thread, default "1h")
4742
+ *
4743
+ * @param {object} args
4744
+ * @param {string} args.configuredTtl - Default TTL when 1h is eligible (usually "1h").
4745
+ * @param {boolean} args.roleScopedTtl - Whether role-scoped 5m downgrade is enabled.
4746
+ * @param {boolean} args.isMainForCache - True when the request is the main/interactive role.
4747
+ * @param {boolean} [args.isSubagent] - True when the request is an opencode subagent
4748
+ * (detected via the `x-parent-session-id` header). One-shot subagents prefer 5m.
4749
+ * @param {Record<string, string | undefined>} [args.env] - Environment (defaults to process.env).
4750
+ * @returns {string} The TTL string ("5m" or "1h"/configuredTtl).
4751
+ */
4752
+ function resolveCacheTtl({ configuredTtl, roleScopedTtl, isMainForCache, isSubagent = false, env = process.env }) {
4753
+ // Env overrides (exact CC REH() behavior). isTruthyEnv mirrors CC's uH().
4754
+ if (isTruthyEnv(env.FORCE_PROMPT_CACHING_5M)) return "5m";
4755
+ if (isTruthyEnv(env.ENABLE_PROMPT_CACHING_1H)) return "1h";
4756
+ // Subagent requests (opencode marks them with the x-parent-session-id header)
4757
+ // are usually one-shot, and opencode runs them sequentially with a DIFFERENT
4758
+ // system prompt than the main thread, so they rarely re-hit a 1h cache. Prefer
4759
+ // the cheap 5m write tier (~1.25x base) over the 1h tier (~2x base) to avoid
4760
+ // paying the long-cache write premium on a prefix that won't be reused. Gated
4761
+ // on the same role-scoped master switch as the side-query downgrade below.
4762
+ if (roleScopedTtl && isSubagent) return "5m";
4763
+ // Role-scoped downgrade: non-main (side-query) requests use the cheap 5m tier.
4764
+ if (roleScopedTtl && !isMainForCache) return "5m";
4765
+ return configuredTtl;
4766
+ }
4767
+
4768
+ /**
4769
+ * Read a single incoming request header by name (case-insensitive) from either a
4770
+ * `Request` input or a fetch `init.headers` (Headers | array | plain object).
4771
+ * Returns the trimmed value or null. Used to detect opencode's subagent marker
4772
+ * `x-parent-session-id` without consuming/normalizing the full header set.
4773
+ * @param {any} input
4774
+ * @param {any} requestInit
4775
+ * @param {string} name
4776
+ * @returns {string | null}
4777
+ */
4778
+ function getIncomingHeader(input, requestInit, name) {
4779
+ const lower = name.toLowerCase();
4780
+ const pick = (v) => (v != null && String(v).trim() !== "" ? String(v).trim() : null);
4781
+ try {
4782
+ if (input && typeof input === "object" && input.headers && typeof input.headers.get === "function") {
4783
+ const v = pick(input.headers.get(name));
4784
+ if (v) return v;
4785
+ }
4786
+ } catch {
4787
+ // malformed Request — fall through to init.headers
4788
+ }
4789
+ const h = requestInit?.headers;
4790
+ if (!h) return null;
4791
+ try {
4792
+ if (typeof h.get === "function") return pick(h.get(name));
4793
+ if (Array.isArray(h)) {
4794
+ for (const pair of h) {
4795
+ if (Array.isArray(pair) && String(pair[0]).toLowerCase() === lower) return pick(pair[1]);
4796
+ }
4797
+ return null;
4798
+ }
4799
+ for (const [k, v] of Object.entries(h)) {
4800
+ if (k.toLowerCase() === lower) return pick(v);
4801
+ }
4802
+ } catch {
4803
+ // unexpected header shape — treat as absent
4804
+ }
4805
+ return null;
4806
+ }
4807
+
4808
+ /**
4809
+ * Decide whether to place the prompt-cache breakpoint on the last tool.
4810
+ *
4811
+ * Default (no stability data, or detector disabled): TRUE — exact Claude Code
4812
+ * behavior (breakpoint on the last tool).
4813
+ *
4814
+ * Adaptive override: returns FALSE only when there is concrete evidence that the
4815
+ * tool boundary is thrashing (a `tool:*` source changed within the last turn,
4816
+ * i.e. stability 0) WHILE the system-prompt boundary is stable (unchanged for
4817
+ * >= STABLE_TURNS turns). In that case the stable system-prompt breakpoint is a
4818
+ * better cache anchor than a tool array that moves every turn.
4819
+ *
4820
+ * @param {Map<string, number> | null | undefined} stability - source -> consecutive-unchanged turns
4821
+ * @returns {boolean} whether to stamp cache_control on the last tool
4822
+ */
4823
+ function shouldPlaceToolBreakpoint(stability) {
4824
+ if (!stability || stability.size === 0) return true; // no data -> CC behavior
4825
+ const STABLE_TURNS = 2;
4826
+ const systemStability = stability.get("system_prompt");
4827
+ const systemIsStable = typeof systemStability === "number" && systemStability >= STABLE_TURNS;
4828
+ if (!systemIsStable) return true; // system not proven stable -> keep CC behavior
4829
+
4830
+ // Is any tool boundary thrashing right now (changed within the last turn)?
4831
+ let toolThrashing = false;
4832
+ for (const [source, turns] of stability) {
4833
+ if (source.startsWith("tool:") && turns === 0) {
4834
+ toolThrashing = true;
4835
+ break;
4836
+ }
4837
+ }
4838
+ // Skip the last-tool breakpoint only when tools thrash AND system is the
4839
+ // stable anchor. Otherwise fall back to CC behavior.
4840
+ return !toolThrashing;
4841
+ }
4842
+
4606
4843
  function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
4607
4844
  const hashes = new Map();
4608
4845
  try {
@@ -5141,6 +5378,11 @@ function forceEscalateAdaptiveContext() {
5141
5378
  }
5142
5379
 
5143
5380
  const MODEL_PRICING = {
5381
+ // Opus 4.8 (launched 2026-05-28): $5/$25 per 1M (input/output), notably
5382
+ // cheaper than 4.6/4.7. cacheRead = 0.1x input, cacheWrite = 1.25x input
5383
+ // (Anthropic's standard 5m-write ratio).
5384
+ "claude-opus-4-8": { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25 },
5385
+ "claude-opus-4-7": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
5144
5386
  "claude-opus-4-6": { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75 },
5145
5387
  "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
5146
5388
  "claude-haiku-4-5": { input: 0.8, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0 },
@@ -6247,7 +6489,7 @@ function extractFirstUserMessageText(messages) {
6247
6489
  const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
6248
6490
  const _EFFORT_BETA_FLAG = "effort-2025-11-24";
6249
6491
  const _ADVANCED_TOOL_USE_BETA_FLAG = "advanced-tool-use-2025-11-20";
6250
- const _FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
6492
+ const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
6251
6493
  const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
6252
6494
  const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
6253
6495
  const KNOWN_IDENTITY_STRINGS = new Set([
@@ -6481,6 +6723,20 @@ function isOpus47Model(model) {
6481
6723
  return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
6482
6724
  }
6483
6725
 
6726
+ /**
6727
+ * Detects claude-opus-4.8 / claude-opus-4-8 model IDs.
6728
+ * Opus 4.8 (launched 2026-05-28) is an adaptive-thinking model: manual
6729
+ * `thinking: {type: "enabled", budget_tokens}` returns a 400 — it MUST use
6730
+ * `thinking: {type: "adaptive"}` + the effort parameter. It also supports
6731
+ * `speed: "fast"` (fast-mode research preview) and 1M context by default.
6732
+ * @param {string | undefined} model
6733
+ * @returns {boolean}
6734
+ */
6735
+ function isOpus48Model(model) {
6736
+ if (!model) return false;
6737
+ return /claude-opus-4[._-]8|opus[._-]4[._-]8/i.test(model);
6738
+ }
6739
+
6484
6740
  /**
6485
6741
  * Models eligible for the "simple system prompt" mode that real CC ships in
6486
6742
  * v2.1.133+ under GrowthBook flag `tengu_vellum_lantern` (or forced via
@@ -6495,7 +6751,7 @@ function isOpus47Model(model) {
6495
6751
  */
6496
6752
  function isSimpleSystemPromptEligible(model) {
6497
6753
  if (!model) return false;
6498
- if (isOpus47Model(model)) return true;
6754
+ if (isOpus47Model(model) || isOpus48Model(model)) return true;
6499
6755
  // -eap suffix variant, e.g. "claude-opus-4-7-eap" or "claude-opus-4-7-eap[1m]"
6500
6756
  if (/-eap(?:\[|$|-)/i.test(model)) return true;
6501
6757
  return false;
@@ -6513,12 +6769,12 @@ function isSonnet46Model(model) {
6513
6769
 
6514
6770
  /**
6515
6771
  * Detects models that support adaptive thinking ({type: "adaptive"}).
6516
- * Currently: Opus 4.6, Opus 4.7, and Sonnet 4.6.
6772
+ * Currently: Opus 4.6, Opus 4.7, Opus 4.8, and Sonnet 4.6.
6517
6773
  * @param {string | undefined} body
6518
6774
  * @returns {boolean}
6519
6775
  */
6520
6776
  function isAdaptiveThinkingModel(model) {
6521
- return isOpus46Model(model) || isOpus47Model(model) || isSonnet46Model(model);
6777
+ return isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model) || isSonnet46Model(model);
6522
6778
  }
6523
6779
 
6524
6780
  /**
@@ -6533,8 +6789,11 @@ function isEligibleFor1MContext(model) {
6533
6789
  // Explicit 1m suffix/tag in model name
6534
6790
  if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
6535
6791
  // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6.
6536
- // Opus 4.7 (successor to 4.6) is also 1M-context eligible.
6537
- return /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model);
6792
+ // Opus 4.7 / 4.8 (successors to 4.6) are also 1M-context eligible.
6793
+ // Opus 4.8 ships with 1M context by default on the Claude API.
6794
+ return (
6795
+ /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model) || isOpus48Model(model)
6796
+ );
6538
6797
  }
6539
6798
 
6540
6799
  /**
@@ -7309,6 +7568,11 @@ function buildSystemPromptBlocks(system, signature) {
7309
7568
  * @param {string} [requestPath]
7310
7569
  * @param {boolean} [hasFileReferences]
7311
7570
  * @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
7571
+ * @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
7572
+ * Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
7573
+ * Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
7574
+ * computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
7575
+ * accumulates fast-mode independently of actual body content.
7312
7576
  * @returns {string}
7313
7577
  */
7314
7578
  function buildAnthropicBetaHeader(
@@ -7323,6 +7587,7 @@ function buildAnthropicBetaHeader(
7323
7587
  adaptiveOverride,
7324
7588
  tokenEconomy,
7325
7589
  microcompactBetas, // NEW 11th param
7590
+ fastModeActive, // NEW 12th param — see @param above
7326
7591
  ) {
7327
7592
  const incomingBetasList = incomingBeta
7328
7593
  .split(",")
@@ -7471,6 +7736,16 @@ function buildAnthropicBetaHeader(
7471
7736
  }
7472
7737
  }
7473
7738
 
7739
+ // Fast-mode beta — the PLUGIN injects speed:"fast" into the body, not the host.
7740
+ // Because the host never sends speed:"fast", it also never sends the matching beta.
7741
+ // We must add it here, derived structurally from the already-transformed body so
7742
+ // it is always in lockstep with the speed field. Added BEFORE the dedupe merge so
7743
+ // any duplicate (e.g. via incoming passthrough) is collapsed without duplication.
7744
+ // Unlike effort-2025-11-24 / advanced-tool-use / tool-search-tool (which arrive via
7745
+ // incoming-header passthrough because the HOST sets those body features), fast-mode
7746
+ // is plugin-only and requires this explicit push.
7747
+ if (fastModeActive) betas.push(FAST_MODE_BETA_FLAG);
7748
+
7474
7749
  // Merge incoming betas from the original request, filtering out host-injected
7475
7750
  // betas (e.g. fine-grained-tool-streaming-2025-05-14, structured-outputs-2025-11-13)
7476
7751
  // that OpenCode's Anthropic SDK adds but real Claude Code never sends.
@@ -7523,7 +7798,9 @@ function budgetTokensToEffort(budgetTokens) {
7523
7798
 
7524
7799
  /**
7525
7800
  * Normalise the `thinking` block in the request body for the target model:
7526
- * - Opus 4.6 / Sonnet 4.6 (adaptive thinking): produces `{ type: "adaptive" }`
7801
+ * - Opus 4.6 / 4.7 / 4.8 / Sonnet 4.6 (adaptive thinking): produces
7802
+ * `{ type: "adaptive" }`. This is REQUIRED for Opus 4.7/4.8 — manual
7803
+ * `{ type: "enabled", budget_tokens }` returns a 400 on those models.
7527
7804
  * - Older models: passes the existing thinking block through unchanged.
7528
7805
  *
7529
7806
  * @param {any} thinking
@@ -7656,6 +7933,14 @@ function buildRequestHeaders(
7656
7933
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
7657
7934
  const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
7658
7935
  const provider = detectProvider(requestUrl);
7936
+ // Detect fast mode structurally from the already-transformed body string.
7937
+ // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
7938
+ // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
7939
+ // toast check (~line 3018). This is the correct call site for fast-mode: the
7940
+ // computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
7941
+ // is always false/absent there, keeping the latch free of fast-mode. The
7942
+ // requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
7943
+ const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
7659
7944
  const mergedBetas = buildAnthropicBetaHeader(
7660
7945
  incomingBeta,
7661
7946
  signature.enabled,
@@ -7667,6 +7952,8 @@ function buildRequestHeaders(
7667
7952
  hasFileReferences,
7668
7953
  adaptiveOverride,
7669
7954
  tokenEconomy,
7955
+ undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
7956
+ fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
7670
7957
  );
7671
7958
 
7672
7959
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
@@ -7781,6 +8068,21 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7781
8068
 
7782
8069
  try {
7783
8070
  const parsed = JSON.parse(body);
8071
+ // Diagnostic: log the incoming model + thinking/speed shape so model-routing
8072
+ // issues (e.g. an unrecognized fast variant, or "No available account"
8073
+ // symptoms tied to a specific model) are observable with /anthropic set debug on.
8074
+ // NOTE: transformRequestBody is a top-level function outside the plugin
8075
+ // closure where debugLog() lives, so we mirror debugLog's behaviour directly
8076
+ // (console.error + same prefix, gated on config.debug).
8077
+ if (config?.debug) {
8078
+ console.error("[opencode-anthropic-auth]", "transformRequestBody: incoming model", {
8079
+ model: parsed.model,
8080
+ adaptive: isAdaptiveThinkingModel(parsed.model || ""),
8081
+ thinkingType: parsed.thinking?.type,
8082
+ hasEffort: parsed.effort !== undefined || parsed.output_config?.effort !== undefined,
8083
+ speed: parsed.speed,
8084
+ });
8085
+ }
7784
8086
  // Output cap: resolve max_tokens before any other body transforms
7785
8087
  if (config?.output_cap?.enabled) {
7786
8088
  parsed.max_tokens = resolveMaxTokens(parsed, config);
@@ -7852,10 +8154,19 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7852
8154
  // Anthropic API rejects temperature when thinking is enabled
7853
8155
  delete parsed.temperature;
7854
8156
 
7855
- // Claude Code v2.1.84: inject context_management body field when thinking
7856
- // is active and context-management beta is in use. This tells the API how
7857
- // to handle thinking blocks during context management operations.
7858
- if (!parsed.context_management) {
8157
+ // Claude Code v2.1.84: inject the context_management body field ONLY when the
8158
+ // context-management beta is actually enabled (token_economy.context_management,
8159
+ // the SAME flag that gates the beta in buildAnthropicBetaHeader). A top-level
8160
+ // context_management field WITHOUT the beta is rejected by the API with
8161
+ // "context_management: Extra inputs are not permitted". Keeping the field and
8162
+ // the anthropic-beta header in lockstep prevents that 400 — previously the
8163
+ // field was injected on every thinking turn regardless of the beta, which only
8164
+ // worked if the beta happened to be forced on via custom_betas/ANTHROPIC_BETAS.
8165
+ if (
8166
+ config?.token_economy?.context_management &&
8167
+ !/claude-3-/i.test(parsed.model || "") &&
8168
+ !parsed.context_management
8169
+ ) {
7859
8170
  parsed.context_management = {
7860
8171
  edits: [{ type: "clear_thinking_20251015", keep: "all" }],
7861
8172
  };
@@ -7982,9 +8293,34 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7982
8293
  const modelId = parsed.model || "";
7983
8294
  // Extract first user message text for billing hash computation (cch)
7984
8295
  const firstUserMessage = extractFirstUserMessageText(parsed.messages);
8296
+ // Resolve the prompt-cache TTL ONCE so system blocks, tools, and messages
8297
+ // all share the SAME ttl. Anthropic processes cache_control blocks in the
8298
+ // order tools → system → messages and rejects any ttl='1h' block that comes
8299
+ // AFTER a ttl='5m' block. Before this fix the role-scoped 5m downgrade was
8300
+ // applied only to tools/messages (resolveCacheTtl in the breakpoint loop)
8301
+ // while the system blocks kept the configured 1h — so subagent requests
8302
+ // (5m tools, then 1h system) tripped:
8303
+ // "system.1.cache_control.ttl: a ttl='1h' cache_control block must not
8304
+ // come after a ttl='5m' cache_control block".
8305
+ // Threading it through buildSystemPromptBlocks keeps every cache_control ttl
8306
+ // consistent (real CC derives the ttl uniformly from querySource).
8307
+ const baseCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
8308
+ const cachingEnabledForTtl = baseCachePolicy.ttl !== "off" && baseCachePolicy.ttl_supported !== false;
8309
+ const resolvedCacheTtl = cachingEnabledForTtl
8310
+ ? resolveCacheTtl({
8311
+ configuredTtl: baseCachePolicy.ttl || "1h",
8312
+ roleScopedTtl: config?.token_economy?.role_scoped_cache_ttl !== false,
8313
+ isMainForCache: runtime?.requestRole === "main" || runtime?.requestRole == null,
8314
+ isSubagent: runtime?.isSubagent === true,
8315
+ env: process.env,
8316
+ })
8317
+ : baseCachePolicy.ttl;
7985
8318
  const signatureWithModel = {
7986
8319
  ...signature,
7987
8320
  modelId,
8321
+ // Override cachePolicy.ttl with the role/subagent-resolved ttl so the
8322
+ // system blocks match the tool/message breakpoint ttl (see comment above).
8323
+ cachePolicy: { ...baseCachePolicy, ttl: resolvedCacheTtl },
7988
8324
  firstUserMessage,
7989
8325
  antiVerbosity: config?.anti_verbosity,
7990
8326
  // Role-aware system-prompt leaning: for non-main-thread requests (title,
@@ -8073,31 +8409,61 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
8073
8409
  // our system prompt ttl=1h). Then add our own to the last user message
8074
8410
  // (matching real CC behavior seen in proxy capture).
8075
8411
  //
8076
- // Role-scoped TTL: real CC's MoY(querySource) gates the `ttl:"1h"` field
8077
- // on an allowlist of query sources (`repl_main_thread*`, `sdk`,
8078
- // `auto_mode`). Non-matching requests fall back to the default 5m tier
8079
- // — which is cheaper to write. We match this via classifyRequestRole
8080
- // (main → configured TTL, usually 1h; everything else → 5m).
8081
- const configuredTtl = signature.cachePolicy?.ttl || "1h";
8082
- const roleScopedTtl = config?.token_economy?.role_scoped_cache_ttl !== false;
8083
- const isMainForCache = runtime?.requestRole === "main" || runtime?.requestRole == null;
8084
- const ccTtl = roleScopedTtl && !isMainForCache ? "5m" : configuredTtl;
8412
+ // Role-scoped TTL: real CC's REH(querySource) decides the `ttl:"1h"` field.
8413
+ // Decompiled from CC 2.1.154 (function REH, offset 225174828), precedence:
8414
+ // 1. FORCE_PROMPT_CACHING_5M => 5m (highest priority)
8415
+ // 2. ENABLE_PROMPT_CACHING_1H => 1h
8416
+ // 3. no tengu support / overage => 5m
8417
+ // 4. querySource ∈ allowlist [repl_main_thread*, sdk, auto_mode, ...] => 1h, else 5m
8418
+ // We map step 4 via classifyRequestRole: main (interactive/auto_mode thread)
8419
+ // → 1h; side-queries (title-gen, etc.) → 5m. The env vars are honored
8420
+ // exactly as CC does, giving the user a manual override + mimicry fidelity.
8421
+ // Reuse the request-wide resolved ttl (computed above where
8422
+ // signatureWithModel is built) so tools/messages match the system blocks
8423
+ // and never trip the 1h-after-5m ordering rule on subagent requests.
8424
+ const ccTtl = resolvedCacheTtl;
8085
8425
  if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
8086
8426
  for (const tool of parsed.tools) {
8087
8427
  if (tool.cache_control) delete tool.cache_control;
8088
8428
  }
8089
- // Add cache_control to last tool as prompt-cache breakpoint (CC does this)
8090
- parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
8429
+ // Adaptive breakpoint placement (opt-in, runtime-driven). Real CC always
8430
+ // pins the breakpoint on the last tool because CC controls a STABLE tool
8431
+ // array. opencode may reorder/add/remove tools between turns, which moves
8432
+ // the "last tool" and invalidates the cached prefix after it. When the
8433
+ // detector reports the tool boundary is thrashing while the system prompt
8434
+ // is stable, we SKIP the volatile last-tool breakpoint and let the stable
8435
+ // system-prompt breakpoint (added by buildSystemPromptBlocks) anchor the
8436
+ // cache. Falls back to exact CC behavior when no stability data exists.
8437
+ const placeToolBreakpoint = shouldPlaceToolBreakpoint(runtime?.cacheBoundaryStability);
8438
+ if (placeToolBreakpoint) {
8439
+ parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
8440
+ }
8091
8441
  }
8092
8442
  if (Array.isArray(parsed.messages)) {
8093
8443
  for (const msg of parsed.messages) {
8094
8444
  if (Array.isArray(msg.content)) {
8095
8445
  for (const block of msg.content) {
8446
+ // CONTRACT GUARD: `thinking` / `redacted_thinking` blocks MUST be
8447
+ // round-tripped byte-identical to the model's original response
8448
+ // (Anthropic extended-thinking rules). ANY mutation — including
8449
+ // `delete block.cache_control` — triggers the 400 error
8450
+ // "thinking or redacted_thinking blocks in the latest assistant
8451
+ // message cannot be modified". `cache_control` is not even a valid
8452
+ // field on a thinking block, so we never strip nor add it here;
8453
+ // we leave the block exactly as received. This is required for
8454
+ // adaptive-thinking models (Opus 4.6/4.7/4.8, Sonnet 4.6) on
8455
+ // tool-continuation turns. See docs/mimese-http-header-system-prompt.md.
8456
+ if (!block || typeof block !== "object") continue;
8457
+ if (block.type === "thinking" || block.type === "redacted_thinking") {
8458
+ continue;
8459
+ }
8096
8460
  if (block.cache_control) delete block.cache_control;
8097
8461
  }
8098
8462
  }
8099
8463
  }
8100
- // Add cache_control to last user message (real CC does this)
8464
+ // Add cache_control to last user message (real CC does this).
8465
+ // (User messages never contain thinking blocks, so the guard above
8466
+ // does not affect this breakpoint.)
8101
8467
  for (let i = parsed.messages.length - 1; i >= 0; i--) {
8102
8468
  const msg = parsed.messages[i];
8103
8469
  if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
@@ -8228,12 +8594,18 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
8228
8594
  parsed.context_hint = { enabled: true };
8229
8595
  }
8230
8596
 
8231
- // Fast mode: inject speed parameter for Opus 4.6 only (v2.1.97 restriction).
8232
- // Real CC v2.1.97 xJ() checks: model.includes("opus-4-6") — Sonnet is NOT eligible.
8233
- // NOTE: Deliberately NOT extended to Opus 4.7. xJ() is a fingerprint-sensitive
8234
- // check tied to the exact real-CC version we mirror; blindly enabling
8235
- // speed:"fast" for 4.7 could diverge from real CC's behavior for that model.
8236
- // Revisit once a real-CC dump confirms xJ() matches opus-4-7.
8597
+ // Fast mode: inject speed parameter for fast-mode-eligible models.
8598
+ // Per Anthropic fast-mode docs, `speed: "fast"` (beta fast-mode-2026-02-01) is
8599
+ // supported on Opus 4.6, Opus 4.7, and Opus 4.8 (research preview). Opus 4.7
8600
+ // is the /fast default in real Claude Code v2.1.142+. Sonnet is NOT eligible.
8601
+ // NOTE: switching speed invalidates system + message prompt caches (per
8602
+ // Anthropic fast-mode docs), so only flip it deliberately.
8603
+ // When the selected account's fast pool is cooling down (a prior fast 429),
8604
+ // suppress speed:"fast" entirely so this turn runs at standard speed on the
8605
+ // same account rather than re-hitting the exhausted fast pool.
8606
+ const fastPoolAvailable = !signature.fastRateLimited;
8607
+ const isFastModeEligibleModel = (m) =>
8608
+ fastPoolAvailable && (isOpus46Model(m) || isOpus47Model(m) || isOpus48Model(m));
8237
8609
  const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
8238
8610
  let fastModeAutoApplied = false;
8239
8611
  if (
@@ -8241,7 +8613,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
8241
8613
  te.fast_mode_auto === true &&
8242
8614
  isMainRole &&
8243
8615
  parsed.model &&
8244
- isOpus46Model(parsed.model) &&
8616
+ isFastModeEligibleModel(parsed.model) &&
8245
8617
  Array.isArray(parsed.messages) &&
8246
8618
  parsed.messages.length >= 2
8247
8619
  ) {
@@ -8264,7 +8636,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
8264
8636
  }
8265
8637
  }
8266
8638
  }
8267
- if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isOpus46Model(parsed.model)) {
8639
+ if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isFastModeEligibleModel(parsed.model)) {
8268
8640
  parsed.speed = "fast";
8269
8641
  }
8270
8642
 
@@ -8572,6 +8944,32 @@ function stripMcpPrefixFromParsedEvent(parsed) {
8572
8944
  return modified;
8573
8945
  }
8574
8946
 
8947
+ /**
8948
+ * Resolve the byte-stream idle timeout in milliseconds.
8949
+ *
8950
+ * Parity with Claude Code's `tengu_byte_stream_idle_timeout_ms`: a watchdog that
8951
+ * fires when the upstream SSE stream produces no bytes for N ms, so a stalled /
8952
+ * half-dead connection surfaces as a fast error instead of an indefinite hang.
8953
+ *
8954
+ * Resolution order: env `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS` →
8955
+ * `config.streaming.idle_timeout_ms` → 0 (disabled). Disabled by default because
8956
+ * a too-aggressive value could abort a legitimate long generation; Anthropic SSE
8957
+ * emits periodic `ping` events, so a multi-minute silence is the safe signal.
8958
+ *
8959
+ * @param {any} cfg
8960
+ * @returns {number} timeout in ms, or 0 to disable
8961
+ */
8962
+ function resolveStreamIdleTimeoutMs(cfg) {
8963
+ const envRaw = process.env.OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS;
8964
+ if (envRaw != null && envRaw !== "") {
8965
+ const n = Number(envRaw);
8966
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
8967
+ }
8968
+ const c = cfg?.streaming?.idle_timeout_ms;
8969
+ if (typeof c === "number" && Number.isFinite(c) && c >= 0) return Math.floor(c);
8970
+ return 0;
8971
+ }
8972
+
8575
8973
  /**
8576
8974
  * Wrap a response body stream to strip mcp_ prefix from tool names,
8577
8975
  * extract token usage stats from SSE events, and detect mid-stream
@@ -8592,6 +8990,10 @@ function transformResponse(response, onUsage, onAccountError) {
8592
8990
  const encoder = new TextEncoder();
8593
8991
  const EMPTY_CHUNK = new Uint8Array();
8594
8992
 
8993
+ // Byte-stream idle-timeout watchdog (parity with CC tengu_byte_stream_idle_timeout_ms).
8994
+ // 0 = disabled (default). See resolveStreamIdleTimeoutMs.
8995
+ const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
8996
+
8595
8997
  /** @type {UsageStats} */
8596
8998
  const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
8597
8999
  let sseBuffer = "";
@@ -8671,7 +9073,38 @@ function transformResponse(response, onUsage, onAccountError) {
8671
9073
 
8672
9074
  const stream = new ReadableStream({
8673
9075
  async pull(controller) {
8674
- const { done, value } = await reader.read();
9076
+ let readResult;
9077
+ if (idleTimeoutMs > 0) {
9078
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
9079
+ let idleTimer;
9080
+ try {
9081
+ readResult = await Promise.race([
9082
+ reader.read(),
9083
+ new Promise((_resolve, reject) => {
9084
+ idleTimer = setTimeout(
9085
+ () => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
9086
+ idleTimeoutMs,
9087
+ );
9088
+ }),
9089
+ ]);
9090
+ } catch (err) {
9091
+ // Idle timeout (or read error): cancel the upstream reader and surface a
9092
+ // clear error so the consumer can retry instead of waiting on a dead
9093
+ // connection. Mirrors CC's stream-idle watchdog (no silent hang).
9094
+ try {
9095
+ await reader.cancel();
9096
+ } catch {
9097
+ // Reader may already be released; nothing to do.
9098
+ }
9099
+ controller.error(err instanceof Error ? err : new Error(String(err)));
9100
+ return;
9101
+ } finally {
9102
+ clearTimeout(idleTimer);
9103
+ }
9104
+ } else {
9105
+ readResult = await reader.read();
9106
+ }
9107
+ const { done, value } = readResult;
8675
9108
  if (done) {
8676
9109
  processSSEBuffer(true);
8677
9110
 
@@ -9023,6 +9456,8 @@ AnthropicAuthPlugin.__testing__ = {
9023
9456
  buildSystemPromptBlocks,
9024
9457
  stripMcpPrefixFromParsedEvent,
9025
9458
  CORE_TOOL_NAMES,
9459
+ // exposed for subagent-detection tests (x-parent-session-id header extraction)
9460
+ getIncomingHeader,
9026
9461
  // exposed for determinism regression tests (phase C1)
9027
9462
  applyContextHintCompaction,
9028
9463
  // exposed for session-dedupe regression tests (phase C3)
@@ -9058,4 +9493,19 @@ AnthropicAuthPlugin.__testing__ = {
9058
9493
  },
9059
9494
  };
9060
9495
 
9496
+ /**
9497
+ * Internal cache helpers exposed for unit testing only. Not part of the public
9498
+ * plugin API. Attached as a PROPERTY of the function for the SAME reason as
9499
+ * `__testing__` above: a bare `export const __cacheInternals = {...}` object
9500
+ * export breaks Opencode's plugin loader, which iterates `Object.values(mod)`
9501
+ * and throws "Plugin export is not a function" on ANY non-function export —
9502
+ * silently disabling the whole plugin (no slash command, no OAuth). Tests reach
9503
+ * these via `AnthropicAuthPlugin.__cacheInternals`.
9504
+ */
9505
+ AnthropicAuthPlugin.__cacheInternals = {
9506
+ resolveCacheTtl,
9507
+ shouldPlaceToolBreakpoint,
9508
+ updateBoundaryStability,
9509
+ };
9510
+
9061
9511
  export default AnthropicAuthPlugin;
package/lib/accounts.mjs CHANGED
@@ -47,6 +47,16 @@ function hashTokenFragment(token) {
47
47
 
48
48
  const MAX_ACCOUNTS = 10;
49
49
  const RATE_LIMIT_KEY = "anthropic";
50
+ // Fast mode (speed:"fast") has a SEPARATE rate-limit pool from standard Opus
51
+ // per Anthropic docs. We track it under a distinct bucket so that exhausting the
52
+ // fast pool does NOT block standard-speed requests on the same account (it should
53
+ // only trigger a fallback to standard speed). The key lives in the same
54
+ // rateLimitResetTimes record, so no schema migration is needed — existing
55
+ // accounts simply won't have this key until they hit a fast-mode rate limit, and
56
+ // #clearExpiredRateLimits already iterates all keys generically.
57
+ const RATE_LIMIT_KEY_FAST = "anthropic:fast";
58
+
59
+ export { RATE_LIMIT_KEY, RATE_LIMIT_KEY_FAST };
50
60
 
51
61
  /**
52
62
  * @typedef {object} StatsDelta
@@ -351,16 +361,29 @@ export class AccountManager {
351
361
  }
352
362
 
353
363
  /**
354
- * Check if an account is currently rate-limited.
364
+ * Check if an account is currently rate-limited for the given bucket.
365
+ * Defaults to the STANDARD bucket so that a fast-only rate limit never makes
366
+ * an account look unavailable for standard requests.
355
367
  * @param {ManagedAccount} account
368
+ * @param {string} [bucket] - Rate-limit bucket key (default: standard).
356
369
  * @returns {boolean}
357
370
  */
358
- #isRateLimited(account) {
371
+ #isRateLimited(account, bucket = RATE_LIMIT_KEY) {
359
372
  this.#clearExpiredRateLimits(account);
360
- const resetTime = account.rateLimitResetTimes[RATE_LIMIT_KEY];
373
+ const resetTime = account.rateLimitResetTimes[bucket];
361
374
  return resetTime !== undefined && Date.now() < resetTime;
362
375
  }
363
376
 
377
+ /**
378
+ * Public check: is this account rate-limited for the fast-mode pool?
379
+ * Used to decide whether to inject speed:"fast" on a request.
380
+ * @param {ManagedAccount} account
381
+ * @returns {boolean}
382
+ */
383
+ isFastRateLimited(account) {
384
+ return this.#isRateLimited(account, RATE_LIMIT_KEY_FAST);
385
+ }
386
+
364
387
  /**
365
388
  * Select the best account for the current request.
366
389
  * @param {Set<number>} [excludedIndices] - Temporary per-request exclusions
@@ -408,27 +431,43 @@ export class AccountManager {
408
431
 
409
432
  /**
410
433
  * Mark an account as rate-limited.
434
+ *
435
+ * When `bucket` is the FAST pool (RATE_LIMIT_KEY_FAST), this is a soft marker:
436
+ * it only cools down the fast pool and does NOT increment consecutiveFailures
437
+ * or apply a health penalty, because the standard pool is still fully usable
438
+ * (the request can simply fall back to standard speed). Marking the whole
439
+ * account unhealthy for a fast-only limit would defeat Anthropic's separate
440
+ * fast pool and waste standard quota.
411
441
  * @param {ManagedAccount} account
412
442
  * @param {RateLimitReason} reason
413
443
  * @param {number | null} [retryAfterMs]
444
+ * @param {string} [bucket] - Rate-limit bucket key (default: standard).
414
445
  * @returns {number} The backoff duration in ms
415
446
  */
416
- markRateLimited(account, reason, retryAfterMs) {
447
+ markRateLimited(account, reason, retryAfterMs, bucket = RATE_LIMIT_KEY) {
417
448
  const now = Date.now();
449
+ const isFastBucket = bucket === RATE_LIMIT_KEY_FAST;
418
450
 
419
- // Reset consecutive failures if TTL has expired
420
- if (account.lastFailureTime !== null && now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000) {
421
- account.consecutiveFailures = 0;
422
- }
451
+ if (!isFastBucket) {
452
+ // Reset consecutive failures if TTL has expired
453
+ if (account.lastFailureTime !== null && now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000) {
454
+ account.consecutiveFailures = 0;
455
+ }
423
456
 
424
- account.consecutiveFailures += 1;
425
- account.lastFailureTime = now;
457
+ account.consecutiveFailures += 1;
458
+ account.lastFailureTime = now;
459
+ }
426
460
 
427
- const backoffMs = calculateBackoffMs(reason, account.consecutiveFailures - 1, retryAfterMs);
461
+ // Fast bucket does not track consecutiveFailures, so use index 0 to avoid a
462
+ // negative array index in calculateBackoffMs (QUOTA_EXHAUSTED path).
463
+ const failureIndex = isFastBucket ? 0 : account.consecutiveFailures - 1;
464
+ const backoffMs = calculateBackoffMs(reason, failureIndex, retryAfterMs);
428
465
 
429
- account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + backoffMs;
466
+ account.rateLimitResetTimes[bucket] = now + backoffMs;
430
467
 
431
- this.#healthTracker.recordRateLimit(account.index);
468
+ if (!isFastBucket) {
469
+ this.#healthTracker.recordRateLimit(account.index);
470
+ }
432
471
 
433
472
  this.requestSaveToDisk();
434
473
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { AccountManager } from "./accounts.mjs";
2
+ import { AccountManager, RATE_LIMIT_KEY_FAST } from "./accounts.mjs";
3
3
  import { DEFAULT_CONFIG } from "./config.mjs";
4
4
 
5
5
  // Mock storage module — pass through pure helpers, mock I/O
@@ -331,6 +331,41 @@ describe("AccountManager rate limiting", () => {
331
331
  manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
332
332
  expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
333
333
  });
334
+
335
+ // ── Fast-mode pool isolation (Anthropic: fast has a SEPARATE rate-limit pool) ──
336
+ it("fast-bucket rate limit does NOT block standard requests on the same account", () => {
337
+ const account = manager.getCurrentAccount();
338
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
339
+ // Fast pool is limited...
340
+ expect(manager.isFastRateLimited(account)).toBe(true);
341
+ // ...but the account is still selectable for standard requests (standard
342
+ // bucket untouched, so getCurrentAccount still returns it).
343
+ expect(manager.getCurrentAccount()).not.toBeNull();
344
+ });
345
+
346
+ it("fast-bucket rate limit does NOT increment consecutiveFailures or set lastFailureTime", () => {
347
+ const account = manager.getCurrentAccount();
348
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
349
+ expect(account.consecutiveFailures).toBe(0);
350
+ expect(account.lastFailureTime).toBeNull();
351
+ expect(manager.isFastRateLimited(account)).toBe(true);
352
+ });
353
+
354
+ it("standard rate limit does NOT mark the fast bucket as limited", () => {
355
+ const account = manager.getCurrentAccount();
356
+ manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
357
+ expect(manager.isFastRateLimited(account)).toBe(false);
358
+ });
359
+
360
+ it("fast-bucket backoff returns a positive duration and expires independently", () => {
361
+ const account = manager.getCurrentAccount();
362
+ const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
363
+ expect(backoffMs).toBeGreaterThan(0);
364
+ expect(manager.isFastRateLimited(account)).toBe(true);
365
+ // After the backoff expires, the fast pool is available again.
366
+ vi.advanceTimersByTime(backoffMs + 1000);
367
+ expect(manager.isFastRateLimited(account)).toBe(false);
368
+ });
334
369
  });
335
370
 
336
371
  // ---------------------------------------------------------------------------
package/lib/config.mjs CHANGED
@@ -97,7 +97,7 @@ import { randomBytes } from "node:crypto";
97
97
  * @property {{ enabled: boolean, default_max_tokens: number, escalated_max_tokens: number }} output_cap
98
98
  * @property {{ enabled: boolean, timeout_ms: number }} preconnect
99
99
  * @property {{ enabled: boolean, safety_margin: number }} overflow_recovery
100
- * @property {{ enabled: boolean, alert_threshold: number }} cache_break_detection
100
+ * @property {{ enabled: boolean, alert_threshold: number, adaptive_breakpoint: boolean }} cache_break_detection
101
101
  * @property {{ enabled: boolean, background_max_service_retries: number, background_max_should_retries: number }} request_classification
102
102
  * @property {{ enabled: boolean, default: number, completion_threshold: number }} token_budget
103
103
  * @property {{ enabled: boolean, threshold_percent: number }} microcompact
@@ -191,6 +191,16 @@ export const DEFAULT_CONFIG = {
191
191
  * (no point suggesting reset at the start of a session). */
192
192
  min_turns_before_suggest: 3,
193
193
  },
194
+ /** SSE response streaming behavior. */
195
+ streaming: {
196
+ /** Byte-stream idle-timeout watchdog (ms). Parity with Claude Code's
197
+ * `tengu_byte_stream_idle_timeout_ms`: if the upstream produces no bytes for
198
+ * this long, the reader is cancelled and the stream errors instead of hanging.
199
+ * 0 = disabled (default — Anthropic SSE sends periodic pings, so only set a
200
+ * multi-minute value to catch genuinely dead connections). Env override:
201
+ * `OPENCODE_ANTHROPIC_STREAM_IDLE_TIMEOUT_MS`. */
202
+ idle_timeout_ms: 0,
203
+ },
194
204
  /** Token economy optimizations. */
195
205
  token_economy: {
196
206
  /** [DEPRECATED] token-efficient-tools-2026-03-28 was removed in CC v2.1.90.
@@ -276,15 +286,18 @@ export const DEFAULT_CONFIG = {
276
286
  * `ttl:"1h"` on an allowlist of query sources (`repl_main_thread*`, `sdk`,
277
287
  * `auto_mode`). Everything else falls back to the 5m tier, which is
278
288
  * cheaper to write. Matches by classifying request role: main → 1h (or
279
- * whatever signature.cachePolicy.ttl is configured as), else → 5m. */
280
- role_scoped_cache_ttl: false,
289
+ * whatever signature.cachePolicy.ttl is configured as), else → 5m.
290
+ * Default ON: this MATCHES real CC behaviour (main thread 1h, side-queries
291
+ * 5m) and only makes non-main cache writes cheaper — no fingerprint risk. */
292
+ role_scoped_cache_ttl: true,
281
293
 
282
294
  /** Lean system prompt for non-main requests. For title-gen / small /
283
295
  * subagent-shaped requests, strip billing identity + CC identity
284
- * injection. Saves ~1-2kB per request. Opt-in: changes the system-prompt
285
- * shape that downstream observers (billing/telemetry) may rely on, so
286
- * enable deliberately rather than by default. */
287
- lean_system_non_main: false,
296
+ * injection. Saves ~1-2kB per request. Default ON: only affects non-main
297
+ * (title-gen / subagent) requests where the CC identity/billing block is
298
+ * not load-bearing for the main-thread fingerprint; the main interactive
299
+ * thread is untouched. */
300
+ lean_system_non_main: true,
288
301
 
289
302
  /** Simple system prompt mode (real CC v2.1.133+ `tengu_vellum_lantern`
290
303
  * equivalent). When enabled AND the request model is eligible
@@ -294,8 +307,11 @@ export const DEFAULT_CONFIG = {
294
307
  * Conservative implementation: gates ONLY anti-verbosity. Identity
295
308
  * preamble, billing block, user instructions, and `<system-reminder>`
296
309
  * blocks are all preserved so the CC fingerprint match still holds.
297
- * Saves ~600-1500 tokens per request on eligible models. Opt-in. */
298
- simple_system_prompt: false,
310
+ * Saves ~600-1500 tokens per request on eligible models. Default ON:
311
+ * gates ONLY the anti-verbosity boilerplate (which real CC v2.1.133+ also
312
+ * drops for eligible Opus 4.x via tengu_vellum_lantern); identity, billing
313
+ * and system-reminders are preserved, so the fingerprint match holds. */
314
+ simple_system_prompt: true,
299
315
 
300
316
  // NOTE: identical-tool-call short-circuit was considered but requires SSE
301
317
  // response-stream rewriting that breaks normal tool-execution semantics.
@@ -361,6 +377,17 @@ export const DEFAULT_CONFIG = {
361
377
  cache_break_detection: {
362
378
  enabled: true,
363
379
  alert_threshold: 2_000,
380
+ /**
381
+ * Adaptive breakpoint placement (opt-in). When true, the prompt-cache
382
+ * breakpoint is anchored on the most-stable boundary (e.g. the system
383
+ * prompt) instead of always on the last tool. This is a DELIBERATE
384
+ * divergence from Claude Code, useful for hosts (like opencode) whose tool
385
+ * array order/count is less stable than CC's. Default true (opencode's tool
386
+ * presentation is less stable than CC's, so adaptive anchoring improves cache
387
+ * hit rate). Set false to force exact CC behavior. Safe either way: with no
388
+ * stability baseline (first turns) it falls back to CC's last-tool breakpoint.
389
+ */
390
+ adaptive_breakpoint: true,
364
391
  },
365
392
  /** Request classification: reduced retry budget for background requests. */
366
393
  request_classification: {
@@ -885,6 +912,10 @@ function validateConfig(raw) {
885
912
  1_000_000,
886
913
  DEFAULT_CONFIG.cache_break_detection.alert_threshold,
887
914
  ),
915
+ adaptive_breakpoint:
916
+ typeof cbd.adaptive_breakpoint === "boolean"
917
+ ? cbd.adaptive_breakpoint
918
+ : DEFAULT_CONFIG.cache_break_detection.adaptive_breakpoint,
888
919
  };
889
920
  }
890
921
 
@@ -16,9 +16,15 @@
16
16
  // Version / build / SDK constants
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.150";
19
+ export const FALLBACK_CLAUDE_CLI_VERSION = "2.1.159";
20
20
  export const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
21
- export const CLAUDE_CODE_BUILD_TIME = "2026-05-15T17:39:39Z";
21
+ // Real build markers extracted from the 2.1.159 linux-x64 native binary
22
+ // (Bun-embedded JS): `BUILD_TIME:"2026-05-31T16:22:50Z"`,
23
+ // `GIT_SHA:"dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b"`.
24
+ // (Prior 2.1.154 win32-x64: BUILD_TIME 2026-05-28T12:27:24Z,
25
+ // GIT_SHA b84d2da9ada13121515426fc644786a303e9ac53.)
26
+ export const CLAUDE_CODE_BUILD_TIME = "2026-05-31T16:22:50Z";
27
+ export const CLAUDE_CODE_GIT_SHA = "dd8c11fc8d05cea0b2b9fc8f5a99a5c5c5dffc9b";
22
28
 
23
29
  // The @anthropic-ai/sdk version bundled with Claude Code.
24
30
  // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
@@ -32,6 +38,22 @@ export const ANTHROPIC_SDK_VERSION = "0.94.0";
32
38
 
33
39
  // Map of CLI version to bundled SDK version (update when CLI version changes)
34
40
  export const CLI_TO_SDK_VERSION = new Map([
41
+ // 2.1.155-2.1.159: only wire change is the revival of
42
+ // summarize-connector-text-2026-03-13 (label narration_summaries), gated by
43
+ // GrowthBook pewter_owl_header (default-off) — not always-on. SDK still 0.94.0
44
+ // (binary-verified: x-stainless-package-version NQ="0.94.0" in 2.1.159).
45
+ ["2.1.159", "0.94.0"],
46
+ ["2.1.158", "0.94.0"],
47
+ ["2.1.157", "0.94.0"],
48
+ ["2.1.156", "0.94.0"],
49
+ ["2.1.155", "0.94.0"],
50
+ // 2.1.151-2.1.154: Opus 4.8 launch (2026-05-28). npm now ships a native
51
+ // binary (no JS bundle), so the bundled SDK version can't be string-extracted;
52
+ // no SDK bump was observed across 2.1.150->2.1.154, so we carry 0.94.0 forward.
53
+ ["2.1.154", "0.94.0"],
54
+ ["2.1.153", "0.94.0"],
55
+ ["2.1.152", "0.94.0"],
56
+ ["2.1.151", "0.94.0"],
35
57
  ["2.1.150", "0.94.0"],
36
58
  ["2.1.143", "0.81.0"],
37
59
  ["2.1.142", "0.81.0"],
@@ -114,6 +136,9 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
114
136
  "advisor-tool-2026-03-01",
115
137
  "afk-mode-2026-01-31",
116
138
  "cache-diagnosis-2026-04-07",
139
+ // CCR bring-your-own-cloud beta. Registered in the 2.1.154 binary's _W() table
140
+ // but not emitted on /v1/messages; kept here for the disable-experimental guard.
141
+ "ccr-byoc-2025-07-29",
117
142
  "code-execution-2025-08-25",
118
143
  "compact-2026-01-12",
119
144
  "context-1m-2025-08-07",
@@ -124,6 +149,9 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
124
149
  "fast-mode-2026-02-01",
125
150
  "files-api-2025-04-14",
126
151
  "interleaved-thinking-2025-05-14",
152
+ // Registered in the 2.1.154 binary's _W() table (mcp_servers label). Plugin
153
+ // proxies MCP tool calls inline and does not emit this on chat completions.
154
+ "mcp-servers-2025-12-04",
127
155
  // Registered in CC v2.1.143 but NOT in real CC's always-on emission set
128
156
  // (master registry only, not in the always-on subset). Likely GrowthBook-gated
129
157
  // pending server rollout. Paired telemetry: tengu_mid_conv_system_fallback_retry.
@@ -131,6 +159,11 @@ export const EXPERIMENTAL_BETA_FLAGS = new Set([
131
159
  "prompt-caching-scope-2026-01-05",
132
160
  "redact-thinking-2026-02-12",
133
161
  "structured-outputs-2025-12-15",
162
+ // Revived in CC 2.1.159 as registry label `narration_summaries` (it was a dead
163
+ // slot from v2.1.90-2.1.154). Real CC emits it only when GrowthBook flag
164
+ // `pewter_owl_header` is on (default-off), first-party, and NOT in fast-mode.
165
+ // Listed here for the disable-guard + manual opt-in ONLY; never emitted always-on.
166
+ "summarize-connector-text-2026-03-13",
134
167
  "task-budgets-2026-03-13",
135
168
  "tool-search-tool-2025-10-19",
136
169
  // SDK admin route beta (used for /v1/user_profiles* endpoints). Registered for
@@ -172,6 +205,9 @@ export const BETA_SHORTCUTS = new Map([
172
205
  ["advisor-tool", "advisor-tool-2026-03-01"],
173
206
  ["mcp-servers", "mcp-servers-2025-12-04"],
174
207
  ["ccr-byoc", "ccr-byoc-2025-07-29"],
208
+ ["connector-text", "summarize-connector-text-2026-03-13"],
209
+ ["narration-summaries", "summarize-connector-text-2026-03-13"],
210
+ ["summarize-connector-text", "summarize-connector-text-2026-03-13"],
175
211
  ]);
176
212
 
177
213
  // ---------------------------------------------------------------------------
@@ -13,8 +13,8 @@ import {
13
13
  } from "./request-headers.mjs";
14
14
 
15
15
  describe("request-headers constants", () => {
16
- it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.150", () => {
17
- expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.150");
16
+ it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.159", () => {
17
+ expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.159");
18
18
  });
19
19
 
20
20
  it("ANTHROPIC_SDK_VERSION matches expected value", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",