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/lib/config.mjs CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
2
2
  import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
+ import { OAUTH_CLIENT_ID } from "./oauth-constants.mjs";
6
+ import { PROJECT_SCOPES } from "./oauth-scopes.mjs";
5
7
 
6
8
  /**
7
9
  * @typedef {'sticky' | 'round-robin' | 'hybrid' | 'single'} AccountSelectionStrategy
@@ -104,7 +106,7 @@ import { randomBytes } from "node:crypto";
104
106
  * @property {{ enabled: boolean, default_cooldown_ms: number, poll_quota_on_overload: boolean }} overload_recovery
105
107
  * @property {{ proactive_disabled: boolean }} account_management
106
108
  * @property {{ enabled: boolean }} anti_verbosity
107
- * @property {{ sdk_token_useragent: boolean }} oauth
109
+ * @property {{ sdk_token_useragent: boolean, revoke_on_logout: boolean, plugins_scope: boolean, project_scopes: string[] }} oauth
108
110
  */
109
111
 
110
112
  /** @type {AnthropicAuthConfig} */
@@ -438,22 +440,51 @@ export const DEFAULT_CONFIG = {
438
440
  },
439
441
  /** OAuth token-endpoint request fingerprint. */
440
442
  oauth: {
441
- /** When true (DEFAULT), the OAuth token EXCHANGE/REFRESH POSTs use Claude
442
- * Code 2.1.195's SDK-native-fetch fingerprint (`User-Agent:
443
- * anthropic-sdk-typescript/0.94.0 userOAuthProvider`, `anthropic-beta:
444
- * oauth-2025-04-20`, no axios `Accept`). When false, the legacy axios
445
- * fingerprint (`User-Agent: axios/1.13.6`, axios `Accept` header) is sent
446
- * byte-for-byte as before.
447
- * Default ON to match upstream CC 2.1.195's userOAuthProvider. Set to
448
- * `false` to revert to the legacy axios/1.13.6 + `Accept` fingerprint. */
443
+ /** [DEPRECATED — has no effect] Formerly selected between the SDK
444
+ * fingerprint and a legacy `axios/1.13.6` one. Claude Code 2.1.280 has no
445
+ * axios OAuth path, so setting this to `false` did not make a user more
446
+ * anonymous — it made them the only client on the network sending that
447
+ * shape. The axios path is removed; every token request now carries
448
+ * `anthropic-sdk-typescript/0.112.1 userOAuthProvider`.
449
+ * The key stays recognised so existing config files keep loading, and an
450
+ * explicit value warns once per process.
451
+ * @see docs/oauth-2.1.280-contract.md §7 divergence D2 */
449
452
  sdk_token_useragent: true,
453
+ /** Attempt RFC 7009 server-side revocation on logout. Default OFF.
454
+ * `/v1/oauth/revoke` appears nowhere in the Claude Code 2.1.280 bundle, so
455
+ * every revocation is a request that identifies this client. It is kept as
456
+ * an opt-in rather than deleted, because users who want the affordance
457
+ * should be able to choose it knowingly.
458
+ * @see docs/oauth-2.1.280-contract.md §7 divergence D1 */
459
+ revoke_on_logout: false,
460
+ /** Request the `user:plugins` scope at authorize time. Default OFF.
461
+ * §13.8 records that the genuine client appends `user:plugins` when its
462
+ * `PLUGINS_SCOPE_REGISTERED` gate is on, but does not record the gate's
463
+ * shipped state. Defaulting to off preserves the previously-emitted
464
+ * five-scope string byte for byte; it is not a claim that the genuine
465
+ * default is off.
466
+ * @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9 */
467
+ plugins_scope: false,
468
+ /** Extra project scopes to request at authorize time. Default none.
469
+ * Only `user:projects:read` and `user:projects:write` are recognised;
470
+ * anything else is dropped by the scope composer rather than forwarded,
471
+ * because an unrecognised scope in the authorize URL is a fingerprint and
472
+ * is likely to be rejected by the server. (§13.8 does not record how the
473
+ * authorize endpoint treats an unknown scope, so "likely" is the strongest
474
+ * claim the evidence supports.) A dropped entry is warned about once at
475
+ * config-load time so the user is not left wondering.
476
+ * @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9 */
477
+ project_scopes: Object.freeze([]),
450
478
  },
451
479
  };
452
480
 
453
481
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid", "single"];
454
482
 
455
- /** OpenCode's OAuth client ID for Anthropic console auth flows. */
456
- export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
483
+ /** The OAuth client ID for Anthropic auth flows.
484
+ * Re-exported from ./oauth-constants.mjs so there is exactly one literal in
485
+ * the repository; this alias exists for the call sites that already import it
486
+ * from here. */
487
+ export const CLIENT_ID = OAUTH_CLIENT_ID;
457
488
 
458
489
  /**
459
490
  * Build a deep-cloned config object from defaults.
@@ -487,7 +518,7 @@ function createDefaultConfig() {
487
518
  microcompact: { ...DEFAULT_CONFIG.microcompact },
488
519
  overload_recovery: { ...DEFAULT_CONFIG.overload_recovery },
489
520
  account_management: { ...DEFAULT_CONFIG.account_management },
490
- oauth: { ...DEFAULT_CONFIG.oauth },
521
+ oauth: { ...DEFAULT_CONFIG.oauth, project_scopes: [...DEFAULT_CONFIG.oauth.project_scopes] },
491
522
  };
492
523
  }
493
524
 
@@ -537,6 +568,12 @@ function clampNumber(value, min, max, fallback) {
537
568
  */
538
569
  let contextHintDeprecationWarned = false;
539
570
 
571
+ /** One-time latch for the `oauth.sdk_token_useragent` deprecation warning. */
572
+ let sdkTokenUserAgentDeprecationWarned = false;
573
+
574
+ /** One-time latch for the `oauth.project_scopes` unrecognised-entry warning. */
575
+ let projectScopesDropWarned = false;
576
+
540
577
  function validateConfig(raw) {
541
578
  const config = createDefaultConfig();
542
579
 
@@ -1045,9 +1082,50 @@ function validateConfig(raw) {
1045
1082
  // OAuth token-endpoint fingerprint sub-config
1046
1083
  if (raw.oauth && typeof raw.oauth === "object") {
1047
1084
  const o = /** @type {Record<string, unknown>} */ (raw.oauth);
1085
+
1086
+ // Non-silent deprecation. Any explicit value warns, because both values now
1087
+ // produce the same request: a user who set `false` deliberately is relying
1088
+ // on behaviour that no longer exists and deserves to be told, and a user who
1089
+ // set `true` is carrying a setting that no longer means anything.
1090
+ if (typeof o.sdk_token_useragent === "boolean" && !sdkTokenUserAgentDeprecationWarned) {
1091
+ sdkTokenUserAgentDeprecationWarned = true;
1092
+
1093
+ // The message deliberately carries no `2.1.<NN>` literal. A version string
1094
+ // in production source is exactly what the version-literals conformance
1095
+ // guard exists to forbid, and weakening that guard for a log line would
1096
+ // trade a real invariant for a cosmetic one.
1097
+ console.warn(
1098
+ "[anthropic-auth] oauth.sdk_token_useragent is deprecated and has no effect: " +
1099
+ "no axios OAuth path exists in the genuine client, so token requests always send " +
1100
+ "the SDK user-agent. See the OAuth wire contract doc, divergence D2. " +
1101
+ "Remove the setting from your config to silence this warning.",
1102
+ );
1103
+ }
1104
+
1105
+ // Unrecognised project scopes are dropped here AND downstream by the scope
1106
+ // composer. Dropping silently in two places is how a user ends up staring
1107
+ // at a typo that produces no error and no scope, so say it once.
1108
+ let projectScopes = [...DEFAULT_CONFIG.oauth.project_scopes];
1109
+ if (Array.isArray(o.project_scopes)) {
1110
+ const requested = o.project_scopes.filter((s) => typeof s === "string");
1111
+ projectScopes = requested.filter((s) => PROJECT_SCOPES.includes(s));
1112
+ const dropped = requested.filter((s) => !PROJECT_SCOPES.includes(s));
1113
+ if (dropped.length > 0 && !projectScopesDropWarned) {
1114
+ projectScopesDropWarned = true;
1115
+ console.warn(
1116
+ `[anthropic-auth] oauth.project_scopes contains unrecognised entries that were dropped: ` +
1117
+ `${dropped.join(", ")}. Recognised values are: ${PROJECT_SCOPES.join(", ")}.`,
1118
+ );
1119
+ }
1120
+ }
1121
+
1048
1122
  config.oauth = {
1049
1123
  sdk_token_useragent:
1050
1124
  typeof o.sdk_token_useragent === "boolean" ? o.sdk_token_useragent : DEFAULT_CONFIG.oauth.sdk_token_useragent,
1125
+ revoke_on_logout:
1126
+ typeof o.revoke_on_logout === "boolean" ? o.revoke_on_logout : DEFAULT_CONFIG.oauth.revoke_on_logout,
1127
+ plugins_scope: typeof o.plugins_scope === "boolean" ? o.plugins_scope : DEFAULT_CONFIG.oauth.plugins_scope,
1128
+ project_scopes: projectScopes,
1051
1129
  };
1052
1130
  }
1053
1131
 
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.
151
+ */
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.
73
163
  */
74
- export const PROMPT_CACHING_SCOPE_BETA = "prompt-caching-scope-2026-01-05";
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
 
@@ -139,18 +240,21 @@ export const ADAPTER_ENV_KEYS = Object.freeze([
139
240
  * PROFILE INHERITANCE POLICY. The adapter never passes an explicit `profile`
140
241
  * argument to the package's entry points; omitting it IS the mechanism by which
141
242
  * the plugin inherits whatever `DEFAULT_PROFILE` the installed
142
- * `@tormentalabs/claude-code-wire-compat` declares. The dependency is tracked at
143
- * the `latest` dist-tag, so each package release can advance the default profile
144
- * and the plugin follows without a code change to the composition path.
145
- *
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.
243
+ * `@tormentalabs/claude-code-wire-compat` declares. Under the `latest` dist-tag
244
+ * each package release can advance the default profile and the plugin follows
245
+ * without a code change to the composition path. The dependency is currently
246
+ * pinned to an exact version instead, holding the default profile back until the
247
+ * plugin is ported to the next one (docs/shared-package-provenance.md).
248
+ *
249
+ * These two constants are the baseline `resolveProfileOverride` compares
250
+ * against, and they are READ FROM the profile through the seam
251
+ * (`WIRE_PROFILE`, lib/mimicry/wire-compat.mjs) rather than re-typed. A package
252
+ * bump that advances the default profile therefore moves the plugin's baseline
253
+ * with it — no lockstep edit, and no way for the two to silently disagree and
254
+ * emit a spurious profile override on every request.
151
255
  */
152
- export const PROFILE_CLI_VERSION = "2.1.233";
153
- export const PROFILE_USER_AGENT = "claude-cli/2.1.233 (external, cli)";
256
+ export const PROFILE_CLI_VERSION = WIRE_PROFILE.cliVersion;
257
+ export const PROFILE_USER_AGENT = WIRE_PROFILE.userAgent;
154
258
 
155
259
  // ---------------------------------------------------------------------------
156
260
  // Environment snapshot
@@ -177,7 +281,7 @@ export function resolveAdapterEnv(source) {
177
281
  // ---------------------------------------------------------------------------
178
282
 
179
283
  /**
180
- * Pure mirror of `buildExtendedUserAgent` (lib/request-headers.mjs) with the
284
+ * Pure mirror of `buildExtendedUserAgent` (lib/mimicry/headers.mjs) with the
181
285
  * environment injected instead of read. The two are pinned to agree by
182
286
  * `adapter-input.test.mjs`.
183
287
  *
@@ -385,11 +489,11 @@ function buildAdditionalBetas(args) {
385
489
  const isRoundRobin = strategy === "round-robin";
386
490
  const te = tokenEconomy || {};
387
491
 
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");
492
+ if (supportsWebSearch(model)) betas.push(WEB_SEARCH_BETA);
493
+ if (!isClaude3Model(model)) betas.push(ADVISOR_TOOL_BETA);
494
+ if (isFilesEndpoint || hasFileReferences) betas.push(FILES_API_BETA);
495
+ if (te.extended_cache_ttl !== false && !isRoundRobin) betas.push(EXTENDED_CACHE_TTL_BETA);
496
+ if (te.structured_outputs && supportsStructuredOutputs(model)) betas.push(STRUCTURED_OUTPUTS_BETA);
393
497
  // The package deliberately excludes Haiku from `claude-code-20250219`; the
394
498
  // plugin deliberately includes it so Haiku subagents reached through
395
499
  // model-router delegation still get full mimic behaviour.
@@ -639,17 +743,17 @@ export function buildAdapterTransport(state) {
639
743
  }
640
744
  // Lean system prompt for non-main requests (`token_economy.lean_system_non_main`).
641
745
  //
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.
746
+ // This is the ONLY place the lean decision is made. `suppressCanonicalBlocks`
747
+ // hands the composition of the billing header and the identity prefix to the
748
+ // package, so the plugin can no longer withhold either block by simply not
749
+ // emitting it; the intent has to be expressed as the package's two root
750
+ // seams (`suppressBillingBlock` / `suppressIdentityBlock`), which the package
751
+ // honours when it assembles the canonical blocks. Without them the opt-in
752
+ // silently degrades to a no-op.
649
753
  //
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).
754
+ // The gate: opt-in flag, a "title" or "small" role, and not a title-generator
755
+ // turn (that shape gets its own canonical system prompt swap and keeps the
756
+ // attribution).
653
757
  if (
654
758
  tokenEconomy.lean_system_non_main === true &&
655
759
  (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