opencode-anthropic-fix 0.6.0 → 1.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/lib/betas.mjs ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Host beta policy.
3
+ *
4
+ * Three things live here, and none of them is wire construction:
5
+ * - `EXPERIMENTAL_BETA_FLAGS`: the betas the HOST treats as experimental, i.e.
6
+ * the set it filters out when `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` is on;
7
+ * - `BETA_SHORTCUTS`: CLI UX aliases for `/anthropic betas` and
8
+ * `config.custom_betas`;
9
+ * - `resolveBetaShortcut`: the resolver behind those aliases.
10
+ *
11
+ * The betas that actually go on the wire are the shared package's business
12
+ * (`lib/mimicry/wire-compat.mjs`); this module only decides what the plugin
13
+ * ADDS or SUPPRESSES on top of them.
14
+ *
15
+ * RECONCILIATION IS ENFORCED BY TEST, not by convention. Every literal header
16
+ * below is checked against the package's `BETA_REGISTRY_2_1_233` — the genuine
17
+ * client's own registry — by `lib/betas.test.mjs`, which fails on a stale date
18
+ * stamp and requires the host-only entries (the ones upstream has no registry
19
+ * slot for) to be enumerated and justified rather than merely accumulated. Add
20
+ * a beta here that upstream does not ship and the test will tell you to say why.
21
+ */
22
+
23
+ /**
24
+ * Set of all known experimental/optional beta flags.
25
+ * Used to filter betas when CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1.
26
+ * WARNING: this set intentionally overlaps with always-on betas - only use
27
+ * it with the disable-experimental guard, never unconditionally.
28
+ */
29
+ export const EXPERIMENTAL_BETA_FLAGS = new Set([
30
+ "adaptive-thinking-2026-01-28",
31
+ "advanced-tool-use-2025-11-20",
32
+ "advisor-tool-2026-03-01",
33
+ "afk-mode-2026-01-31",
34
+ "cache-diagnosis-2026-04-07",
35
+ // CCR bring-your-own-cloud beta. Registered in the 2.1.154 binary's _W() table
36
+ // but not emitted on /v1/messages; kept here for the disable-experimental guard.
37
+ "ccr-byoc-2025-07-29",
38
+ "code-execution-2025-08-25",
39
+ "compact-2026-01-12",
40
+ "context-1m-2025-08-07",
41
+ "context-hint-2026-04-09",
42
+ "context-management-2025-06-27",
43
+ "environments-2025-11-01",
44
+ "extended-cache-ttl-2025-04-11",
45
+ // Refusal-fallback credit beta from the upstream Udd registry (CC 2.1.195).
46
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
47
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
48
+ "fallback-credit-2026-06-01",
49
+ "fast-mode-2026-02-01",
50
+ "files-api-2025-04-14",
51
+ "interleaved-thinking-2025-05-14",
52
+ // Registered in the 2.1.154 binary's _W() table (mcp_servers label). Plugin
53
+ // proxies MCP tool calls inline and does not emit this on chat completions.
54
+ "mcp-servers-2025-12-04",
55
+ // Registered in CC v2.1.143 but NOT in real CC's always-on emission set
56
+ // (master registry only, not in the always-on subset). Likely GrowthBook-gated
57
+ // pending server rollout. Paired telemetry: tengu_mid_conv_system_fallback_retry.
58
+ "mid-conversation-system-2026-04-07",
59
+ "prompt-caching-scope-2026-01-05",
60
+ "redact-thinking-2026-02-12",
61
+ // Server-side refusal-fallback beta from the upstream Udd registry (CC 2.1.195).
62
+ // Opt-in/GrowthBook-gated; never auto-emitted on a default /v1/messages turn.
63
+ // Listed here for the disable-experimental guard + manual opt-in ONLY.
64
+ "server-side-fallback-2026-06-01",
65
+ "structured-outputs-2025-12-15",
66
+ // Revived in CC 2.1.159 as registry label `narration_summaries` (it was a dead
67
+ // slot from v2.1.90-2.1.154). Real CC emits it only when GrowthBook flag
68
+ // `pewter_owl_header` is on (default-off), first-party, and NOT in fast-mode.
69
+ // Listed here for the disable-guard + manual opt-in ONLY; never emitted always-on.
70
+ "summarize-connector-text-2026-03-13",
71
+ "task-budgets-2026-03-13",
72
+ "tool-search-tool-2025-10-19",
73
+ // SDK admin route beta (used for /v1/user_profiles* endpoints). Registered for
74
+ // forward-compat; the plugin proxies /v1/messages only and does NOT emit this
75
+ // on chat completions.
76
+ "user-profiles-2026-03-24",
77
+ "web-search-2025-03-05",
78
+ ]);
79
+
80
+ /** Friendly shortcut aliases for config.custom_betas values. */
81
+ export const BETA_SHORTCUTS = new Map([
82
+ ["1m", "context-1m-2025-08-07"],
83
+ ["1m-context", "context-1m-2025-08-07"],
84
+ ["context-1m", "context-1m-2025-08-07"],
85
+ ["cache-diagnosis", "cache-diagnosis-2026-04-07"],
86
+ ["cache-diag", "cache-diagnosis-2026-04-07"],
87
+ ["cache-ttl", "extended-cache-ttl-2025-04-11"],
88
+ ["context-hint", "context-hint-2026-04-09"],
89
+ ["environments", "environments-2025-11-01"],
90
+ ["extended-cache-ttl", "extended-cache-ttl-2025-04-11"],
91
+ ["hint", "context-hint-2026-04-09"],
92
+ ["fast", "fast-mode-2026-02-01"],
93
+ ["fast-mode", "fast-mode-2026-02-01"],
94
+ ["opus-fast", "fast-mode-2026-02-01"],
95
+ ["task-budgets", "task-budgets-2026-03-13"],
96
+ ["budgets", "task-budgets-2026-03-13"],
97
+ ["redact-thinking", "redact-thinking-2026-02-12"],
98
+ ["mid-conv-system", "mid-conversation-system-2026-04-07"],
99
+ ["mid-system", "mid-conversation-system-2026-04-07"],
100
+ ["context-management", "context-management-2025-06-27"],
101
+ ["structured-outputs", "structured-outputs-2025-12-15"],
102
+ ["web-search", "web-search-2025-03-05"],
103
+ ["advanced-tool-use", "advanced-tool-use-2025-11-20"],
104
+ ["tool-search-tool", "tool-search-tool-2025-10-19"],
105
+ ["effort", "effort-2025-11-24"],
106
+ ["prompt-caching-scope", "prompt-caching-scope-2026-01-05"],
107
+ ["thinking-token-count", "thinking-token-count-2026-05-13"],
108
+ ["afk-mode", "afk-mode-2026-01-31"],
109
+ ["advisor-tool", "advisor-tool-2026-03-01"],
110
+ ["mcp-servers", "mcp-servers-2025-12-04"],
111
+ ["ccr-byoc", "ccr-byoc-2025-07-29"],
112
+ ["connector-text", "summarize-connector-text-2026-03-13"],
113
+ ["narration-summaries", "summarize-connector-text-2026-03-13"],
114
+ ["summarize-connector-text", "summarize-connector-text-2026-03-13"],
115
+ ["server-side-fallback", "server-side-fallback-2026-06-01"],
116
+ ["fallback", "server-side-fallback-2026-06-01"],
117
+ ["fallback-credit", "fallback-credit-2026-06-01"],
118
+ ]);
119
+
120
+ /**
121
+ * Resolve a beta shortcut alias to its canonical flag name.
122
+ * Returns the value unchanged if no alias matches.
123
+ * @param {string | null | undefined} value
124
+ * @returns {string}
125
+ */
126
+ export function resolveBetaShortcut(value) {
127
+ if (!value) return "";
128
+ const trimmed = value.trim();
129
+ const mapped = BETA_SHORTCUTS.get(trimmed.toLowerCase());
130
+ return mapped || trimmed;
131
+ }
package/lib/diagnose.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { getConfigDir, loadConfig } from "./config.mjs";
7
7
  import { redactSecrets } from "./redact.mjs";
8
- import { FALLBACK_CLAUDE_CLI_VERSION } from "./request-headers.mjs";
8
+ import { WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
9
9
  import { loadAccounts } from "./storage.mjs";
10
10
 
11
11
  const ENV_KEYS = [
@@ -172,7 +172,9 @@ export async function buildDiagnosticBundle() {
172
172
  pluginVersion: getPluginVersion(),
173
173
  nodeVersion: process.version,
174
174
  platform: process.platform,
175
- mimicryBaseline: FALLBACK_CLAUDE_CLI_VERSION,
175
+ // Static baseline on purpose: the CLI version the wire package's profile
176
+ // declares, NOT the (possibly npm-updated) version of the running session.
177
+ mimicryBaseline: WIRE_PROFILE.cliVersion,
176
178
  },
177
179
  config: redactSecrets(loadConfig()),
178
180
  env: getRuntimeEnv(),
@@ -23,19 +23,30 @@
23
23
  */
24
24
 
25
25
  import { isTruthyEnv, isFalsyEnv } from "../env.mjs";
26
- import { CLAUDE_3_MODEL_RE, hasOneMillionContext } from "./models.mjs";
27
- import { BETA_SHORTCUTS, EXPERIMENTAL_BETA_FLAGS } from "../request-headers.mjs";
26
+ import {
27
+ hasOneMillionContext,
28
+ isClaude3Model,
29
+ isHaikuModel,
30
+ supportsStructuredOutputs,
31
+ supportsWebSearch,
32
+ WIRE_PROFILE,
33
+ BETA_REGISTRY_2_1_233,
34
+ } from "./wire-compat.mjs";
35
+ import { BETA_SHORTCUTS, EXPERIMENTAL_BETA_FLAGS } from "../betas.mjs";
28
36
  import { resolveCacheTtl } from "./cache.mjs";
37
+ // CLAUDE_CODE_BETA_FLAG and HOST_SDK_BETAS_BLOCKLIST are HOST POLICY, not wire
38
+ // capability: the first is the sentinel this module tests for when deciding
39
+ // whether a rescued host beta is already package-composed, the second is the
40
+ // plugin's own list of betas the host SDK injects that the genuine client never
41
+ // sends. Both stay plugin-side by design; only the model CAPABILITY predicates
42
+ // moved to the package seam above.
29
43
  import {
30
44
  CLAUDE_CODE_BETA_FLAG,
31
45
  HOST_SDK_BETAS_BLOCKLIST,
32
46
  buildStainlessHelperHeader,
33
47
  getStainlessArch,
34
48
  getStainlessOs,
35
- isHaikuModel,
36
49
  parseRequestBodyMetadata,
37
- supportsStructuredOutputs,
38
- supportsWebSearch,
39
50
  } from "./headers.mjs";
40
51
 
41
52
  // ---------------------------------------------------------------------------
@@ -48,6 +59,69 @@ export const ADAPTER_SKIP_SIGNATURE_DISABLED = "signature-disabled";
48
59
  /** The shared package pins the Anthropic first-party URL and rejects any other. */
49
60
  export const ADAPTER_SKIP_NON_ANTHROPIC_PROVIDER = "non-anthropic-provider";
50
61
 
62
+ /**
63
+ * The pathnames the shared package has a surface for. `/v1/messages` and
64
+ * `/messages` go to `buildClaudeCodeRequest`; the count pair goes to
65
+ * `buildClaudeCodeCountTokensRequest`. Every other pathname (files, models,
66
+ * a gateway-prefixed route) is passed through untouched by the interceptor.
67
+ */
68
+ export const ADAPTER_MESSAGES_PATHNAMES = new Set(["/v1/messages", "/messages"]);
69
+
70
+ /** @see ADAPTER_MESSAGES_PATHNAMES */
71
+ export const ADAPTER_COUNT_TOKENS_PATHNAMES = new Set(["/v1/messages/count_tokens", "/messages/count_tokens"]);
72
+
73
+ /**
74
+ * Reject a request the adapter is REQUIRED to handle but cannot.
75
+ *
76
+ * WHY THIS THROWS INSTEAD OF DEGRADING. Since Phase 2.2 the adapter is
77
+ * unconditional: with signature emulation on, a messages/count_tokens turn has
78
+ * exactly one construction path. A body the package cannot consume used to fall
79
+ * back to the legacy forge, which meant a malformed or bodiless request quietly
80
+ * went on the wire with a different fingerprint than every other turn in the
81
+ * session — the failure mode the migration exists to remove. Failing loudly
82
+ * here keeps the path single, and the message names the endpoint and the exact
83
+ * defect so the caller can fix its request.
84
+ *
85
+ * The check is deliberately structural (a JSON object), not semantic: `model`,
86
+ * `max_tokens` and `messages` validation belongs to the package, which already
87
+ * reports it as `INVALID_INPUT` with `safeDetails`.
88
+ *
89
+ * @param {unknown} body The body the host handed the interceptor.
90
+ * @param {string | undefined} pathname The outgoing pathname, for the message.
91
+ * @returns {void}
92
+ * @throws {Error} When the body is absent, unparsable, or not a JSON object.
93
+ */
94
+ export function assertAdapterBodyUsable(body, pathname) {
95
+ const where = `${pathname ?? "the messages endpoint"} with signature emulation on`;
96
+ if (typeof body !== "string" || body.length === 0) {
97
+ throw new Error(
98
+ `opencode-anthropic-fix: ${where} requires a JSON request body, but the request carried ` +
99
+ `${typeof body === "string" ? "an empty string" : `no usable body (${typeof body})`}. ` +
100
+ `The Claude Code wire adapter cannot construct this request.`,
101
+ );
102
+ }
103
+
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(body);
107
+ } catch (error) {
108
+ throw new Error(
109
+ `opencode-anthropic-fix: ${where} requires a JSON request body, but the body could not be ` +
110
+ `parsed as JSON (${error instanceof Error ? error.message : String(error)}). ` +
111
+ `The Claude Code wire adapter cannot construct this request.`,
112
+ { cause: error },
113
+ );
114
+ }
115
+
116
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
117
+ throw new Error(
118
+ `opencode-anthropic-fix: ${where} requires a JSON OBJECT request body, but the body parsed ` +
119
+ `as ${Array.isArray(parsed) ? "an array" : String(parsed === null ? "null" : typeof parsed)}. ` +
120
+ `The Claude Code wire adapter cannot construct this request.`,
121
+ );
122
+ }
123
+ }
124
+
51
125
  /**
52
126
  * `additionalBetas` accepts at most 32 entries; overflow is `INVALID_INPUT`,
53
127
  * a hard failure rather than a degradation. Exported so the test suite can
@@ -70,8 +144,28 @@ export const MAX_SUPPRESS_BETAS = 32;
70
144
  * the round-robin strategy: rotating accounts means the cache scope marker
71
145
  * points at a prompt cache the next account cannot read, so paying the beta's
72
146
  * bookkeeping buys nothing.
147
+ *
148
+ * The header string itself is WIRE state, so it is read from the package's beta
149
+ * registry through the seam rather than re-typed here: if the package ever
150
+ * re-dates the beta, the suppression keeps matching what the package composes.
73
151
  */
74
- export const PROMPT_CACHING_SCOPE_BETA = "prompt-caching-scope-2026-01-05";
152
+ export const PROMPT_CACHING_SCOPE_BETA = BETA_REGISTRY_2_1_233.PROMPT_CACHING_SCOPE.header;
153
+
154
+ /**
155
+ * Betas the plugin opts INTO on its own (see `buildAdditionalBetas`): whether
156
+ * each one is emitted is HOST POLICY — model capability, request shape, the
157
+ * round-robin rotation, `toolEnablement` — and that policy stays here.
158
+ *
159
+ * The CANONICAL NAMES are not host policy; they are wire state owned by the
160
+ * package. Reading them from the registry through the seam means an upstream
161
+ * rename or date bump surfaces as a changed emission instead of drifting
162
+ * silently against a literal re-typed in this file.
163
+ */
164
+ export const WEB_SEARCH_BETA = BETA_REGISTRY_2_1_233.WEB_SEARCH.header;
165
+ export const ADVISOR_TOOL_BETA = BETA_REGISTRY_2_1_233.ADVISOR_TOOL.header;
166
+ export const FILES_API_BETA = BETA_REGISTRY_2_1_233.FILES_API.header;
167
+ export const EXTENDED_CACHE_TTL_BETA = BETA_REGISTRY_2_1_233.EXTENDED_CACHE_TTL.header;
168
+ export const STRUCTURED_OUTPUTS_BETA = BETA_REGISTRY_2_1_233.STRUCTURED_OUTPUTS.header;
75
169
 
76
170
  /**
77
171
  * `runtime.sessionId` is REQUIRED by the package (`requiredString`), while the
@@ -82,6 +176,13 @@ export const PROMPT_CACHING_SCOPE_BETA = "prompt-caching-scope-2026-01-05";
82
176
  * package's own correlation checks; in practice `signatureSessionId` is always
83
177
  * populated when signature emulation is enabled, so this is a safety net rather
84
178
  * than a routine path.
179
+ *
180
+ * BOUNDARY: host policy, NOT wire state. This is a plugin invention — genuine
181
+ * Claude Code always has a session id, so it has no equivalent constant, and
182
+ * neither the package (no occurrence in `dist/`) nor the reverse-engineering
183
+ * notes carry this UUID. It therefore stays a local literal by design and is
184
+ * exempt from the version-literal governance guard
185
+ * (`test/conformance/version-literals-retired.test.mjs`).
85
186
  */
86
187
  export const SESSION_ID_FALLBACK = "00000000-0000-4000-8000-000000000000";
87
188
 
@@ -143,14 +244,15 @@ export const ADAPTER_ENV_KEYS = Object.freeze([
143
244
  * the `latest` dist-tag, so each package release can advance the default profile
144
245
  * and the plugin follows without a code change to the composition path.
145
246
  *
146
- * These two constants are the ONLY thing that has to move in lockstep: they
147
- * mirror the default profile's `cliVersion` / `userAgent` so that
148
- * `resolveProfileOverride` stays silent in the common case. Keep them in sync
149
- * with `FALLBACK_CLAUDE_CLI_VERSION` (lib/request-headers.mjs) on every package
150
- * bump that changes the default profile.
247
+ * These two constants are the baseline `resolveProfileOverride` compares
248
+ * against, and they are READ FROM the profile through the seam
249
+ * (`WIRE_PROFILE`, lib/mimicry/wire-compat.mjs) rather than re-typed. A package
250
+ * bump that advances the default profile therefore moves the plugin's baseline
251
+ * with it — no lockstep edit, and no way for the two to silently disagree and
252
+ * emit a spurious profile override on every request.
151
253
  */
152
- export const PROFILE_CLI_VERSION = "2.1.233";
153
- export const PROFILE_USER_AGENT = "claude-cli/2.1.233 (external, cli)";
254
+ export const PROFILE_CLI_VERSION = WIRE_PROFILE.cliVersion;
255
+ export const PROFILE_USER_AGENT = WIRE_PROFILE.userAgent;
154
256
 
155
257
  // ---------------------------------------------------------------------------
156
258
  // Environment snapshot
@@ -177,7 +279,7 @@ export function resolveAdapterEnv(source) {
177
279
  // ---------------------------------------------------------------------------
178
280
 
179
281
  /**
180
- * Pure mirror of `buildExtendedUserAgent` (lib/request-headers.mjs) with the
282
+ * Pure mirror of `buildExtendedUserAgent` (lib/mimicry/headers.mjs) with the
181
283
  * environment injected instead of read. The two are pinned to agree by
182
284
  * `adapter-input.test.mjs`.
183
285
  *
@@ -385,11 +487,11 @@ function buildAdditionalBetas(args) {
385
487
  const isRoundRobin = strategy === "round-robin";
386
488
  const te = tokenEconomy || {};
387
489
 
388
- if (supportsWebSearch(model)) betas.push("web-search-2025-03-05");
389
- if (!CLAUDE_3_MODEL_RE.test(model)) betas.push("advisor-tool-2026-03-01");
390
- if (isFilesEndpoint || hasFileReferences) betas.push("files-api-2025-04-14");
391
- if (te.extended_cache_ttl !== false && !isRoundRobin) betas.push("extended-cache-ttl-2025-04-11");
392
- if (te.structured_outputs && supportsStructuredOutputs(model)) betas.push("structured-outputs-2025-12-15");
490
+ if (supportsWebSearch(model)) betas.push(WEB_SEARCH_BETA);
491
+ if (!isClaude3Model(model)) betas.push(ADVISOR_TOOL_BETA);
492
+ if (isFilesEndpoint || hasFileReferences) betas.push(FILES_API_BETA);
493
+ if (te.extended_cache_ttl !== false && !isRoundRobin) betas.push(EXTENDED_CACHE_TTL_BETA);
494
+ if (te.structured_outputs && supportsStructuredOutputs(model)) betas.push(STRUCTURED_OUTPUTS_BETA);
393
495
  // The package deliberately excludes Haiku from `claude-code-20250219`; the
394
496
  // plugin deliberately includes it so Haiku subagents reached through
395
497
  // model-router delegation still get full mimic behaviour.
@@ -639,17 +741,17 @@ export function buildAdapterTransport(state) {
639
741
  }
640
742
  // Lean system prompt for non-main requests (`token_economy.lean_system_non_main`).
641
743
  //
642
- // On the LEGACY path this decision lives in `buildSystemPromptBlocks`
643
- // (`leanNonMain` in lib/mimicry/system-prompt.mjs), which returns the
644
- // sanitized blocks before the billing header and the identity prefix are
645
- // prepended. On the ADAPTER path those two blocks are no longer the plugin's
646
- // to withhold — `suppressCanonicalBlocks` hands their composition to the
647
- // package — so the same decision has to be re-expressed as the package's two
648
- // root seams, or the opt-in silently degrades to a no-op.
744
+ // This is the ONLY place the lean decision is made. `suppressCanonicalBlocks`
745
+ // hands the composition of the billing header and the identity prefix to the
746
+ // package, so the plugin can no longer withhold either block by simply not
747
+ // emitting it; the intent has to be expressed as the package's two root
748
+ // seams (`suppressBillingBlock` / `suppressIdentityBlock`), which the package
749
+ // honours when it assembles the canonical blocks. Without them the opt-in
750
+ // silently degrades to a no-op.
649
751
  //
650
- // The gate is the SAME conjunction as the legacy one: opt-in flag, a "title"
651
- // or "small" role, and not a title-generator turn (that shape gets its own
652
- // canonical system prompt swap and keeps the attribution).
752
+ // The gate: opt-in flag, a "title" or "small" role, and not a title-generator
753
+ // turn (that shape gets its own canonical system prompt swap and keeps the
754
+ // attribution).
653
755
  if (
654
756
  tokenEconomy.lean_system_non_main === true &&
655
757
  (state.requestRole === "title" || state.requestRole === "small") &&
@@ -1,12 +1,94 @@
1
+ /**
2
+ * ===========================================================================
3
+ * BOUNDARY: THE LEGACY HEADER FORGE IS A FROZEN COMPATIBILITY SHIM.
4
+ * ===========================================================================
5
+ *
6
+ * WHAT IS FROZEN. The beta/header COMPOSITION in this module —
7
+ * `buildAnthropicBetaHeader`, `buildRequestHeaders`, and the Claude Code beta
8
+ * constants and model gates they consume (`CLAUDE_CODE_BETA_FLAG`,
9
+ * `_EFFORT_BETA_FLAG`, `FAST_MODE_BETA_FLAG`, `TOKEN_COUNTING_BETA_FLAG`,
10
+ * `_EFFORT_EXCLUDED_MODELS`, `isEffortCapableModel`, and the local
11
+ * `isHaikuModel` / `supportsStructuredOutputs` / `supportsWebSearch` copies).
12
+ * It is a hand-written reimplementation of what
13
+ * `@tormentalabs/claude-code-wire-compat` now does from the genuine client's
14
+ * own tables.
15
+ *
16
+ * WHY IT IS STILL HERE. It is reachable ONLY for requests the shared package
17
+ * has no surface for: the files and models endpoints, a gateway-prefixed route,
18
+ * or any request made with signature emulation switched off. On `/v1/messages`
19
+ * and `/v1/messages/count_tokens` with emulation on — the path that carries the
20
+ * fingerprint claim — the adapter never enters it. It is a fallback, not a
21
+ * second implementation competing for the same traffic.
22
+ *
23
+ * THE RULE. Do not extend it, do not add a beta to it, do not "improve" its
24
+ * gating, and do not let a new caller in. Fixes to wire behaviour belong in the
25
+ * package. When the package grows a files/models/gateway surface, DELETE this
26
+ * forge and the constants above rather than porting them forward.
27
+ *
28
+ * GOVERNANCE. The Claude Code beta constants here would otherwise trip the
29
+ * Phase 4.1 guard against locally-declared wire constants; they are allowlisted
30
+ * as an explicitly-commented compat exception, and this banner is the comment
31
+ * the allowlist points at. Removing it should fail that guard, by design.
32
+ *
33
+ * NOT FROZEN, and NOT part of the exception: `HOST_SDK_BETAS_BLOCKLIST` (host
34
+ * policy — see its own comment), `parseRequestBodyMetadata`,
35
+ * `stripStainlessHelperMarkers`, `getStainlessOs`/`getStainlessArch` and
36
+ * `buildStainlessHelperHeader`, all of which the adapter path imports and uses.
37
+ */
38
+
1
39
  import { isTruthyEnv, isFalsyEnv } from "../env.mjs";
2
- import { CLAUDE_3_MODEL_RE, hasOneMillionContext } from "./models.mjs";
3
- import { EXPERIMENTAL_BETA_FLAGS, BETA_SHORTCUTS, buildExtendedUserAgent } from "../request-headers.mjs";
40
+ import { hasOneMillionContext, isClaude3Model } from "./wire-compat.mjs";
41
+ import { EXPERIMENTAL_BETA_FLAGS, BETA_SHORTCUTS } from "../betas.mjs";
4
42
  import { randomUUID } from "node:crypto";
5
43
 
44
+ /**
45
+ * Build the extended User-Agent for API calls.
46
+ * Real CC v96 sends "claude-cli/{version} (external, {entrypoint})" - confirmed via
47
+ * proxy capture of real CC on Windows/Node.js.
48
+ *
49
+ * SCOPE: the legacy forge below is its only production caller, i.e. the
50
+ * endpoints the shared wire package has no surface for. `buildAdapterUserAgent`
51
+ * (lib/mimicry/adapter-input.mjs) is the pure mirror the adapter path uses, and
52
+ * `adapter-input.test.mjs` pins the two to agree. Phase 3.3 of the wire-compat
53
+ * migration may replace this with the package's own user-agent template.
54
+ *
55
+ * @param {string} version
56
+ * @returns {string}
57
+ */
58
+ export function buildExtendedUserAgent(version) {
59
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
60
+ const sdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` : "";
61
+ const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
62
+ ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`
63
+ : "";
64
+ return `claude-cli/${version} (external, ${entrypoint}${sdkVersion}${clientApp})`;
65
+ }
66
+
6
67
  export const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
7
68
  export const _EFFORT_BETA_FLAG = "effort-2025-11-24";
8
69
  export const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
9
70
  export const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
71
+ /**
72
+ * BOUNDARY: host policy, not wire construction. Deliberately plugin-side.
73
+ *
74
+ * These are betas the HOST SDK injects into its outgoing request that the
75
+ * genuine Claude Code client NEVER sends. They are not stale registry entries
76
+ * and they are not the package's business — `BETA_REGISTRY_2_1_233` has no slot
77
+ * for either, because upstream has no reason to model a header it does not
78
+ * emit. The plugin drops them so a host's own SDK cannot leak a foreign
79
+ * fingerprint into a request that is otherwise byte-identical to the real
80
+ * client's.
81
+ *
82
+ * `structured-outputs-2025-11-13` is the sharp one: it is an OLDER date stamp of
83
+ * a beta upstream does ship (`structured-outputs-2025-12-15`). Blocking it is
84
+ * what keeps a host SDK from pinning the plugin to a superseded revision of a
85
+ * live beta. `lib/betas.test.mjs` reconciles the host tables against the
86
+ * registry; this set is intentionally exempt from that reconciliation, because
87
+ * its whole purpose is to name headers the registry does not contain.
88
+ *
89
+ * Consumed by BOTH paths — `lib/mimicry/adapter-input.mjs` imports it — so
90
+ * unlike the beta constants above it does NOT die with the legacy forge.
91
+ */
10
92
  export const HOST_SDK_BETAS_BLOCKLIST = new Set([
11
93
  "fine-grained-tool-streaming-2025-05-14",
12
94
  "structured-outputs-2025-11-13",
@@ -160,10 +242,35 @@ export function stripStainlessHelperMarkers(tools, messages) {
160
242
 
161
243
  export function isEffortCapableModel(model) {
162
244
  if (!model) return false;
163
- if (CLAUDE_3_MODEL_RE.test(model)) return false;
245
+ if (isClaude3Model(model)) return false;
164
246
  return !_EFFORT_EXCLUDED_MODELS.some((re) => re.test(model));
165
247
  }
166
248
 
249
+ /**
250
+ * ===========================================================================
251
+ * FROZEN COMPATIBILITY SHIM — DO NOT EXTEND. See the banner at the top of this
252
+ * file for the full boundary contract.
253
+ * ===========================================================================
254
+ *
255
+ * The legacy beta composer. On a `/v1/messages` or `/v1/messages/count_tokens`
256
+ * turn with signature emulation on — i.e. the overwhelming majority of traffic
257
+ * — the adapter path NEVER enters this function: the shared package composes the
258
+ * beta list and `lib/mimicry/adapter-input.mjs` only supplies `additionalBetas`
259
+ * and `suppressBetas` on top of it.
260
+ *
261
+ * It survives for exactly ONE caller and no more: `buildRequestHeaders` below,
262
+ * i.e. the endpoints the package has no surface for (files, models, a
263
+ * gateway-prefixed route).
264
+ *
265
+ * PHASE 4.1 — RESOLVED. The second caller, the `computedBetaHeader` call in
266
+ * `index.mjs`, is gone. It never reached the wire; it existed only so
267
+ * `transformRequestBody` could substring-test for `task-budgets-2026-03-13`,
268
+ * and that consumer now takes a boolean derived at the call site from the same
269
+ * gates `buildAdditionalBetas` applies. Do not add a caller back.
270
+ *
271
+ * @param {string} incomingBeta
272
+ * @param {boolean} signatureEnabled
273
+ */
167
274
  export function buildAnthropicBetaHeader(
168
275
  incomingBeta,
169
276
  signatureEnabled,
@@ -222,7 +329,7 @@ export function buildAnthropicBetaHeader(
222
329
  // (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
223
330
  // thinking and real CC never sends this flag for them, so emitting it
224
331
  // diverges the fingerprint for legacy Haiku/Sonnet 3.x requests.
225
- if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !CLAUDE_3_MODEL_RE.test(model)) {
332
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !isClaude3Model(model)) {
226
333
  betas.push("interleaved-thinking-2025-05-14");
227
334
  }
228
335
 
@@ -257,7 +364,7 @@ export function buildAnthropicBetaHeader(
257
364
  // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
258
365
  // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
259
366
  // first-party models. Opt out via token_economy.context_management = false.
260
- if (te.context_management !== false && !CLAUDE_3_MODEL_RE.test(model)) {
367
+ if (te.context_management !== false && !isClaude3Model(model)) {
261
368
  betas.push("context-management-2025-06-27");
262
369
  }
263
370
 
@@ -284,7 +391,7 @@ export function buildAnthropicBetaHeader(
284
391
  // (tengu_sage_compass2) and firstParty+isLoggedIn. Since we can't check
285
392
  // CC's feature flags, include it unconditionally for Claude 4+ models.
286
393
  // CC v108 sends it in MITM captures for Max/Pro users.
287
- if (!CLAUDE_3_MODEL_RE.test(model)) {
394
+ if (!isClaude3Model(model)) {
288
395
  betas.push("advisor-tool-2026-03-01");
289
396
  }
290
397
 
@@ -310,13 +417,13 @@ export function buildAnthropicBetaHeader(
310
417
 
311
418
  // v2.1.150: redact-thinking is default-ON in CC (first-party, non-SDK, thinking models).
312
419
  // Opt out via `/anthropic set redact-thinking off` to see thinking content.
313
- if (te.redact_thinking !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
420
+ if (te.redact_thinking !== false && !disableExperimentalBetas && !isClaude3Model(model)) {
314
421
  betas.push("redact-thinking-2026-02-12");
315
422
  }
316
423
 
317
424
  // v2.1.150: thinking-token-count for token budget tracking (behind tengu_chert_bezel in CC).
318
425
  // Default ON for token economy visibility. Opt out via token_economy.thinking_token_count = false.
319
- if (te.thinking_token_count !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
426
+ if (te.thinking_token_count !== false && !disableExperimentalBetas && !isClaude3Model(model)) {
320
427
  betas.push("thinking-token-count-2026-05-13");
321
428
  }
322
429
 
@@ -383,6 +490,27 @@ export function getStainlessArch(value) {
383
490
  return value;
384
491
  }
385
492
 
493
+ /**
494
+ * ===========================================================================
495
+ * FROZEN COMPATIBILITY SHIM — DO NOT EXTEND. See the banner at the top of this
496
+ * file for the full boundary contract.
497
+ * ===========================================================================
498
+ *
499
+ * The legacy header forge. ONE production caller (`index.mjs`), reached only
500
+ * when the wire adapter declines the request — signature emulation off, or a
501
+ * pathname the shared package has no surface for. Every `/v1/messages` and
502
+ * `/v1/messages/count_tokens` turn with emulation on is built by the package
503
+ * instead, so this function's output is not what the plugin's fingerprint claim
504
+ * rests on.
505
+ *
506
+ * Deleting it is a package-capability question, not a plugin one: when
507
+ * `@tormentalabs/claude-code-wire-compat` grows a files / models / gateway
508
+ * surface, this forge and the CC beta constants it consumes go with it.
509
+ *
510
+ * @param {Record<string, unknown>} input
511
+ * @param {RequestInit | undefined} requestInit
512
+ * @param {string} accessToken
513
+ */
386
514
  export function buildRequestHeaders(
387
515
  input,
388
516
  requestInit,
@@ -423,12 +551,12 @@ export function buildRequestHeaders(
423
551
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
424
552
  const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
425
553
  // Detect fast mode structurally from the already-transformed body string.
426
- // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
427
- // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
428
- // toast check (~line 3018). This is the correct call site for fast-mode: the
429
- // computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
430
- // is always false/absent there, keeping the latch free of fast-mode. The
431
- // requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
554
+ // transformRequestBody runs before buildRequestHeaders in index.mjs, so speed:"fast" is
555
+ // already present when fast mode fired. This is now the ONLY call site of
556
+ // buildAnthropicBetaHeader (the pre-transform `computedBetaHeader` caller was deleted in
557
+ // Phase 4.1), and it is the correct one for fast-mode detection: it runs AFTER the body
558
+ // transform. The requestHeaders.set("anthropic-beta", mergedBetas) call below is what
559
+ // reaches the wire.
432
560
  const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
433
561
  const mergedBetas = buildAnthropicBetaHeader(
434
562
  incomingBeta,
@@ -440,7 +568,7 @@ export function buildRequestHeaders(
440
568
  hasFileReferences,
441
569
  adaptiveOverride,
442
570
  tokenEconomy,
443
- undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
571
+ undefined, // microcompactBetas: not available at this call site (the interceptor computes them)
444
572
  fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
445
573
  );
446
574