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/README.md +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
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
|
-
/**
|
|
442
|
-
* Code 2.1.
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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
|
-
/**
|
|
456
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
27
|
-
|
|
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
|
|
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.
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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 =
|
|
153
|
-
export const PROFILE_USER_AGENT =
|
|
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/
|
|
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(
|
|
389
|
-
if (!
|
|
390
|
-
if (isFilesEndpoint || hasFileReferences) betas.push(
|
|
391
|
-
if (te.extended_cache_ttl !== false && !isRoundRobin) betas.push(
|
|
392
|
-
if (te.structured_outputs && supportsStructuredOutputs(model)) betas.push(
|
|
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
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
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
|
|
651
|
-
//
|
|
652
|
-
//
|
|
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") &&
|
package/lib/mimicry/headers.mjs
CHANGED
|
@@ -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 {
|
|
3
|
-
import { EXPERIMENTAL_BETA_FLAGS, BETA_SHORTCUTS
|
|
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 (
|
|
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) && !
|
|
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 && !
|
|
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 (!
|
|
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 && !
|
|
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 && !
|
|
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
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
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 (
|
|
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
|
|