opencode-anthropic-fix 0.6.0 → 2.0.0

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
@@ -3,19 +3,14 @@ import { stdin, stdout } from "node:process";
3
3
  import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
- import { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
6
+ import { AccountManager, RATE_LIMIT_KEY_FAST, warnOnEphemeralTokenRotation } from "./lib/accounts.mjs";
7
7
  import {
8
8
  authorize as oauthAuthorize,
9
9
  exchange as oauthExchange,
10
10
  parseOAuthCallback,
11
11
  refreshToken,
12
12
  } from "./lib/oauth.mjs";
13
- import {
14
- FALLBACK_CLAUDE_CLI_VERSION,
15
- CLAUDE_CODE_NPM_LATEST_URL,
16
- CLAUDE_CODE_BUILD_TIME,
17
- resolveBetaShortcut,
18
- } from "./lib/request-headers.mjs";
13
+ import { resolveBetaShortcut } from "./lib/betas.mjs";
19
14
  import { loadConfig, loadConfigFresh, saveConfig, getConfigDir } from "./lib/config.mjs";
20
15
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
21
16
  import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
@@ -37,7 +32,7 @@ import {
37
32
  maybeApplySessionToolResultDedupe,
38
33
  applyContextHintCompaction,
39
34
  } from "./lib/token-economy/transforms.mjs";
40
- import { shouldMicrocompact, buildMicrocompactBetas } from "./lib/token-economy/microcompact.mjs";
35
+ import { shouldMicrocompact } from "./lib/token-economy/microcompact.mjs";
41
36
  import { redactSecrets, redactString } from "./lib/redact.mjs";
42
37
  import {
43
38
  createTransformedSSEStream,
@@ -49,7 +44,12 @@ import { isTruthyEnv } from "./lib/env.mjs";
49
44
  import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
50
45
  import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
51
46
  import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
52
- import { SERVICE_WIDE_MAX_RETRIES, CONSECUTIVE_529_FALLBACK_THRESHOLD } from "./lib/tuning.mjs";
47
+ import {
48
+ SERVICE_WIDE_MAX_RETRIES,
49
+ CONSECUTIVE_529_FALLBACK_THRESHOLD,
50
+ FOREGROUND_REFRESH_EXPIRY_BUFFER_MS,
51
+ } from "./lib/tuning.mjs";
52
+ import { isTokenExpired, isTokenUsable } from "./lib/account-state.mjs";
53
53
  import {
54
54
  computeServiceRetrySleepMs,
55
55
  selectFallbackModel,
@@ -58,7 +58,6 @@ import {
58
58
  } from "./lib/retry/overload-loop.mjs";
59
59
  import {
60
60
  buildRequestHeaders,
61
- buildAnthropicBetaHeader,
62
61
  parseRequestBodyMetadata,
63
62
  extractFileIds,
64
63
  stripStainlessHelperMarkers,
@@ -68,25 +67,31 @@ import {
68
67
  compactSystemText,
69
68
  compactToolDescription,
70
69
  dedupeSystemBlocks,
71
- getCachedCCPrompt,
72
70
  isTitleGeneratorSystemBlocks,
73
71
  normalizeSystemTextBlocks,
74
- resetCachedCCPrompt,
75
72
  sanitizeSystemText,
76
73
  CLAUDE_CODE_IDENTITY_STRING,
77
- SUBAGENT_CC_ANCHOR,
78
74
  } from "./lib/mimicry/system-prompt.mjs";
79
- import { buildAdapterTransport, resolveAdapterEnv } from "./lib/mimicry/adapter-input.mjs";
80
- import { buildWireCompatibleRequest, buildWireCompatibleCountTokensRequest } from "./lib/mimicry/wire-compat.mjs";
81
75
  import {
76
+ ADAPTER_COUNT_TOKENS_PATHNAMES,
77
+ ADAPTER_MESSAGES_PATHNAMES,
78
+ assertAdapterBodyUsable,
79
+ buildAdapterTransport,
80
+ resolveAdapterEnv,
81
+ } from "./lib/mimicry/adapter-input.mjs";
82
+ import { buildPassthroughHeaders, stripNonApiBodyFields } from "./lib/passthrough-headers.mjs";
83
+ import {
84
+ buildWireCompatibleRequest,
85
+ buildWireCompatibleCountTokensRequest,
86
+ WIRE_PROFILE,
82
87
  hasOneMillionContext,
83
- isEligibleFor1MContext,
88
+ isEligibleFor1MContextWire,
84
89
  isOpus46Model,
85
90
  isOpus47Model,
86
91
  isOpus48Model,
87
- } from "./lib/mimicry/models.mjs";
92
+ } from "./lib/mimicry/wire-compat.mjs";
88
93
 
89
- export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/models.mjs";
94
+ export { isFable5Model, isMythos5Model, isAdaptiveThinkingModel } from "./lib/mimicry/wire-compat.mjs";
90
95
 
91
96
  // Max times a single logical request may fall back from fast->standard speed on
92
97
  // the same account before giving up the fast attempt entirely. 1 is enough: one
@@ -298,18 +303,6 @@ export async function AnthropicAuthPlugin({ client }) {
298
303
  let willowLastSuggestionTime = 0;
299
304
  let _lastOAuthPruneTime = 0; // QA fix L-oauthPrune: throttle for periodic prune
300
305
 
301
- // Beta header latching: once a beta is sent in a session, it stays on for
302
- // the rest of the session to prevent cache key churn (~50-70K tokens per flip).
303
- // Cleared on /clear or /compact if needed.
304
- const betaLatchState = {
305
- /** @type {Set<string>} betas that have been sent at least once this session */
306
- sent: new Set(),
307
- /** When true, a config change invalidated the latch and next request rebuilds. */
308
- dirty: false,
309
- /** @type {string | null} The last computed beta header string (for latching). */
310
- lastHeader: null,
311
- };
312
-
313
306
  // F4: Session-level latch for rejected custom betas.
314
307
  // When a custom beta triggers a 400/anthropic-beta or 413-with-signal rejection,
315
308
  // its canonical name is stored here so subsequent requests within
@@ -1240,8 +1233,6 @@ export async function AnthropicAuthPlugin({ client }) {
1240
1233
  te.token_efficient_tools = enabled;
1241
1234
  saveConfig({ token_economy: te });
1242
1235
  config.token_economy = te;
1243
- // Invalidate latched betas so the change takes effect next request
1244
- betaLatchState.dirty = true;
1245
1236
  },
1246
1237
  "redact-thinking": () => {
1247
1238
  const enabled = value === "on" || value === "true" || value === "1";
@@ -1251,7 +1242,6 @@ export async function AnthropicAuthPlugin({ client }) {
1251
1242
  te.redact_thinking = enabled;
1252
1243
  saveConfig({ token_economy: te });
1253
1244
  config.token_economy = te;
1254
- betaLatchState.dirty = true;
1255
1245
  },
1256
1246
  "tool-deferral": () => {
1257
1247
  const enabled = value === "on" || value === "true" || value === "1";
@@ -1439,7 +1429,7 @@ export async function AnthropicAuthPlugin({ client }) {
1439
1429
  */
1440
1430
  async function getFilesAuth(acct) {
1441
1431
  let tok = acct.access;
1442
- if (!tok || !acct.expires || acct.expires < Date.now()) {
1432
+ if (!tok || isTokenExpired(acct.expires)) {
1443
1433
  tok = await refreshAccountTokenSingleFlight(acct);
1444
1434
  }
1445
1435
  return {
@@ -2215,7 +2205,9 @@ export async function AnthropicAuthPlugin({ client }) {
2215
2205
  return config.account_selection_strategy;
2216
2206
  }
2217
2207
 
2218
- let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
2208
+ // Baseline CLI version, read from the wire package's profile through the
2209
+ // adapter seam. `fetchLatestClaudeCodeVersion` may raise it at runtime.
2210
+ let claudeCliVersion = WIRE_PROFILE.cliVersion;
2219
2211
  const signatureSessionId = randomUUID();
2220
2212
  const signatureUserId = getOrCreateDeviceId();
2221
2213
  if (shouldFetchClaudeCodeVersion) {
@@ -2297,7 +2289,7 @@ export async function AnthropicAuthPlugin({ client }) {
2297
2289
  // Ignore idle failure here; foreground path handles refresh decisions.
2298
2290
  }
2299
2291
 
2300
- if (account.access && account.expires && account.expires > Date.now()) {
2292
+ if (account.access && isTokenUsable(account.expires)) {
2301
2293
  return account.access;
2302
2294
  }
2303
2295
  } else {
@@ -2410,7 +2402,7 @@ export async function AnthropicAuthPlugin({ client }) {
2410
2402
  const excluded = new Set([activeAccount.index]);
2411
2403
  const candidates = accountManager
2412
2404
  .getEnabledAccounts(excluded)
2413
- .filter((acc) => !acc.expires || acc.expires <= now + getIdleRefreshWindowMs())
2405
+ .filter((acc) => isTokenExpired(acc.expires, getIdleRefreshWindowMs(), now))
2414
2406
  .filter((acc) => {
2415
2407
  const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
2416
2408
  return now - last >= getIdleRefreshMinIntervalMs();
@@ -2577,7 +2569,26 @@ export async function AnthropicAuthPlugin({ client }) {
2577
2569
 
2578
2570
  // Transform URL once (shared across retries)
2579
2571
  const requestInit = init ?? {};
2580
- const { requestInput, requestUrl } = transformRequestUrl(input);
2572
+ // The emulation flag decides whether the URL may be reshaped at
2573
+ // all (`?beta=true`, `/messages` -> `/v1/messages`). Read here,
2574
+ // live, because `/anthropic set` can flip it between requests.
2575
+ const { requestInput, requestUrl } = transformRequestUrl(input, getSignatureEmulationEnabled());
2576
+
2577
+ // A host may hand us a `Request` carrying the body, with the init
2578
+ // empty — `fetchFn(new Request(url, {body}), {})`. Every body-aware
2579
+ // stage downstream (`_parsedBodyOnce`, `transformRequestBody`, the
2580
+ // adapter, the retry-loop rewrites) reads `requestInit.body` and
2581
+ // would silently see nothing, so the body is lifted onto the init
2582
+ // once, here. `clone()` keeps the original Request readable, which
2583
+ // matters because `requestInput` is still what gets sent when no
2584
+ // adapter URL is adopted.
2585
+ if (requestInit.body == null && input instanceof Request && input.body != null) {
2586
+ try {
2587
+ requestInit.body = await input.clone().text();
2588
+ } catch (error) {
2589
+ debugLog(`could not read the body off the host Request: ${error.message}`);
2590
+ }
2591
+ }
2581
2592
  const requestMethod = String(
2582
2593
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
2583
2594
  ).toUpperCase();
@@ -2732,7 +2743,7 @@ export async function AnthropicAuthPlugin({ client }) {
2732
2743
  let accessToken;
2733
2744
  // Per-account token refresh
2734
2745
  // Refresh 5 minutes before expiry to avoid mid-request token expiration (RE doc §1.10)
2735
- if (!account.access || !account.expires || account.expires < Date.now() + 300_000) {
2746
+ if (!account.access || isTokenExpired(account.expires, FOREGROUND_REFRESH_EXPIRY_BUFFER_MS)) {
2736
2747
  const attemptedRefreshToken = account.refreshToken;
2737
2748
  try {
2738
2749
  accessToken = await refreshAccountTokenSingleFlight(account);
@@ -2892,14 +2903,12 @@ export async function AnthropicAuthPlugin({ client }) {
2892
2903
  const _baseTE = config.token_economy || {};
2893
2904
  const _tokenEconomy = { ..._baseTE, __requestRole: _requestRole };
2894
2905
 
2895
- // Microcompact: inject clear betas at high context utilization
2896
- let _microcompactBetas = null;
2906
+ // Microcompact: track high context utilization (state + toast only)
2897
2907
  if (requestInit.body) {
2898
2908
  const estimatedTokens = _parsedBodyOnce
2899
2909
  ? estimatePromptTokensFromParsed(_parsedBodyOnce)
2900
2910
  : estimatePromptTokens(requestInit.body);
2901
2911
  if (shouldMicrocompact(estimatedTokens, config)) {
2902
- _microcompactBetas = buildMicrocompactBetas();
2903
2912
  if (!microcompactState.active) {
2904
2913
  microcompactState.active = true;
2905
2914
  microcompactState.lastActivatedTurn = sessionMetrics.turns;
@@ -2928,68 +2937,53 @@ export async function AnthropicAuthPlugin({ client }) {
2928
2937
  }
2929
2938
  return false;
2930
2939
  });
2931
- let computedBetaHeader = buildAnthropicBetaHeader(
2932
- "",
2933
- getSignatureEmulationEnabled(),
2934
- _reqModel,
2935
- _sessionFilteredCustomBetas,
2936
- getEffectiveStrategy(),
2937
- requestUrl?.pathname,
2938
- _reqHasFileRefs,
2939
- _adaptiveOverride,
2940
- _tokenEconomy,
2941
- _microcompactBetas, // NEW
2942
- );
2943
-
2944
- // Beta header latching: once a beta has been sent in this session,
2945
- // keep sending it to avoid server-side cache key churn (~50-70K tokens
2946
- // per flip). The latch is sticky-on: new betas can be added but never
2947
- // removed mid-session unless explicitly invalidated by config change.
2948
- {
2949
- const currentBetas = computedBetaHeader
2950
- .split(",")
2951
- .map((b) => b.trim())
2952
- .filter(Boolean);
2953
- // Add all current betas to the latch set
2954
- for (const b of currentBetas) betaLatchState.sent.add(b);
2955
- // If a config change dirtied the latch, rebuild from current (allow removal)
2956
- if (betaLatchState.dirty) {
2957
- betaLatchState.dirty = false;
2958
- betaLatchState.sent = new Set(currentBetas);
2959
- }
2960
- // Merge latched betas that aren't in the current set
2961
- const merged = new Set(currentBetas);
2962
- for (const b of betaLatchState.sent) merged.add(b);
2963
- // Custom-beta strip kill switch: when the server rejected our custom
2964
- // betas (customBetasStripped latch fired), evict them from the latch so
2965
- // they do not re-appear on the retry attempt.
2966
- // F1: delete both the raw alias AND the canonical form so that aliases
2967
- // like "cache-diag" (cache-diagnosis-2026-04-07) are fully evicted.
2968
- if (customBetasStripped && config.custom_betas?.length) {
2969
- for (const b of config.custom_betas) {
2970
- const canonical = resolveBetaShortcut(b);
2971
- merged.delete(b);
2972
- merged.delete(canonical);
2973
- betaLatchState.sent.delete(b);
2974
- betaLatchState.sent.delete(canonical);
2975
- }
2976
- }
2977
- // F4: Evict session-rejected betas from the latch on every request so
2978
- // they cannot re-enter the sent set from a prior latched header.
2979
- if (sessionRejectedBetas.size > 0) {
2980
- const _now = Date.now();
2981
- for (const [_canonical, _rejectedAt] of sessionRejectedBetas) {
2982
- if (_now - _rejectedAt <= SESSION_REJECTED_BETA_TTL_MS) {
2983
- merged.delete(_canonical);
2984
- betaLatchState.sent.delete(_canonical);
2985
- } else {
2986
- sessionRejectedBetas.delete(_canonical);
2987
- }
2988
- }
2989
- }
2990
- computedBetaHeader = [...merged].join(",");
2991
- betaLatchState.lastHeader = computedBetaHeader;
2992
- }
2940
+ // PHASE 2.2.3 — THE BETA LATCH IS GONE. It used to merge every
2941
+ // beta ever sent this session back into this value, to avoid
2942
+ // server-side cache-key churn from a beta flipping mid-session.
2943
+ // It was INERT on both live paths: `buildRequestHeaders`
2944
+ // recomputes its own `mergedBetas` from the incoming header, and
2945
+ // the adapter has the package compose the list from
2946
+ // `customBetas`. Neither ever read the latched value, so the only
2947
+ // thing it reached was the `task-budgets-2026-03-13` check in
2948
+ // `transformRequestBody` below.
2949
+ //
2950
+ // The two evictions the latch carried are not lost: both act on
2951
+ // `_sessionFilteredCustomBetas` above, BEFORE the header is
2952
+ // composed — `customBetasStripped` empties the custom set, and
2953
+ // `sessionRejectedBetas` filters the individual betas the API
2954
+ // rejected. That is the mechanism that actually reaches the wire.
2955
+ //
2956
+ // PHASE 4.1 — THE LEGACY BETA FORGE IS GONE FROM THIS PATH. The
2957
+ // whole ~12-param `buildAnthropicBetaHeader` call used to live
2958
+ // here for ONE consumer: `transformRequestBody` tested the
2959
+ // resulting header string for `task-budgets-2026-03-13` to decide
2960
+ // whether to preserve or inject `output_config`
2961
+ // (lib/mimicry/request-body.mjs). Nothing else read it — the
2962
+ // outgoing `anthropic-beta` header is composed by the shared
2963
+ // package on every adapter turn and recomputed from scratch by
2964
+ // `buildRequestHeaders` on every legacy one.
2965
+ //
2966
+ // It is now a BOOLEAN, derived here from the same gates the
2967
+ // adapter path applies to a custom beta. This MIRRORS
2968
+ // `buildAdditionalBetas` in lib/mimicry/adapter-input.mjs — keep
2969
+ // the two in step:
2970
+ // (a) signature emulation off ⇒ the plugin forges no Claude Code
2971
+ // fingerprint at all, so no custom beta reaches the wire;
2972
+ // (b) `_sessionFilteredCustomBetas` is the same list the adapter
2973
+ // is handed as `customBetas` (already `customBetasStripped`-
2974
+ // emptied and `sessionRejectedBetas`-filtered above), and the
2975
+ // adapter shortcut-expands each entry through
2976
+ // `BETA_SHORTCUTS` — `resolveBetaShortcut` is that expansion;
2977
+ // (c) `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` drops every member
2978
+ // of `EXPERIMENTAL_BETA_FLAGS` from the composed list
2979
+ // (adapter-input.mjs:490), and `task-budgets-2026-03-13` is a
2980
+ // member (lib/betas.mjs) — so the flag suppresses it.
2981
+ // There is no pathname gate to mirror: `transformRequestBody` only
2982
+ // runs on the adapter path, i.e. a messages endpoint.
2983
+ const _taskBudgetsActive =
2984
+ getSignatureEmulationEnabled() &&
2985
+ !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) &&
2986
+ _sessionFilteredCustomBetas.some((b) => resolveBetaShortcut(b) === "task-budgets-2026-03-13");
2993
2987
 
2994
2988
  // Cache TTL session latching: latch the cache policy at session start
2995
2989
  // so mid-session toggles don't bust the server-side prompt cache.
@@ -3003,81 +2997,98 @@ export async function AnthropicAuthPlugin({ client }) {
3003
2997
  config.cache_policy || { ttl: "1h", ttl_supported: true };
3004
2998
 
3005
2999
  // Single decision point for the whole request: does this turn go
3006
- // through the shared wire package, or through the legacy
3007
- // buildRequestHeaders path?
3000
+ // through the shared wire package?
3008
3001
  //
3009
- // Two conditions remain now that multi-provider support is gone:
3010
- // 1. signature emulation on — with it off the plugin emits only
3011
- // 3 headers and a minimal beta set, while the package always
3012
- // emits the full Claude Code set;
3013
- // 2. a messages endpoint the package has a surface for. Both
3014
- // qualify now: `buildClaudeCodeRequest` for /v1/messages and
3002
+ // PHASE 2.2 — THE ADAPTER IS UNCONDITIONAL. Two conditions, and
3003
+ // neither of them is about the body any more:
3004
+ // 1. signature emulation on — with it off the plugin does not
3005
+ // forge a Claude Code fingerprint at all, so there is
3006
+ // nothing for the package to build;
3007
+ // 2. a messages endpoint the package has a surface for:
3008
+ // `buildClaudeCodeRequest` for /v1/messages and
3015
3009
  // `buildClaudeCodeCountTokensRequest` for
3016
3010
  // /v1/messages/count_tokens, each pinning its OWN endpoint.
3017
- // The endpoint pin is inert either way — `built.url` is never
3018
- // adopted (see the consumption comment below), so the URL on
3019
- // the wire stays the one transformRequestUrl produced and a
3020
- // custom ANTHROPIC_BASE_URL survives.
3021
3011
  //
3022
3012
  // The former first-party check is now structural: the plugin only
3023
3013
  // speaks to first-party Anthropic, so it is always satisfied.
3024
- // 3. a JSON body is actually present. The main-turn surface
3025
- // requires `model` and `max_tokens`; the count surface
3026
- // requires `model` and `messages` and accepts no
3027
- // `max_tokens` at all. Either way a bodiless request must
3028
- // keep using the legacy path rather than throwing
3029
- // INVALID_INPUT.
3014
+ //
3015
+ // THE BODY IS NO LONGER A ROUTING INPUT. It used to be: a
3016
+ // bodiless or unparsable body silently fell back to the legacy
3017
+ // forge, which put a REQUEST WITH A DIFFERENT FINGERPRINT on the
3018
+ // wire mid-session. That dual path is what this phase removes.
3019
+ // A body the package cannot consume is now a hard error
3020
+ // (`assertAdapterBodyUsable`), never a quiet downgrade.
3021
+ // Endpoints outside the set above still pass through untouched.
3030
3022
  const _adapterPathname = requestUrl?.pathname;
3031
- const _isCountTokens =
3032
- _adapterPathname === "/v1/messages/count_tokens" || _adapterPathname === "/messages/count_tokens";
3023
+ const _isCountTokens = ADAPTER_COUNT_TOKENS_PATHNAMES.has(_adapterPathname);
3024
+ // Read ONCE per attempt. The config is runtime-mutable
3025
+ // (`/anthropic set`), and a flip between the routing decision,
3026
+ // the body transform and the header construction would emit a
3027
+ // half-emulated request.
3028
+ const _emulationEnabled = getSignatureEmulationEnabled();
3033
3029
  const _useAdapter =
3034
- getSignatureEmulationEnabled() &&
3035
- (_adapterPathname === "/v1/messages" || _adapterPathname === "/messages" || _isCountTokens) &&
3036
- typeof requestInit.body === "string" &&
3037
- requestInit.body.length > 0;
3030
+ _emulationEnabled && (ADAPTER_MESSAGES_PATHNAMES.has(_adapterPathname) || _isCountTokens);
3031
+ if (_useAdapter) {
3032
+ // Validated against the INCOMING body, before transformRequestBody
3033
+ // gets a chance to normalize it: the diagnostic has to name what
3034
+ // the host actually sent. `_parsedBodyOnce` is not usable as the
3035
+ // signal — the retry paths below null it out after rewriting a
3036
+ // perfectly valid body.
3037
+ assertAdapterBodyUsable(requestInit.body, _adapterPathname);
3038
+ }
3038
3039
 
3039
- const body = transformRequestBody(
3040
- requestInit.body,
3041
- {
3042
- enabled: getSignatureEmulationEnabled(),
3043
- claudeCliVersion,
3044
- promptCompactionMode: getPromptCompactionMode(),
3045
- useAdapter: _useAdapter,
3046
- cachePolicy: effectiveCachePolicy,
3047
- fastMode: config.fast_mode || false,
3048
- // When the account's fast pool is cooling down, suppress
3049
- // speed:"fast" so this turn runs at standard speed on the same
3050
- // account instead of re-hitting the exhausted fast pool.
3051
- fastRateLimited: accountManager.isFastRateLimited(account),
3052
- strategy: getEffectiveStrategy(),
3053
- toolDeferral: config.token_economy_strategies?.tool_deferral,
3054
- toolDescriptionCompaction: config.token_economy_strategies?.tool_description_compaction,
3055
- adaptiveToolSet: config.token_economy_strategies?.adaptive_tool_set,
3056
- systemPromptTailing: config.token_economy_strategies?.system_prompt_tailing,
3057
- systemPromptTailTurns: config.token_economy_strategies?.system_prompt_tail_turns,
3058
- systemPromptTailMaxChars: config.token_economy_strategies?.system_prompt_tail_max_chars,
3059
- },
3060
- {
3061
- persistentUserId: signatureUserId,
3062
- sessionId: signatureSessionId,
3063
- accountId: getAccountIdentifier(account),
3064
- turns: sessionMetrics.turns,
3065
- usedTools: sessionMetrics.usedTools,
3066
- tokenEconomySession,
3067
- requestRole: _requestRole,
3068
- isSubagent: _isSubagent,
3069
- // Adaptive cache-breakpoint placement: pass the per-boundary
3070
- // stability snapshot so transformRequestBody can anchor the
3071
- // cache_control marker on the most-stable boundary instead of
3072
- // the (possibly thrashing) last tool. Only populated once the
3073
- // detector is enabled and has a baseline.
3074
- cacheBoundaryStability: config.cache_break_detection?.adaptive_breakpoint
3075
- ? cacheBreakState.boundaryStability
3076
- : null,
3077
- },
3078
- computedBetaHeader,
3079
- config,
3080
- );
3040
+ // PHASE 2.2 — EMULATION OFF DOES NOT TRANSFORM THE BODY AT ALL.
3041
+ // `transformRequestBody` used to run on every request and apply
3042
+ // its non-gated structural normalizations (output cap, thinking,
3043
+ // effort -> output_config, system sanitize/compact) even with
3044
+ // emulation off. That is policy the host never asked for. The
3045
+ // only exceptions are the fields the first-party API rejects
3046
+ // outright (`betas`, the stainless-helper markers) — see
3047
+ // stripNonApiBodyFields.
3048
+ const body = !_emulationEnabled
3049
+ ? stripNonApiBodyFields(requestInit.body)
3050
+ : transformRequestBody(
3051
+ requestInit.body,
3052
+ {
3053
+ enabled: _emulationEnabled,
3054
+ claudeCliVersion,
3055
+ promptCompactionMode: getPromptCompactionMode(),
3056
+ useAdapter: _useAdapter,
3057
+ cachePolicy: effectiveCachePolicy,
3058
+ fastMode: config.fast_mode || false,
3059
+ // When the account's fast pool is cooling down, suppress
3060
+ // speed:"fast" so this turn runs at standard speed on the same
3061
+ // account instead of re-hitting the exhausted fast pool.
3062
+ fastRateLimited: accountManager.isFastRateLimited(account),
3063
+ strategy: getEffectiveStrategy(),
3064
+ toolDeferral: config.token_economy_strategies?.tool_deferral,
3065
+ toolDescriptionCompaction: config.token_economy_strategies?.tool_description_compaction,
3066
+ adaptiveToolSet: config.token_economy_strategies?.adaptive_tool_set,
3067
+ systemPromptTailing: config.token_economy_strategies?.system_prompt_tailing,
3068
+ systemPromptTailTurns: config.token_economy_strategies?.system_prompt_tail_turns,
3069
+ systemPromptTailMaxChars: config.token_economy_strategies?.system_prompt_tail_max_chars,
3070
+ },
3071
+ {
3072
+ persistentUserId: signatureUserId,
3073
+ sessionId: signatureSessionId,
3074
+ accountId: getAccountIdentifier(account),
3075
+ turns: sessionMetrics.turns,
3076
+ usedTools: sessionMetrics.usedTools,
3077
+ tokenEconomySession,
3078
+ requestRole: _requestRole,
3079
+ isSubagent: _isSubagent,
3080
+ // Adaptive cache-breakpoint placement: pass the per-boundary
3081
+ // stability snapshot so transformRequestBody can anchor the
3082
+ // cache_control marker on the most-stable boundary instead of
3083
+ // the (possibly thrashing) last tool. Only populated once the
3084
+ // detector is enabled and has a baseline.
3085
+ cacheBoundaryStability: config.cache_break_detection?.adaptive_breakpoint
3086
+ ? cacheBreakState.boundaryStability
3087
+ : null,
3088
+ },
3089
+ _taskBudgetsActive,
3090
+ config,
3091
+ );
3081
3092
  logTransformedSystemPrompt(body);
3082
3093
 
3083
3094
  // Toast on first fast-mode application in session (reset on toggle)
@@ -3119,13 +3130,27 @@ export async function AnthropicAuthPlugin({ client }) {
3119
3130
  // body that deliberately carries NO system, metadata or
3120
3131
  // max_tokens. Legacy path: unchanged, byte for byte.
3121
3132
  //
3122
- // The URL deliberately stays whatever transformRequestUrl already
3123
- // produced. `built.url` is the package's pinned endpoint
3133
+ // The URL is assembled from BOTH sources: origin from the host,
3134
+ // path and query from the package. `built.url` is the pinned
3135
+ // endpoint the headers and body were composed for
3124
3136
  // (`https://api.anthropic.com/v1/messages?beta=true`, or
3125
3137
  // `.../v1/messages/count_tokens?beta=true` on the count surface),
3126
- // which would override a custom ANTHROPIC_BASE_URL or proxy
3127
- // endpoint. transformRequestUrl already appends `?beta=true` to
3128
- // both endpoints itself, so discarding `built.url` costs nothing.
3138
+ // so the package stays the owner of the envelope — canonical path
3139
+ // plus `?beta=true` — and cannot drift from the body it built.
3140
+ // The ORIGIN, though, belongs to whoever the host addressed: a
3141
+ // custom provider baseURL (gateway, LiteLLM, corporate proxy)
3142
+ // arrives on `requestUrl`, and so does OPENCODE_MITM_BASE_URL,
3143
+ // which transformRequestUrl already applied. Taking the package's
3144
+ // origin as well would silently redirect those deployments to
3145
+ // api.anthropic.com.
3146
+ //
3147
+ // No gateway path PREFIX can be lost this way: the `_useAdapter`
3148
+ // gate only admits pathnames in {/v1/messages, /messages,
3149
+ // /v1/messages/count_tokens, /messages/count_tokens}, so a
3150
+ // prefixed endpoint never reaches the adapter in the first place.
3151
+ //
3152
+ // transformRequestUrl still owns the URL outright on the legacy /
3153
+ // emulation-off path.
3129
3154
  const _adapterSignature = {
3130
3155
  enabled: getSignatureEmulationEnabled(),
3131
3156
  claudeCliVersion,
@@ -3136,6 +3161,7 @@ export async function AnthropicAuthPlugin({ client }) {
3136
3161
 
3137
3162
  let requestHeaders;
3138
3163
  let adapterBody;
3164
+ let adapterRequestInput;
3139
3165
  let legacyStrippedBody;
3140
3166
  if (_useAdapter) {
3141
3167
  const _adapterResult = buildAdapterTransport({
@@ -3181,14 +3207,61 @@ export async function AnthropicAuthPlugin({ client }) {
3181
3207
  : await buildWireCompatibleRequest(body, _adapterResult.transport);
3182
3208
  requestHeaders = built.headers;
3183
3209
  adapterBody = built.body;
3210
+ try {
3211
+ // Origin from the HOST, path+query from the PACKAGE.
3212
+ // `requestUrl` already carries the provider's configured
3213
+ // baseURL and already had OPENCODE_MITM_BASE_URL applied by
3214
+ // transformRequestUrl, so copying its origin is what keeps a
3215
+ // gateway / LiteLLM / proxy deployment reachable. With no
3216
+ // usable `requestUrl` the package's own origin stands.
3217
+ const _adapterUrl = new URL(built.url);
3218
+ if (requestUrl) {
3219
+ _adapterUrl.protocol = requestUrl.protocol;
3220
+ _adapterUrl.hostname = requestUrl.hostname;
3221
+ _adapterUrl.port = requestUrl.port;
3222
+ }
3223
+ adapterRequestInput =
3224
+ input instanceof Request ? new Request(_adapterUrl.toString(), input) : _adapterUrl;
3225
+ } catch (error) {
3226
+ // Unparsable package URL — fall back to the transformed URL.
3227
+ debugLog(`adapter url adoption skipped (${error.message}); keeping the transformed url`);
3228
+ }
3184
3229
  } else {
3185
- debugLog(`adapter not applicable (${_adapterResult.reason}); using the legacy request path`);
3230
+ // Unreachable by construction, and it must STAY unreachable:
3231
+ // `buildAdapterTransport` declines on exactly two conditions,
3232
+ // a non-anthropic provider and signature emulation off, and
3233
+ // the call site above pins `provider: "anthropic"` while
3234
+ // `_useAdapter` already required emulation on. Falling back to
3235
+ // the legacy forge here would silently restore the dual path
3236
+ // Phase 2.2 removed, so a decline is an error instead.
3237
+ throw new Error(
3238
+ `opencode-anthropic-fix: the Claude Code wire adapter declined a request it must handle ` +
3239
+ `(${_adapterResult.reason}) on ${_adapterPathname}. This is a plugin bug — ` +
3240
+ `the legacy request path is no longer reachable with signature emulation on.`,
3241
+ );
3186
3242
  }
3187
3243
  }
3188
3244
 
3245
+ if (!requestHeaders && !_emulationEnabled) {
3246
+ // PHASE 2.2 — EMULATION OFF IS PURE PASSTHROUGH PLUS THE AUTH
3247
+ // ENVELOPE. No mimicry function composes these headers: the
3248
+ // host's set goes out as it arrived, minus the two credentials
3249
+ // that must not travel with our bearer, plus `authorization`
3250
+ // and an ADDITIVE `oauth-2025-04-20`. The forged claude-cli
3251
+ // user-agent and the substituted beta list — the two mimicry
3252
+ // vectors that used to survive with emulation off — are gone.
3253
+ // See lib/passthrough-headers.mjs for why the envelope is not
3254
+ // mimicry.
3255
+ requestHeaders = buildPassthroughHeaders(input, requestInit, accessToken);
3256
+ }
3257
+
3189
3258
  if (!requestHeaders) {
3190
- // Legacy path, and the fallback whenever the adapter declined:
3191
- // a bodiless request, or a transport the translation refused.
3259
+ // Emulation ON, on an endpoint the package has no surface for
3260
+ // (files, models, a gateway-prefixed route). Unchanged, and the
3261
+ // only caller of the legacy forge left: on a messages or
3262
+ // count_tokens turn the adapter above always produced headers,
3263
+ // which `test/conformance/adapter-unconditional.test.mjs`
3264
+ // observes directly.
3192
3265
  requestHeaders = buildRequestHeaders(
3193
3266
  input,
3194
3267
  requestInit,
@@ -3273,7 +3346,7 @@ export async function AnthropicAuthPlugin({ client }) {
3273
3346
  debugLog("failed to write outgoing request headers to debug-headers.log", e);
3274
3347
  }
3275
3348
  }
3276
- response = await fetch(requestInput, {
3349
+ response = await fetch(adapterRequestInput ?? requestInput, {
3277
3350
  ...requestInit,
3278
3351
  body: finalBody,
3279
3352
  headers: requestHeaders,
@@ -4457,7 +4530,7 @@ export async function AnthropicAuthPlugin({ client }) {
4457
4530
 
4458
4531
  const getAccessToken = async () => {
4459
4532
  let tok = account.access;
4460
- if (!tok || !account.expires || account.expires < Date.now()) {
4533
+ if (!tok || isTokenExpired(account.expires)) {
4461
4534
  tok = await refreshAccountTokenSingleFlight(account);
4462
4535
  }
4463
4536
  if (!tok) throw new Error("no access token available for Haiku call");
@@ -5172,7 +5245,7 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig, parsedBody) {
5172
5245
  }
5173
5246
 
5174
5247
  // Model must be eligible for 1M context at all (includes Opus 4.6)
5175
- if (!isEligibleFor1MContext(model)) {
5248
+ if (!isEligibleFor1MContextWire(model)) {
5176
5249
  return false;
5177
5250
  }
5178
5251
 
@@ -5423,6 +5496,12 @@ function writeCacheStatsFile(usage, model, hitRate) {
5423
5496
 
5424
5497
  // --- Phase 5: Minimal telemetry emulation ("Silent Observer") ---
5425
5498
 
5499
+ // Host telemetry payload only (ClaudeCodeInternalEvent env block) — never sent
5500
+ // on /v1/messages, so it is not part of the wire-compat surface.
5501
+ // Real build markers extracted from the 2.1.195 native binary (Bun-embedded JS):
5502
+ // `BUILD_TIME:"2026-06-26T01:00:56Z"`.
5503
+ const CLAUDE_CODE_BUILD_TIME = "2026-06-26T01:00:56Z";
5504
+
5426
5505
  class TelemetryEmitter {
5427
5506
  #enabled = false;
5428
5507
  #sent = false;
@@ -5828,9 +5907,10 @@ function getAccountIdentifier(account) {
5828
5907
  * @param {{ use1MContext?: boolean }} [adaptiveOverride] - When set, overrides the static hasOneMillionContext() check.
5829
5908
  * @param {boolean} [fastModeActive] - When true, emits FAST_MODE_BETA_FLAG (fast-mode-2026-02-01).
5830
5909
  * Must be derived structurally from the already-transformed outgoing body (body.includes('"speed":"fast"')).
5831
- * Only passed from the buildRequestHeaders call site (after body transform). The pre-transform
5832
- * computedBetaHeader call site (index.mjs:2905) leaves this undefined so the latch never
5833
- * accumulates fast-mode independently of actual body content.
5910
+ * Only passed from the buildRequestHeaders call site (after body transform), which is now the
5911
+ * ONLY call site: the pre-transform `computedBetaHeader` caller was deleted in Phase 4.1. Its
5912
+ * sole consumer, the `task-budgets-2026-03-13` check in transformRequestBody, takes a derived
5913
+ * boolean instead (see `_taskBudgetsActive` in the interceptor).
5834
5914
  * @returns {string}
5835
5915
  */
5836
5916
  // Mirrors CC's Kw(model) effort eligibility: returns false for claude-3-* and the
@@ -5865,6 +5945,9 @@ function getAccountIdentifier(account) {
5865
5945
  * @returns {string}
5866
5946
  */
5867
5947
 
5948
+ // Host update polling only (npm registry lookup), not mimicry.
5949
+ const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
5950
+
5868
5951
  /**
5869
5952
  * Resolve latest claude-code package version from npm registry.
5870
5953
  * Returns null on timeout/network/parse failures.
@@ -5915,13 +5998,20 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
5915
5998
  */
5916
5999
 
5917
6000
  /**
5918
- * Transform the request URL: add ?beta=true to /v1/messages and /v1/messages/count_tokens.
5919
- * Preserves behaviors F1-F3.
6001
+ * Transform the request URL: add ?beta=true to /v1/messages and
6002
+ * /v1/messages/count_tokens, normalizing a `/messages` path to `/v1/messages`
6003
+ * on the way. Preserves behaviors F1-F3.
6004
+ *
6005
+ * Both of those are CLAUDE CODE SHAPE, so both are gated on `emulateSignature`.
6006
+ * The `OPENCODE_MITM_BASE_URL` rewrite is not, and applies either way.
5920
6007
  *
5921
6008
  * @param {any} input
6009
+ * @param {boolean} [emulateSignature] Whether signature emulation is on for this
6010
+ * request. Read at the call site rather than here: the config is
6011
+ * runtime-mutable and this function lives outside the plugin closure.
5922
6012
  * @returns {{requestInput: any, requestUrl: URL | null}}
5923
6013
  */
5924
- function transformRequestUrl(input) {
6014
+ function transformRequestUrl(input, emulateSignature = true) {
5925
6015
  let requestInput = input;
5926
6016
  let requestUrl = null;
5927
6017
  try {
@@ -5934,7 +6024,14 @@ function transformRequestUrl(input) {
5934
6024
  requestUrl = null;
5935
6025
  }
5936
6026
 
5937
- if (requestUrl && !requestUrl.searchParams.has("beta")) {
6027
+ // PHASE 2.2 (QA finding 1) — THE URL REWRITE IS MIMICRY, SO IT IS GATED.
6028
+ // `?beta=true` is the endpoint the genuine Claude Code client pins, and
6029
+ // normalizing `/messages` to `/v1/messages` is the same kind of client-shape
6030
+ // assumption. With signature emulation off the plugin forges nothing, and
6031
+ // that has to include the URL: the host's URL goes out exactly as the host
6032
+ // wrote it. The MITM rewrite below stays unconditional — it is a debug and
6033
+ // conformance knob the operator asked for, not a disguise.
6034
+ if (emulateSignature && requestUrl && !requestUrl.searchParams.has("beta")) {
5938
6035
  const p = requestUrl.pathname;
5939
6036
  // SDK may send to /messages (base URL includes /v1) or /v1/messages (base URL is root)
5940
6037
  const isMessages =
@@ -6076,7 +6173,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6076
6173
  const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
6077
6174
  const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
6078
6175
  const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
6079
- const memAuthExpired = !account.expires || account.expires <= Date.now();
6176
+ const memAuthExpired = isTokenExpired(account.expires);
6080
6177
  const allowExpiredFallback = options.allowExpiredFallback === true;
6081
6178
  if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
6082
6179
  return false;
@@ -6112,7 +6209,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6112
6209
  // Accept CC credential if:
6113
6210
  // - expiresAt is in the future (normal case), OR
6114
6211
  // - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
6115
- if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
6212
+ if (match && (match.expiresAt === 0 || isTokenUsable(match.expiresAt))) {
6116
6213
  account.access = match.accessToken;
6117
6214
  account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
6118
6215
  markTokenStateUpdated(account);
@@ -6148,7 +6245,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6148
6245
  if (!lock.acquired) {
6149
6246
  const diskAuth = await readDiskAccountAuth(account.id);
6150
6247
  const adopted = applyDiskAuthIfFresher(account, diskAuth, { allowExpiredFallback: true });
6151
- if (adopted && account.access && account.expires && account.expires > Date.now()) {
6248
+ if (adopted && account.access && isTokenUsable(account.expires)) {
6152
6249
  return account.access;
6153
6250
  }
6154
6251
  throw new Error("Refresh lock busy");
@@ -6159,14 +6256,21 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6159
6256
  const adopted = applyDiskAuthIfFresher(account, diskAuthBeforeRefresh);
6160
6257
  // Apply fresher disk tokens for both foreground and idle paths — prevents an
6161
6258
  // unnecessary HTTP refresh when another process already rotated the token.
6162
- if (adopted && account.access && account.expires && account.expires > Date.now()) {
6259
+ if (adopted && account.access && isTokenUsable(account.expires)) {
6163
6260
  return account.access;
6164
6261
  }
6165
6262
 
6166
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(15_000) });
6263
+ // `clientId` is undefined for every normal account, which means "use the
6264
+ // default". Only a CLAUDE_CODE_OAUTH_CLIENT_ID-seeded env account carries one
6265
+ // (contract §5.2, divergence D10).
6266
+ const json = await refreshToken(account.refreshToken, {
6267
+ signal: AbortSignal.timeout(15_000),
6268
+ clientId: account.clientId,
6269
+ });
6167
6270
 
6168
6271
  account.access = json.access_token;
6169
6272
  account.expires = Date.now() + json.expires_in * 1000;
6273
+ warnOnEphemeralTokenRotation(account, json.refresh_token);
6170
6274
  if (json.refresh_token) {
6171
6275
  account.refreshToken = json.refresh_token;
6172
6276
  }
@@ -6294,13 +6398,6 @@ AnthropicAuthPlugin.__testing__ = {
6294
6398
  maybeApplySessionToolResultDedupe,
6295
6399
  // exposed for experimental.session.summarize integration tests
6296
6400
  runHaikuSessionSummarize,
6297
- get cachedCCPrompt() {
6298
- return getCachedCCPrompt();
6299
- },
6300
- resetCachedCCPrompt() {
6301
- resetCachedCCPrompt();
6302
- },
6303
- SUBAGENT_CC_ANCHOR,
6304
6401
  CLAUDE_CODE_IDENTITY_STRING,
6305
6402
  /** Test-only: drive the session turn counter so code paths gated on
6306
6403
  * `sessionMetrics.turns >= N` can be exercised without a real SSE stream. */