opencode-anthropic-fix 0.0.37 → 0.0.38

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.
Files changed (3) hide show
  1. package/index.mjs +204 -25
  2. package/lib/config.mjs +14 -0
  3. package/package.json +1 -1
package/index.mjs CHANGED
@@ -158,6 +158,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
158
158
  let willowLastRequestTime = Date.now();
159
159
  let willowLastSuggestionTime = 0;
160
160
 
161
+ // Beta header latching: once a beta is sent in a session, it stays on for
162
+ // the rest of the session to prevent cache key churn (~50-70K tokens per flip).
163
+ // Cleared on /clear or /compact if needed.
164
+ const betaLatchState = {
165
+ /** @type {Set<string>} betas that have been sent at least once this session */
166
+ sent: new Set(),
167
+ /** When true, a config change invalidated the latch and next request rebuilds. */
168
+ dirty: false,
169
+ /** @type {string | null} The last computed beta header string (for latching). */
170
+ lastHeader: null,
171
+ };
172
+
173
+ // Cache TTL session latching: latch the cache policy at session start
174
+ // so mid-session toggles don't bust the server-side prompt cache.
175
+ let sessionCachePolicyLatched = false;
176
+ /** @type {{ttl: string, ttl_supported: boolean, boundary_marker?: boolean} | null} */
177
+ let latchedCachePolicy = null;
178
+
161
179
  /**
162
180
  * Whether OPENCODE_ANTHROPIC_INITIAL_ACCOUNT env var pinned this session to a
163
181
  * specific account. When true, syncActiveIndexFromDisk is skipped and strategy
@@ -846,6 +864,43 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
846
864
  adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
847
865
  }
848
866
  },
867
+ "token-efficient-tools": () => {
868
+ const enabled = value === "on" || value === "true" || value === "1";
869
+ const te = config.token_economy || {
870
+ token_efficient_tools: true,
871
+ redact_thinking: false,
872
+ connector_text_summarization: true,
873
+ };
874
+ te.token_efficient_tools = enabled;
875
+ saveConfig({ token_economy: te });
876
+ config.token_economy = te;
877
+ // Invalidate latched betas so the change takes effect next request
878
+ betaLatchState.dirty = true;
879
+ },
880
+ "redact-thinking": () => {
881
+ const enabled = value === "on" || value === "true" || value === "1";
882
+ const te = config.token_economy || {
883
+ token_efficient_tools: true,
884
+ redact_thinking: false,
885
+ connector_text_summarization: true,
886
+ };
887
+ te.redact_thinking = enabled;
888
+ saveConfig({ token_economy: te });
889
+ config.token_economy = te;
890
+ betaLatchState.dirty = true;
891
+ },
892
+ "connector-text": () => {
893
+ const enabled = value === "on" || value === "true" || value === "1";
894
+ const te = config.token_economy || {
895
+ token_efficient_tools: true,
896
+ redact_thinking: false,
897
+ connector_text_summarization: true,
898
+ };
899
+ te.connector_text_summarization = enabled;
900
+ saveConfig({ token_economy: te });
901
+ config.token_economy = te;
902
+ betaLatchState.dirty = true;
903
+ },
849
904
  };
850
905
 
851
906
  if (!key || !setters[key]) {
@@ -2397,7 +2452,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2397
2452
 
2398
2453
  const _adaptiveOverride = _adaptiveOverrideForRequest;
2399
2454
 
2400
- const computedBetaHeader = buildAnthropicBetaHeader(
2455
+ // Token economy config (resolved once, passed to beta builder)
2456
+ const _tokenEconomy = config.token_economy || {};
2457
+
2458
+ let computedBetaHeader = buildAnthropicBetaHeader(
2401
2459
  "",
2402
2460
  getSignatureEmulationEnabled(),
2403
2461
  _reqModel,
@@ -2407,8 +2465,43 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2407
2465
  requestUrl?.pathname,
2408
2466
  _reqHasFileRefs,
2409
2467
  _adaptiveOverride,
2468
+ _tokenEconomy,
2410
2469
  );
2411
2470
 
2471
+ // Beta header latching: once a beta has been sent in this session,
2472
+ // keep sending it to avoid server-side cache key churn (~50-70K tokens
2473
+ // per flip). The latch is sticky-on: new betas can be added but never
2474
+ // removed mid-session unless explicitly invalidated by config change.
2475
+ {
2476
+ const currentBetas = computedBetaHeader
2477
+ .split(",")
2478
+ .map((b) => b.trim())
2479
+ .filter(Boolean);
2480
+ // Add all current betas to the latch set
2481
+ for (const b of currentBetas) betaLatchState.sent.add(b);
2482
+ // If a config change dirtied the latch, rebuild from current (allow removal)
2483
+ if (betaLatchState.dirty) {
2484
+ betaLatchState.dirty = false;
2485
+ betaLatchState.sent = new Set(currentBetas);
2486
+ }
2487
+ // Merge latched betas that aren't in the current set
2488
+ const merged = new Set(currentBetas);
2489
+ for (const b of betaLatchState.sent) merged.add(b);
2490
+ computedBetaHeader = [...merged].join(",");
2491
+ betaLatchState.lastHeader = computedBetaHeader;
2492
+ }
2493
+
2494
+ // Cache TTL session latching: latch the cache policy at session start
2495
+ // so mid-session toggles don't bust the server-side prompt cache.
2496
+ if (!sessionCachePolicyLatched) {
2497
+ sessionCachePolicyLatched = true;
2498
+ latchedCachePolicy = config.cache_policy
2499
+ ? { ...config.cache_policy }
2500
+ : { ttl: "1h", ttl_supported: true };
2501
+ }
2502
+ const effectiveCachePolicy = latchedCachePolicy ||
2503
+ config.cache_policy || { ttl: "1h", ttl_supported: true };
2504
+
2412
2505
  let body = transformRequestBody(
2413
2506
  requestInit.body,
2414
2507
  {
@@ -2416,7 +2509,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2416
2509
  claudeCliVersion,
2417
2510
  promptCompactionMode: getPromptCompactionMode(),
2418
2511
  provider: _reqProvider,
2419
- cachePolicy: config.cache_policy || { ttl: "1h", ttl_supported: true },
2512
+ cachePolicy: effectiveCachePolicy,
2420
2513
  fastMode: config.fast_mode || false,
2421
2514
  strategy: getEffectiveStrategy(),
2422
2515
  },
@@ -2443,6 +2536,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2443
2536
  strategy: getEffectiveStrategy(),
2444
2537
  },
2445
2538
  _adaptiveOverride,
2539
+ _tokenEconomy,
2446
2540
  );
2447
2541
  // Execute the request
2448
2542
  let response;
@@ -2629,10 +2723,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2629
2723
  // Auto-escalate adaptive context on prompt_too_long so the retry
2630
2724
  // includes the 1M beta header (if model supports it).
2631
2725
  if (config.adaptive_context?.enabled) {
2632
- forceEscalateAdaptiveContext();
2633
- toast("⬡ 1M context force-activated (prompt too long)", "warning", {
2634
- debounceKey: "adaptive-ctx",
2635
- }).catch(() => {});
2726
+ const stateChanged = forceEscalateAdaptiveContext();
2727
+ if (stateChanged) {
2728
+ // Invalidate cached adaptive decision so the retry loop
2729
+ // re-evaluates with the new active=true state.
2730
+ _adaptiveDecisionMade = false;
2731
+ toast("⬡ 1M context force-activated (prompt too long)", "warning", {
2732
+ debounceKey: "adaptive-ctx",
2733
+ }).catch(() => {});
2734
+ }
2636
2735
  }
2637
2736
  try {
2638
2737
  const parsedBody = JSON.parse(body);
@@ -3215,7 +3314,7 @@ function estimatePromptTokens(bodyString) {
3215
3314
  * @returns {boolean}
3216
3315
  */
3217
3316
  function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
3218
- // Non-adaptive: use static check
3317
+ // Non-adaptive: use static check (only explicit "1m" models)
3219
3318
  if (!adaptiveConfig.enabled) {
3220
3319
  return hasOneMillionContext(model);
3221
3320
  }
@@ -3225,8 +3324,8 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
3225
3324
  return false;
3226
3325
  }
3227
3326
 
3228
- // Model must be eligible for 1M context at all
3229
- if (!hasOneMillionContext(model)) {
3327
+ // Model must be eligible for 1M context at all (includes Opus 4.6)
3328
+ if (!isEligibleFor1MContext(model)) {
3230
3329
  return false;
3231
3330
  }
3232
3331
 
@@ -3235,10 +3334,25 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
3235
3334
 
3236
3335
  if (adaptiveContextState.active) {
3237
3336
  // Currently active — consider de-escalation
3238
- // Don't de-escalate if error-escalated (sticky)
3337
+
3338
+ // Error-escalated: sticky for ERROR_STICKY_TURNS turns, then allow de-escalation
3339
+ // if prompt has dropped well below threshold (prevents permanent 1M lock-in).
3340
+ const ERROR_STICKY_TURNS = 5;
3239
3341
  if (adaptiveContextState.escalatedByError) {
3240
- return true;
3342
+ if (turnsSinceTransition < ERROR_STICKY_TURNS) {
3343
+ return true; // Still within sticky window
3344
+ }
3345
+ // Past sticky window: allow de-escalation if tokens dropped significantly
3346
+ // (below 75% of deescalation threshold to avoid flapping)
3347
+ if (estimatedTokens < adaptiveConfig.deescalation_threshold * 0.75) {
3348
+ adaptiveContextState.active = false;
3349
+ adaptiveContextState.escalatedByError = false;
3350
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
3351
+ return false;
3352
+ }
3353
+ return true; // Still high enough to keep 1M
3241
3354
  }
3355
+
3242
3356
  // Hysteresis: require at least 2 turns before considering de-escalation
3243
3357
  if (turnsSinceTransition < 2) {
3244
3358
  return true;
@@ -3268,13 +3382,17 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
3268
3382
 
3269
3383
  /**
3270
3384
  * Force-escalate adaptive context (e.g. after prompt_too_long error).
3385
+ * Returns true so callers can invalidate cached decisions.
3386
+ * @returns {boolean}
3271
3387
  */
3272
3388
  function forceEscalateAdaptiveContext() {
3389
+ const wasActive = adaptiveContextState.active;
3273
3390
  if (!adaptiveContextState.active) {
3274
3391
  adaptiveContextState.active = true;
3275
3392
  adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
3276
3393
  }
3277
3394
  adaptiveContextState.escalatedByError = true;
3395
+ return !wasActive; // true if state actually changed
3278
3396
  }
3279
3397
 
3280
3398
  const MODEL_PRICING = {
@@ -3656,6 +3774,8 @@ const EXPERIMENTAL_BETA_FLAGS = new Set([
3656
3774
  "prompt-caching-scope-2026-01-05",
3657
3775
  "redact-thinking-2026-02-12",
3658
3776
  "structured-outputs-2025-12-15",
3777
+ "summarize-connector-text-2026-03-13",
3778
+ "token-efficient-tools-2026-03-28",
3659
3779
  "tool-examples-2025-10-29",
3660
3780
  "tool-search-tool-2025-10-19",
3661
3781
  "web-search-2025-03-05",
@@ -3669,6 +3789,11 @@ const BETA_SHORTCUTS = new Map([
3669
3789
  ["opus-fast", "fast-mode-2026-02-01"],
3670
3790
  ["task-budgets", "task-budgets-2026-03-13"],
3671
3791
  ["budgets", "task-budgets-2026-03-13"],
3792
+ ["efficient-tools", "token-efficient-tools-2026-03-28"],
3793
+ ["token-efficient", "token-efficient-tools-2026-03-28"],
3794
+ ["redact-thinking", "redact-thinking-2026-02-12"],
3795
+ ["connector-text", "summarize-connector-text-2026-03-13"],
3796
+ ["anti-distill", "summarize-connector-text-2026-03-13"],
3672
3797
  ]);
3673
3798
  const STAINLESS_HELPER_KEYS = [
3674
3799
  "x_stainless_helper",
@@ -3884,14 +4009,26 @@ function isAdaptiveThinkingModel(model) {
3884
4009
  }
3885
4010
 
3886
4011
  /**
3887
- * @param {string | undefined} body
4012
+ * Check if a model is eligible for 1M context (can receive context-1m beta).
4013
+ * This includes models with explicit "1m" in the name AND Opus 4.6.
4014
+ * @param {string} model
3888
4015
  * @returns {boolean}
3889
4016
  */
3890
- function hasOneMillionContext(model) {
3891
- // Models with explicit 1m suffix, or Opus 4.6 (1M by default since v2.1.75).
4017
+ function isEligibleFor1MContext(model) {
3892
4018
  return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model) || isOpus46Model(model);
3893
4019
  }
3894
4020
 
4021
+ /**
4022
+ * Check if a model should ALWAYS use 1M context (static mode, no adaptive gating).
4023
+ * Only models with explicit "1m" in the name — NOT bare Opus 4.6.
4024
+ * When adaptive_context is enabled, Opus 4.6 uses the adaptive decision instead.
4025
+ * @param {string} model
4026
+ * @returns {boolean}
4027
+ */
4028
+ function hasOneMillionContext(model) {
4029
+ return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model);
4030
+ }
4031
+
3895
4032
  /**
3896
4033
  * @param {string | undefined} body
3897
4034
  * @returns {boolean}
@@ -4324,6 +4461,7 @@ function buildAnthropicBetaHeader(
4324
4461
  requestPath,
4325
4462
  hasFileReferences,
4326
4463
  adaptiveOverride,
4464
+ tokenEconomy,
4327
4465
  ) {
4328
4466
  const incomingBetasList = incomingBeta
4329
4467
  .split(",")
@@ -4350,13 +4488,23 @@ function buildAnthropicBetaHeader(
4350
4488
  const nonInteractive = isNonInteractiveMode();
4351
4489
  const haiku = isHaikuModel(model);
4352
4490
  const isRoundRobin = strategy === "round-robin";
4491
+ const te = tokenEconomy || {};
4353
4492
 
4354
4493
  // === ALWAYS-ON BETAS (Claude Code v2.1.84 base set) ===
4355
4494
  // These are ALWAYS included regardless of env vars or feature flags.
4356
4495
  if (!haiku) {
4357
4496
  betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
4358
4497
  }
4359
- betas.push(ADVANCED_TOOL_USE_BETA_FLAG); // "advanced-tool-use-2025-11-20"
4498
+
4499
+ // Tool search: use provider-aware header.
4500
+ // 1P/Foundry → advanced-tool-use-2025-11-20 (enables broader tool capabilities)
4501
+ // Vertex/Bedrock → tool-search-tool-2025-10-19 (3P-compatible subset)
4502
+ if (provider === "vertex" || provider === "bedrock") {
4503
+ betas.push("tool-search-tool-2025-10-19");
4504
+ } else {
4505
+ betas.push(ADVANCED_TOOL_USE_BETA_FLAG); // "advanced-tool-use-2025-11-20"
4506
+ }
4507
+
4360
4508
  betas.push(FAST_MODE_BETA_FLAG); // "fast-mode-2026-02-01"
4361
4509
  betas.push(EFFORT_BETA_FLAG); // "effort-2025-11-24"
4362
4510
 
@@ -4386,8 +4534,14 @@ function buildAnthropicBetaHeader(
4386
4534
  // Context management — always-on in Claude Code (not env-gated)
4387
4535
  betas.push("context-management-2025-06-27");
4388
4536
 
4389
- // Structured outputs — always-on for capable models
4390
- if (supportsStructuredOutputs(model)) {
4537
+ // Structured outputs vs token-efficient-tools: mutually exclusive.
4538
+ // token-efficient-tools gives ~4.5% output token reduction (JSON FC v3).
4539
+ // When both are configured, token-efficient-tools wins (per CC source:
4540
+ // "strict wins" internally, but we prefer token savings for proxy users).
4541
+ if (te.token_efficient_tools && !disableExperimentalBetas) {
4542
+ betas.push("token-efficient-tools-2026-03-28");
4543
+ // Skip structured-outputs — API rejects both together
4544
+ } else if (supportsStructuredOutputs(model)) {
4391
4545
  betas.push("structured-outputs-2025-12-15");
4392
4546
  }
4393
4547
 
@@ -4396,9 +4550,6 @@ function buildAnthropicBetaHeader(
4396
4550
  betas.push("web-search-2025-03-05");
4397
4551
  }
4398
4552
 
4399
- // Tool search tool
4400
- betas.push("tool-search-tool-2025-10-19");
4401
-
4402
4553
  // Files API — scoped to file endpoints/references
4403
4554
  if (isFilesEndpoint || hasFileReferences) {
4404
4555
  betas.push("files-api-2025-04-14");
@@ -4409,10 +4560,20 @@ function buildAnthropicBetaHeader(
4409
4560
  betas.push(TOKEN_COUNTING_BETA_FLAG);
4410
4561
  }
4411
4562
 
4412
- // === OPTIONAL BETAS (env-gated, intentionally NOT always-on) ===
4563
+ // === TOKEN ECONOMY BETAS (config-controlled) ===
4564
+
4565
+ // redact-thinking: suppresses thinking summaries server-side.
4566
+ // When enabled, API returns redacted_thinking blocks instead of summaries.
4567
+ // Saves bandwidth and tokens for users who don't inspect thinking.
4568
+ if (te.redact_thinking && !disableExperimentalBetas) {
4569
+ betas.push("redact-thinking-2026-02-12");
4570
+ }
4413
4571
 
4414
- // redact-thinking — NOT auto-included (users benefit from seeing thinking blocks)
4415
- // Available via: /anthropic betas add redact-thinking-2026-02-12
4572
+ // summarize-connector-text: API summarizes assistant text between tool calls.
4573
+ // Anti-distillation measure — same mechanism as thinking blocks (summary + signature).
4574
+ if (te.connector_text_summarization && !disableExperimentalBetas) {
4575
+ betas.push("summarize-connector-text-2026-03-13");
4576
+ }
4416
4577
 
4417
4578
  // afk-mode — NOT auto-included (requires user opt-in)
4418
4579
  // Available via: /anthropic betas add afk-mode-2026-01-31
@@ -4559,7 +4720,16 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
4559
4720
  * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy, customBetas?: string[]}} signature
4560
4721
  * @returns {Headers}
4561
4722
  */
4562
- function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature, adaptiveOverride) {
4723
+ function buildRequestHeaders(
4724
+ input,
4725
+ requestInit,
4726
+ accessToken,
4727
+ requestBody,
4728
+ requestUrl,
4729
+ signature,
4730
+ adaptiveOverride,
4731
+ tokenEconomy,
4732
+ ) {
4563
4733
  const requestHeaders = new Headers();
4564
4734
  if (input instanceof Request) {
4565
4735
  input.headers.forEach((value, key) => {
@@ -4600,6 +4770,7 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
4600
4770
  requestUrl?.pathname,
4601
4771
  hasFileReferences,
4602
4772
  adaptiveOverride,
4773
+ tokenEconomy,
4603
4774
  );
4604
4775
 
4605
4776
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
@@ -4753,7 +4924,15 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
4753
4924
  // Cache breakpoint optimization: add cache_control to the last content block
4754
4925
  // of each user/assistant message for maximum prefix caching.
4755
4926
  // Skip for round-robin strategy (cache defeated by account rotation).
4756
- if (signature.enabled && signature.cachePolicy?.ttl !== "off" && signature.cachePolicy?.ttl_supported !== false) {
4927
+ // Skip for title generators / fire-and-forget queries: these are one-shot
4928
+ // requests that don't benefit from caching and would pollute the cache pool.
4929
+ const isTitleGen = isTitleGeneratorSystemBlocks(parsed.system || []);
4930
+ if (
4931
+ signature.enabled &&
4932
+ signature.cachePolicy?.ttl !== "off" &&
4933
+ signature.cachePolicy?.ttl_supported !== false &&
4934
+ !isTitleGen
4935
+ ) {
4757
4936
  const strategy = signature.strategy || "sticky";
4758
4937
  if (strategy !== "round-robin" && Array.isArray(parsed.messages)) {
4759
4938
  const cacheControl = { type: "ephemeral", ttl: signature.cachePolicy?.ttl || "1h" };
package/lib/config.mjs CHANGED
@@ -166,6 +166,19 @@ export const DEFAULT_CONFIG = {
166
166
  * (no point suggesting reset at the start of a session). */
167
167
  min_turns_before_suggest: 3,
168
168
  },
169
+ /** Token economy optimizations. */
170
+ token_economy: {
171
+ /** Enable token-efficient-tools-2026-03-28 beta (~4.5% output token reduction).
172
+ * Mutually exclusive with structured-outputs — this flag takes precedence. */
173
+ token_efficient_tools: true,
174
+ /** Enable redact-thinking-2026-02-12 beta (suppresses thinking summaries
175
+ * server-side for lower bandwidth). Off by default — set to true if you
176
+ * don't need to inspect thinking blocks. */
177
+ redact_thinking: false,
178
+ /** Enable summarize-connector-text-2026-03-13 beta (API summarizes assistant
179
+ * text between tool calls — anti-distillation measure). */
180
+ connector_text_summarization: true,
181
+ },
169
182
  };
170
183
 
171
184
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -194,6 +207,7 @@ function createDefaultConfig() {
194
207
  cc_credential_reuse: { ...DEFAULT_CONFIG.cc_credential_reuse },
195
208
  adaptive_context: { ...DEFAULT_CONFIG.adaptive_context },
196
209
  willow_mode: { ...DEFAULT_CONFIG.willow_mode },
210
+ token_economy: { ...DEFAULT_CONFIG.token_economy },
197
211
  };
198
212
  }
199
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [