opencode-anthropic-fix 0.2.1 → 0.3.0-beta.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 +24 -0
- package/index.mjs +155 -149
- package/lib/config.mjs +47 -25
- package/lib/mimicry/adapter-input.mjs +671 -0
- package/lib/mimicry/context-hint-threshold.mjs +127 -0
- package/lib/mimicry/headers.mjs +71 -64
- package/lib/mimicry/request-body.mjs +23 -22
- package/lib/mimicry/system-prompt.mjs +54 -23
- package/lib/mimicry/wire-compat.mjs +305 -0
- package/package.json +3 -3
- package/lib/account-state.test.mjs +0 -109
- package/lib/accounts.test.mjs +0 -912
- package/lib/adaptive-thinking-models.test.mjs +0 -30
- package/lib/backoff.test.mjs +0 -411
- package/lib/cc-credentials.test.mjs +0 -391
- package/lib/config.test.mjs +0 -764
- package/lib/context-hint-persist.mjs +0 -68
- package/lib/diagnose.test.mjs +0 -122
- package/lib/env.test.mjs +0 -25
- package/lib/haiku-call.test.mjs +0 -72
- package/lib/message-transform.test.mjs +0 -173
- package/lib/mimicry/cache.test.mjs +0 -112
- package/lib/mimicry/headers.test.mjs +0 -50
- package/lib/mimicry/models.test.mjs +0 -132
- package/lib/mimicry/request-body.test.mjs +0 -34
- package/lib/mimicry/request-helpers.test.mjs +0 -155
- package/lib/mimicry/response-stream.test.mjs +0 -127
- package/lib/mimicry/system-prompt.test.mjs +0 -73
- package/lib/oauth.test.mjs +0 -343
- package/lib/redact.test.mjs +0 -154
- package/lib/refresh-lock.test.mjs +0 -131
- package/lib/request-headers.test.mjs +0 -164
- package/lib/retry/overload-loop.test.mjs +0 -107
- package/lib/rotation.test.mjs +0 -392
- package/lib/session-metrics.test.mjs +0 -67
- package/lib/storage.test.mjs +0 -696
- package/lib/token-economy/microcompact.test.mjs +0 -28
- package/lib/token-economy/transforms.test.mjs +0 -192
- package/lib/tuning.test.mjs +0 -89
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation from the plugin's per-request state to the `transport`
|
|
3
|
+
* object `buildWireCompatibleRequest` (lib/mimicry/wire-compat.mjs) expects.
|
|
4
|
+
*
|
|
5
|
+
* PURITY CONTRACT
|
|
6
|
+
* ---------------
|
|
7
|
+
* Nothing in this module performs I/O, and nothing reads `process.env`,
|
|
8
|
+
* `process.arch`, `process.platform`, `process.version` or a random source.
|
|
9
|
+
* Every ambient value is resolved by the CALLER and handed in:
|
|
10
|
+
*
|
|
11
|
+
* - environment -> `state.env` (build it with `resolveAdapterEnv(process.env)`)
|
|
12
|
+
* - provider -> `state.provider` (from `detectProvider`, which reads env)
|
|
13
|
+
* - clientRequestId -> `state.clientRequestId` (the caller calls `randomUUID()`;
|
|
14
|
+
* the plugin owns generation now, the package only consumes the value)
|
|
15
|
+
* - platform/arch/node version -> `state.platform` / `state.arch` /
|
|
16
|
+
* `state.nodeVersion`
|
|
17
|
+
*
|
|
18
|
+
* That makes the whole mapping deterministic and directly testable.
|
|
19
|
+
*
|
|
20
|
+
* WIRED INTO PRODUCTION. `index.mjs` calls this and hands the result to
|
|
21
|
+
* `buildWireCompatibleRequest`, falling back to `buildRequestHeaders` only when
|
|
22
|
+
* the translation declines (`applicable: false`) or the request has no body.
|
|
23
|
+
*/
|
|
24
|
+
|
|
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";
|
|
28
|
+
import { resolveCacheTtl } from "./cache.mjs";
|
|
29
|
+
import {
|
|
30
|
+
CLAUDE_CODE_BETA_FLAG,
|
|
31
|
+
HOST_SDK_BETAS_BLOCKLIST,
|
|
32
|
+
buildStainlessHelperHeader,
|
|
33
|
+
getStainlessArch,
|
|
34
|
+
getStainlessOs,
|
|
35
|
+
isHaikuModel,
|
|
36
|
+
parseRequestBodyMetadata,
|
|
37
|
+
supportsStructuredOutputs,
|
|
38
|
+
supportsWebSearch,
|
|
39
|
+
} from "./headers.mjs";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** `signature.enabled === false` puts the plugin in minimal-header mode. */
|
|
46
|
+
export const ADAPTER_SKIP_SIGNATURE_DISABLED = "signature-disabled";
|
|
47
|
+
|
|
48
|
+
/** The shared package pins the Anthropic first-party URL and rejects any other. */
|
|
49
|
+
export const ADAPTER_SKIP_NON_ANTHROPIC_PROVIDER = "non-anthropic-provider";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `additionalBetas` accepts at most 32 entries; overflow is `INVALID_INPUT`,
|
|
53
|
+
* a hard failure rather than a degradation. Exported so the test suite can
|
|
54
|
+
* assert the realistic worst case stays under it. Deliberately NOT enforced
|
|
55
|
+
* here by truncation: silently dropping a user-configured beta would be a
|
|
56
|
+
* worse failure mode than a loud one.
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_ADDITIONAL_BETAS = 32;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `suppressBetas` has the same 32-entry ceiling as `additionalBetas`. The
|
|
62
|
+
* realistic worst case is `EXPERIMENTAL_BETA_FLAGS` (28) plus the round-robin
|
|
63
|
+
* prompt-caching-scope suppression (1) = 29, so the cap is never reached; the
|
|
64
|
+
* constant exists so `adapter-input.test.mjs` can pin that arithmetic.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_SUPPRESS_BETAS = 32;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Composed by the package unconditionally, but suppressed by the plugin under
|
|
70
|
+
* the round-robin strategy: rotating accounts means the cache scope marker
|
|
71
|
+
* points at a prompt cache the next account cannot read, so paying the beta's
|
|
72
|
+
* bookkeeping buys nothing.
|
|
73
|
+
*/
|
|
74
|
+
export const PROMPT_CACHING_SCOPE_BETA = "prompt-caching-scope-2026-01-05";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `runtime.sessionId` is REQUIRED by the package (`requiredString`), while the
|
|
78
|
+
* plugin historically OMITTED `X-Claude-Code-Session-Id` when it had no session
|
|
79
|
+
* identifier. Omission is not expressible through the seam, so a blank session
|
|
80
|
+
* id resolves to this fixed placeholder instead of raising `INVALID_INPUT` at
|
|
81
|
+
* request time. It is a syntactically valid v4 UUID so it survives the
|
|
82
|
+
* package's own correlation checks; in practice `signatureSessionId` is always
|
|
83
|
+
* populated when signature emulation is enabled, so this is a safety net rather
|
|
84
|
+
* than a routine path.
|
|
85
|
+
*/
|
|
86
|
+
export const SESSION_ID_FALLBACK = "00000000-0000-4000-8000-000000000000";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Host headers that must never be forwarded through `extraHeaders`.
|
|
90
|
+
*
|
|
91
|
+
* - `x-session-affinity` is set by the opencode SDK but never by real Claude
|
|
92
|
+
* Code. It is neither canonical nor denylisted in the package, so
|
|
93
|
+
* `dropConflicting` would happily pass it straight to the wire.
|
|
94
|
+
* - `x-api-key` is deleted by `buildRequestHeaders` today; forwarding it would
|
|
95
|
+
* leak a host API key alongside the OAuth bearer.
|
|
96
|
+
*/
|
|
97
|
+
export const ADAPTER_STRIPPED_HOST_HEADERS = new Set(["x-session-affinity", "x-api-key"]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Correlation keys the package REJECTS inside `metadataOverrides.userIdFields`
|
|
101
|
+
* (`INVALID_INPUT`). The plugin used to let them be overwritten silently by
|
|
102
|
+
* spread order, so they are filtered out here to preserve today's outcome —
|
|
103
|
+
* correlation always wins — without the hard failure.
|
|
104
|
+
*/
|
|
105
|
+
export const METADATA_CORRELATION_KEYS = new Set(["device_id", "account_uuid", "session_id"]);
|
|
106
|
+
|
|
107
|
+
/** Every environment variable this translation reads, resolved by the caller. */
|
|
108
|
+
export const ADAPTER_ENV_KEYS = Object.freeze([
|
|
109
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
110
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
111
|
+
"CLAUDE_AGENT_SDK_CLIENT_APP",
|
|
112
|
+
"CLAUDE_AGENT_SDK_VERSION",
|
|
113
|
+
"CLAUDE_CODE_ADDITIONAL_PROTECTION",
|
|
114
|
+
"CLAUDE_CODE_ATTRIBUTION_HEADER",
|
|
115
|
+
"CLAUDE_CODE_BACKGROUND",
|
|
116
|
+
"CLAUDE_CODE_CONTAINER_ID",
|
|
117
|
+
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
|
|
118
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
119
|
+
"CLAUDE_CODE_EXTRA_METADATA",
|
|
120
|
+
"CLAUDE_CODE_REMOTE_SESSION_ID",
|
|
121
|
+
// Read by `resolveCacheTtl`; snapshotted here so the identity-block ttl this
|
|
122
|
+
// module emits is resolved from the SAME inputs as the body's cache_control
|
|
123
|
+
// markers (request-body.mjs) instead of silently ignoring the two overrides.
|
|
124
|
+
"ENABLE_PROMPT_CACHING_1H",
|
|
125
|
+
"FORCE_PROMPT_CACHING_5M",
|
|
126
|
+
"OPENCODE_ANTHROPIC_SIGNATURE_USER_ID",
|
|
127
|
+
// Read here rather than through `process.env` (see the PURITY CONTRACT at the
|
|
128
|
+
// top of this file) so the capability downgrade is resolved from the same
|
|
129
|
+
// snapshot as everything else this module emits.
|
|
130
|
+
"OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Protocol identity of the pinned profile (`claude-code-2.1.195`). Used to
|
|
135
|
+
* decide whether a `profileOverride` is needed at all — see
|
|
136
|
+
* `resolveProfileOverride` for the user-agent convergence argument.
|
|
137
|
+
*/
|
|
138
|
+
export const PROFILE_CLI_VERSION = "2.1.195";
|
|
139
|
+
export const PROFILE_USER_AGENT = "claude-cli/2.1.195 (external, cli)";
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Environment snapshot
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Snapshot the environment variables this module consumes. Pure with respect to
|
|
147
|
+
* its argument; the caller passes `process.env`.
|
|
148
|
+
*
|
|
149
|
+
* @param {Record<string, string | undefined>} source
|
|
150
|
+
* @returns {Record<string, string | undefined>}
|
|
151
|
+
*/
|
|
152
|
+
export function resolveAdapterEnv(source) {
|
|
153
|
+
/** @type {Record<string, string | undefined>} */
|
|
154
|
+
const snapshot = {};
|
|
155
|
+
for (const key of ADAPTER_ENV_KEYS) {
|
|
156
|
+
if (source?.[key] !== undefined) snapshot[key] = source[key];
|
|
157
|
+
}
|
|
158
|
+
return snapshot;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// User agent
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pure mirror of `buildExtendedUserAgent` (lib/request-headers.mjs) with the
|
|
167
|
+
* environment injected instead of read. The two are pinned to agree by
|
|
168
|
+
* `adapter-input.test.mjs`.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} version
|
|
171
|
+
* @param {Record<string, string | undefined>} env
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function buildAdapterUserAgent(version, env) {
|
|
175
|
+
const entrypoint = env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
|
|
176
|
+
const sdkVersion = env.CLAUDE_AGENT_SDK_VERSION ? `, agent-sdk/${env.CLAUDE_AGENT_SDK_VERSION}` : "";
|
|
177
|
+
const clientApp = env.CLAUDE_AGENT_SDK_CLIENT_APP ? `, client-app/${env.CLAUDE_AGENT_SDK_CLIENT_APP}` : "";
|
|
178
|
+
return `claude-cli/${version} (external, ${entrypoint}${sdkVersion}${clientApp})`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The pinned profile carries a FIXED `userAgent`
|
|
183
|
+
* (`"claude-cli/2.1.195 (external, cli)"`) while the plugin builds one
|
|
184
|
+
* dynamically from entrypoint / agent-sdk version / client-app. In the common
|
|
185
|
+
* case — no `CLAUDE_CODE_ENTRYPOINT`, no `CLAUDE_AGENT_SDK_VERSION`, no
|
|
186
|
+
* `CLAUDE_AGENT_SDK_CLIENT_APP`, detected CLI version `2.1.195` — both sides
|
|
187
|
+
* produce the byte-identical `"claude-cli/2.1.195 (external, cli)"`, so no
|
|
188
|
+
* override is emitted and the request stays byte-identical to the profile.
|
|
189
|
+
*
|
|
190
|
+
* When they diverge, `userAgent` and `cliVersion` are validated TOGETHER by the
|
|
191
|
+
* package (`build-request.ts`), so both are always supplied as a coupled pair.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} claudeCliVersion
|
|
194
|
+
* @param {Record<string, string | undefined>} env
|
|
195
|
+
* @returns {{userAgent: string, cliVersion: string} | undefined}
|
|
196
|
+
*/
|
|
197
|
+
function resolveProfileOverride(claudeCliVersion, env) {
|
|
198
|
+
const cliVersion = claudeCliVersion || PROFILE_CLI_VERSION;
|
|
199
|
+
const userAgent = buildAdapterUserAgent(cliVersion, env);
|
|
200
|
+
if (cliVersion === PROFILE_CLI_VERSION && userAgent === PROFILE_USER_AGENT) return undefined;
|
|
201
|
+
return { userAgent, cliVersion };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Host headers
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reproduce the three-shape header collection of `buildRequestHeaders`
|
|
210
|
+
* (Request headers, then `requestInit.headers` as Headers / pair array / plain
|
|
211
|
+
* object), then strip the names that must never reach the wire, then layer
|
|
212
|
+
* `ANTHROPIC_CUSTOM_HEADERS` on top so it keeps overriding host values.
|
|
213
|
+
*
|
|
214
|
+
* The collected `anthropic-beta` is NOT forwarded as a header — it is canonical,
|
|
215
|
+
* so `extraHeaderPolicy: "dropConflicting"` discards it and the package's own
|
|
216
|
+
* composition governs the wire value. Its CONTENT is still meaningful (a host
|
|
217
|
+
* SDK asking for a beta), so it is split out here and merged into
|
|
218
|
+
* `additionalBetas` by the caller. Dropping the header while keeping the intent
|
|
219
|
+
* is the whole point.
|
|
220
|
+
*
|
|
221
|
+
* @param {Request | undefined} input
|
|
222
|
+
* @param {RequestInit} requestInit
|
|
223
|
+
* @param {Record<string, string | undefined>} env
|
|
224
|
+
* @returns {{pairs: readonly (readonly [string, string])[] | undefined, incomingBetas: readonly string[]}}
|
|
225
|
+
*/
|
|
226
|
+
function collectExtraHeaders(input, requestInit, env) {
|
|
227
|
+
const collected = new Headers();
|
|
228
|
+
|
|
229
|
+
if (input instanceof Request) {
|
|
230
|
+
input.headers.forEach((value, key) => {
|
|
231
|
+
collected.set(key, value);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const initHeaders = requestInit?.headers;
|
|
236
|
+
if (initHeaders) {
|
|
237
|
+
if (initHeaders instanceof Headers) {
|
|
238
|
+
initHeaders.forEach((value, key) => {
|
|
239
|
+
collected.set(key, value);
|
|
240
|
+
});
|
|
241
|
+
} else if (Array.isArray(initHeaders)) {
|
|
242
|
+
for (const [key, value] of initHeaders) {
|
|
243
|
+
if (typeof value !== "undefined") collected.set(key, String(value));
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
for (const [key, value] of Object.entries(initHeaders)) {
|
|
247
|
+
if (typeof value !== "undefined") collected.set(key, String(value));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const name of ADAPTER_STRIPPED_HOST_HEADERS) collected.delete(name);
|
|
253
|
+
|
|
254
|
+
for (const [key, value] of Object.entries(parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS))) {
|
|
255
|
+
collected.set(key, value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// `Headers` normalises names to lower case, so this lookup is already
|
|
259
|
+
// case-insensitive over whatever the host sent, and it joins repeated
|
|
260
|
+
// occurrences with ", " before we split.
|
|
261
|
+
const incomingBetas = (collected.get("anthropic-beta") ?? "")
|
|
262
|
+
.split(",")
|
|
263
|
+
.map((beta) => beta.trim())
|
|
264
|
+
.filter((beta) => beta.length > 0 && !HOST_SDK_BETAS_BLOCKLIST.has(beta));
|
|
265
|
+
|
|
266
|
+
/** @type {(readonly [string, string])[]} */
|
|
267
|
+
const pairs = [];
|
|
268
|
+
collected.forEach((value, key) => {
|
|
269
|
+
pairs.push([key, value]);
|
|
270
|
+
});
|
|
271
|
+
return { pairs: pairs.length > 0 ? pairs : undefined, incomingBetas };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pure mirror of `parseAnthropicCustomHeaders` with the raw value injected.
|
|
276
|
+
*
|
|
277
|
+
* @param {string | undefined} raw
|
|
278
|
+
* @returns {Record<string, string>}
|
|
279
|
+
*/
|
|
280
|
+
function parseCustomHeaders(raw) {
|
|
281
|
+
if (!raw) return {};
|
|
282
|
+
/** @type {Record<string, string>} */
|
|
283
|
+
const headers = {};
|
|
284
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
285
|
+
const trimmed = line.trim();
|
|
286
|
+
if (!trimmed) continue;
|
|
287
|
+
const sep = trimmed.indexOf(":");
|
|
288
|
+
if (sep <= 0) continue;
|
|
289
|
+
const key = trimmed.slice(0, sep).trim();
|
|
290
|
+
const value = trimmed.slice(sep + 1).trim();
|
|
291
|
+
if (!key || !value) continue;
|
|
292
|
+
headers[key] = value;
|
|
293
|
+
}
|
|
294
|
+
return headers;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `x-stainless-retry-count` is a STRING on the header and a NUMBER on the seam.
|
|
299
|
+
* Replicates today's `isFalsyEnv` guard, then coerces. A non-numeric host value
|
|
300
|
+
* (which today would be forwarded verbatim) degrades to 0 rather than pushing
|
|
301
|
+
* `NaN` into the seam.
|
|
302
|
+
*
|
|
303
|
+
* @param {readonly (readonly [string, string])[] | undefined} extraHeaders
|
|
304
|
+
* @returns {number}
|
|
305
|
+
*/
|
|
306
|
+
function resolveStainlessRetryCount(extraHeaders) {
|
|
307
|
+
const pair = extraHeaders?.find(([name]) => name === "x-stainless-retry-count");
|
|
308
|
+
const raw = pair?.[1];
|
|
309
|
+
if (!raw || isFalsyEnv(raw)) return 0;
|
|
310
|
+
const parsed = Number.parseInt(raw, 10);
|
|
311
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Betas
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Build `additionalBetas`: the betas the plugin emits that the package's own
|
|
320
|
+
* `composeBetas` does not.
|
|
321
|
+
*
|
|
322
|
+
* Order is (a) user custom betas, already expanded through `BETA_SHORTCUTS`
|
|
323
|
+
* because the package knows nothing about shortcuts and `validateAdditionalBetas`
|
|
324
|
+
* would let a raw alias such as `afk` reach the wire; then (b) the orphan betas,
|
|
325
|
+
* each under the SAME gate `buildAnthropicBetaHeader` uses today.
|
|
326
|
+
*
|
|
327
|
+
* `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` is applied here because the package
|
|
328
|
+
* does not filter `additionalBetas`.
|
|
329
|
+
*
|
|
330
|
+
* Betas arriving on the host's own `anthropic-beta` header are appended LAST:
|
|
331
|
+
* the header itself is dropped as conflicting, so this is the only path that
|
|
332
|
+
* keeps a host SDK's request alive, and config-declared betas keep precedence
|
|
333
|
+
* in the emitted order.
|
|
334
|
+
*
|
|
335
|
+
* @param {{
|
|
336
|
+
* customBetas: readonly string[] | undefined,
|
|
337
|
+
* strategy: string | undefined,
|
|
338
|
+
* model: string,
|
|
339
|
+
* tokenEconomy: Record<string, unknown>,
|
|
340
|
+
* isFilesEndpoint: boolean,
|
|
341
|
+
* hasFileReferences: boolean,
|
|
342
|
+
* disableExperimentalBetas: boolean,
|
|
343
|
+
* incomingBetas: readonly string[],
|
|
344
|
+
* }} args
|
|
345
|
+
* @returns {readonly string[] | undefined}
|
|
346
|
+
*/
|
|
347
|
+
function buildAdditionalBetas(args) {
|
|
348
|
+
const {
|
|
349
|
+
customBetas,
|
|
350
|
+
strategy,
|
|
351
|
+
model,
|
|
352
|
+
tokenEconomy,
|
|
353
|
+
isFilesEndpoint,
|
|
354
|
+
hasFileReferences,
|
|
355
|
+
disableExperimentalBetas,
|
|
356
|
+
incomingBetas,
|
|
357
|
+
} = args;
|
|
358
|
+
|
|
359
|
+
/** @type {string[]} */
|
|
360
|
+
const betas = [];
|
|
361
|
+
|
|
362
|
+
// (a) user custom betas, shortcut-expanded plugin-side.
|
|
363
|
+
for (const custom of customBetas ?? []) {
|
|
364
|
+
if (typeof custom !== "string") continue;
|
|
365
|
+
const trimmed = custom.trim();
|
|
366
|
+
if (!trimmed) continue;
|
|
367
|
+
betas.push(BETA_SHORTCUTS.get(trimmed.toLowerCase()) || trimmed);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// (b) orphan betas — emitted by the plugin, not by the package's composer.
|
|
371
|
+
const isRoundRobin = strategy === "round-robin";
|
|
372
|
+
const te = tokenEconomy || {};
|
|
373
|
+
|
|
374
|
+
if (supportsWebSearch(model)) betas.push("web-search-2025-03-05");
|
|
375
|
+
if (!CLAUDE_3_MODEL_RE.test(model)) betas.push("advisor-tool-2026-03-01");
|
|
376
|
+
if (isFilesEndpoint || hasFileReferences) betas.push("files-api-2025-04-14");
|
|
377
|
+
if (te.extended_cache_ttl !== false && !isRoundRobin) betas.push("extended-cache-ttl-2025-04-11");
|
|
378
|
+
if (te.structured_outputs && supportsStructuredOutputs(model)) betas.push("structured-outputs-2025-12-15");
|
|
379
|
+
// The package deliberately excludes Haiku from `claude-code-20250219`; the
|
|
380
|
+
// plugin deliberately includes it so Haiku subagents reached through
|
|
381
|
+
// model-router delegation still get full mimic behaviour.
|
|
382
|
+
if (isHaikuModel(model)) betas.push(CLAUDE_CODE_BETA_FLAG);
|
|
383
|
+
|
|
384
|
+
// (c) betas rescued from the host's dropped `anthropic-beta` header, already
|
|
385
|
+
// blocklist-filtered by `collectExtraHeaders`. Last, so the dedup below keeps
|
|
386
|
+
// the config-declared order for anything both sides ask for.
|
|
387
|
+
for (const incoming of incomingBetas) betas.push(incoming);
|
|
388
|
+
|
|
389
|
+
let deduped = [...new Set(betas)];
|
|
390
|
+
if (disableExperimentalBetas) deduped = deduped.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
|
|
391
|
+
|
|
392
|
+
return deduped.length > 0 ? deduped : undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build `suppressBetas`: the FINAL filter, applied by the package after its own
|
|
397
|
+
* composition and after the `additionalBetas` merge. This is the only seam that
|
|
398
|
+
* can reach a beta the package composes on its own — filtering `additionalBetas`
|
|
399
|
+
* cannot, which is exactly why both user-facing switches below used to be
|
|
400
|
+
* silent no-ops once the adapter owned the request.
|
|
401
|
+
*
|
|
402
|
+
* A non-composed identifier is a silent no-op package-side, so passing the whole
|
|
403
|
+
* `EXPERIMENTAL_BETA_FLAGS` set is safe and avoids depending on which of them the
|
|
404
|
+
* package happens to compose for a given model.
|
|
405
|
+
*
|
|
406
|
+
* @param {{isRoundRobin: boolean, disableExperimentalBetas: boolean}} args
|
|
407
|
+
* @returns {readonly string[] | undefined}
|
|
408
|
+
*/
|
|
409
|
+
function buildSuppressBetas({ isRoundRobin, disableExperimentalBetas }) {
|
|
410
|
+
/** @type {Set<string>} */
|
|
411
|
+
const suppressed = new Set();
|
|
412
|
+
|
|
413
|
+
if (isRoundRobin) suppressed.add(PROMPT_CACHING_SCOPE_BETA);
|
|
414
|
+
if (disableExperimentalBetas) for (const beta of EXPERIMENTAL_BETA_FLAGS) suppressed.add(beta);
|
|
415
|
+
|
|
416
|
+
if (suppressed.size === 0) return undefined;
|
|
417
|
+
return [...suppressed];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Metadata
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Translate the two metadata env features onto the mutually exclusive members
|
|
426
|
+
* of `metadataOverrides`. They are structurally different intents:
|
|
427
|
+
*
|
|
428
|
+
* - `OPENCODE_ANTHROPIC_SIGNATURE_USER_ID` replaces `user_id` verbatim with a
|
|
429
|
+
* raw string carrying no correlation triple -> `userId`.
|
|
430
|
+
* - `CLAUDE_CODE_EXTRA_METADATA` merges INTO the derived `user_id` JSON
|
|
431
|
+
* payload -> `userIdFields`.
|
|
432
|
+
*
|
|
433
|
+
* Supplying both is `INVALID_INPUT`, so today's precedence is preserved:
|
|
434
|
+
* `buildRequestMetadata` early-returns on the signature user id, therefore it
|
|
435
|
+
* wins and the extra-metadata merge is never reached.
|
|
436
|
+
*
|
|
437
|
+
* The three correlation keys are filtered because the package rejects them
|
|
438
|
+
* outright, whereas the plugin used to let spread order overwrite them — same
|
|
439
|
+
* observable outcome, no hard failure.
|
|
440
|
+
*
|
|
441
|
+
* @param {Record<string, string | undefined>} env
|
|
442
|
+
* @returns {{userId?: string, userIdFields?: Record<string, unknown>} | undefined}
|
|
443
|
+
*/
|
|
444
|
+
function buildMetadataOverrides(env) {
|
|
445
|
+
const envUserId = env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID?.trim();
|
|
446
|
+
if (envUserId) return { userId: envUserId };
|
|
447
|
+
|
|
448
|
+
const raw = env.CLAUDE_CODE_EXTRA_METADATA?.trim();
|
|
449
|
+
if (!raw) return undefined;
|
|
450
|
+
|
|
451
|
+
let parsed;
|
|
452
|
+
try {
|
|
453
|
+
parsed = JSON.parse(raw);
|
|
454
|
+
} catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
458
|
+
|
|
459
|
+
const userIdFields = Object.fromEntries(
|
|
460
|
+
Object.entries(parsed).filter(([key]) => !METADATA_CORRELATION_KEYS.has(key)),
|
|
461
|
+
);
|
|
462
|
+
return Object.keys(userIdFields).length > 0 ? { userIdFields } : undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Entry point
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @typedef {object} AdapterTransportState
|
|
471
|
+
* @property {Request | undefined} [input] The original host Request.
|
|
472
|
+
* @property {RequestInit} [requestInit] The host request init.
|
|
473
|
+
* @property {string} accessToken Access token of the selected account.
|
|
474
|
+
* @property {URL | undefined} [requestUrl] Parsed outgoing URL.
|
|
475
|
+
* @property {string} provider Result of `detectProvider(requestUrl)`.
|
|
476
|
+
* @property {string} clientRequestId Caller-generated `randomUUID()`.
|
|
477
|
+
* @property {{enabled: boolean, claudeCliVersion: string, customBetas?: readonly string[], strategy?: string, sessionId?: string}} signature
|
|
478
|
+
* @property {{persistentUserId: string, accountId: string}} identity Correlation triple sources.
|
|
479
|
+
* @property {{use1MContext?: unknown} | undefined} [adaptiveOverride]
|
|
480
|
+
* @property {Record<string, unknown> | undefined} [tokenEconomy]
|
|
481
|
+
* @property {{ttl?: string, ttl_supported?: boolean} | undefined} [cachePolicy] Effective cache policy, same object request-body.mjs resolves from.
|
|
482
|
+
* @property {string | undefined} [requestRole] From `classifyRequestRole`; absent means main.
|
|
483
|
+
* @property {boolean | undefined} [isSubagent] True when the turn carries `x-parent-session-id`.
|
|
484
|
+
* @property {boolean | undefined} [isTitleGenerator] True when the ORIGINAL system blocks are the
|
|
485
|
+
* title-generator prompt. Must be derived from the pre-transform body: by the time `body` is
|
|
486
|
+
* handed here the title-generator swap has already replaced those blocks.
|
|
487
|
+
* @property {string} body The already-transformed request body.
|
|
488
|
+
* @property {Record<string, string | undefined>} env From `resolveAdapterEnv`.
|
|
489
|
+
* @property {string} [platform] `process.platform`, injected.
|
|
490
|
+
* @property {string} [arch] `process.arch`, injected.
|
|
491
|
+
* @property {string} [nodeVersion] `process.version`, injected.
|
|
492
|
+
*/
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Translate the plugin's per-request state into the adapter `transport` object.
|
|
496
|
+
*
|
|
497
|
+
* Returns a discriminated result rather than throwing, so the future call site
|
|
498
|
+
* can bifurcate explicitly:
|
|
499
|
+
*
|
|
500
|
+
* - `{applicable: false, reason}` — the adapter MUST NOT be called. Either the
|
|
501
|
+
* signature emulation is off (the plugin emits 3 headers and a minimal beta
|
|
502
|
+
* set; the package always emits the full set), or the provider is not
|
|
503
|
+
* first-party Anthropic (the package pins the Anthropic URL and rejects any
|
|
504
|
+
* other on parse, so Bedrock/Vertex/Foundry/Mantle can never route here).
|
|
505
|
+
* - `{applicable: true, transport}` — safe to hand to
|
|
506
|
+
* `buildWireCompatibleRequest`.
|
|
507
|
+
*
|
|
508
|
+
* `cacheControl` carries ONLY `ttl`: the plugin keeps its own breakpoint
|
|
509
|
+
* placement and the stateful `cacheBoundaryStability` logic, and the omitted
|
|
510
|
+
* `enabled` is what preserves that.
|
|
511
|
+
*
|
|
512
|
+
* @param {AdapterTransportState} state
|
|
513
|
+
* @returns {{applicable: false, reason: string} | {applicable: true, transport: Record<string, unknown>}}
|
|
514
|
+
*/
|
|
515
|
+
export function buildAdapterTransport(state) {
|
|
516
|
+
if (state.provider !== "anthropic") {
|
|
517
|
+
return { applicable: false, reason: ADAPTER_SKIP_NON_ANTHROPIC_PROVIDER };
|
|
518
|
+
}
|
|
519
|
+
if (!state.signature?.enabled) {
|
|
520
|
+
return { applicable: false, reason: ADAPTER_SKIP_SIGNATURE_DISABLED };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const env = state.env ?? {};
|
|
524
|
+
const signature = state.signature;
|
|
525
|
+
const tokenEconomy = state.tokenEconomy ?? {};
|
|
526
|
+
|
|
527
|
+
const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(state.body);
|
|
528
|
+
const requestPath = state.requestUrl?.pathname;
|
|
529
|
+
const isFilesEndpoint = requestPath?.startsWith("/v1/files") ?? false;
|
|
530
|
+
|
|
531
|
+
const { pairs: extraHeaders, incomingBetas } = collectExtraHeaders(state.input, state.requestInit ?? {}, env);
|
|
532
|
+
|
|
533
|
+
const disableExperimentalBetas = isTruthyEnv(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS);
|
|
534
|
+
const isRoundRobin = signature.strategy === "round-robin";
|
|
535
|
+
|
|
536
|
+
const stainlessHelper = buildStainlessHelperHeader(tools, messages);
|
|
537
|
+
const additionalBetas = buildAdditionalBetas({
|
|
538
|
+
customBetas: signature.customBetas,
|
|
539
|
+
strategy: signature.strategy,
|
|
540
|
+
model,
|
|
541
|
+
tokenEconomy,
|
|
542
|
+
isFilesEndpoint,
|
|
543
|
+
hasFileReferences,
|
|
544
|
+
disableExperimentalBetas,
|
|
545
|
+
incomingBetas,
|
|
546
|
+
});
|
|
547
|
+
const suppressBetas = buildSuppressBetas({ isRoundRobin, disableExperimentalBetas });
|
|
548
|
+
|
|
549
|
+
// 1M context is ALWAYS passed explicitly. The plugin's default is
|
|
550
|
+
// `hasOneMillionContext(model)` (`/(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i`)
|
|
551
|
+
// while the package's default is `/\[1m\]/iu` over the raw model — literal
|
|
552
|
+
// brackets. Omitting the member would silently hand the decision to the wrong
|
|
553
|
+
// rule, so the tri-state is resolved here and stated outright.
|
|
554
|
+
const use1MContext =
|
|
555
|
+
state.adaptiveOverride && typeof state.adaptiveOverride.use1MContext === "boolean"
|
|
556
|
+
? state.adaptiveOverride.use1MContext
|
|
557
|
+
: hasOneMillionContext(model);
|
|
558
|
+
|
|
559
|
+
// Same resolution request-body.mjs runs before it stamps cache_control on the
|
|
560
|
+
// system blocks, tools and messages, fed from the same state. Re-deriving it
|
|
561
|
+
// (rather than eyeballing the body) keeps the identity-block marker pinned to
|
|
562
|
+
// the same ttl the rest of the request already carries.
|
|
563
|
+
const basePolicy = state.cachePolicy ?? { ttl: "1h", ttl_supported: true };
|
|
564
|
+
const cachingEnabledForTtl = basePolicy.ttl !== "off" && basePolicy.ttl_supported !== false;
|
|
565
|
+
const cacheTtl = cachingEnabledForTtl
|
|
566
|
+
? resolveCacheTtl({
|
|
567
|
+
configuredTtl: basePolicy.ttl || "1h",
|
|
568
|
+
roleScopedTtl: tokenEconomy.role_scoped_cache_ttl !== false,
|
|
569
|
+
isMainForCache: state.requestRole === "main" || state.requestRole == null,
|
|
570
|
+
isSubagent: state.isSubagent === true,
|
|
571
|
+
env,
|
|
572
|
+
})
|
|
573
|
+
: basePolicy.ttl || "1h";
|
|
574
|
+
|
|
575
|
+
const authTokenOverride = env.ANTHROPIC_AUTH_TOKEN?.trim();
|
|
576
|
+
const profileOverride = resolveProfileOverride(signature.claudeCliVersion, env);
|
|
577
|
+
const metadataOverrides = buildMetadataOverrides(env);
|
|
578
|
+
|
|
579
|
+
/** @type {Record<string, unknown>} */
|
|
580
|
+
const transport = {
|
|
581
|
+
accessToken: authTokenOverride || state.accessToken,
|
|
582
|
+
clientRequestId: state.clientRequestId,
|
|
583
|
+
runtime: {
|
|
584
|
+
sessionId: signature.sessionId || SESSION_ID_FALLBACK,
|
|
585
|
+
deviceId: state.identity.persistentUserId,
|
|
586
|
+
accountUuid: state.identity.accountId,
|
|
587
|
+
runtime: "node",
|
|
588
|
+
runtimeVersion: state.nodeVersion,
|
|
589
|
+
os: getStainlessOs(state.platform),
|
|
590
|
+
arch: getStainlessArch(state.arch),
|
|
591
|
+
},
|
|
592
|
+
app: isTruthyEnv(env.CLAUDE_CODE_BACKGROUND) ? "cli-bg" : "cli",
|
|
593
|
+
stainlessRetryCount: resolveStainlessRetryCount(extraHeaders),
|
|
594
|
+
betaOverrides: { use1MContext },
|
|
595
|
+
// COUNTERINTUITIVE, DO NOT "COMPLETE" THIS OBJECT: `enabled` must stay
|
|
596
|
+
// ABSENT. Its absence is what gates applyToolCacheControl and
|
|
597
|
+
// applyMessageCacheControl package-side, so the plugin's own tool and
|
|
598
|
+
// message cache_control markers survive untouched and placement stays
|
|
599
|
+
// governed by resolveCacheTtl and cacheBoundaryStability here. Setting
|
|
600
|
+
// `enabled: false` would not be equivalent, and `enabled: true` would hand
|
|
601
|
+
// breakpoint placement to the package.
|
|
602
|
+
//
|
|
603
|
+
// `ttl` governs the marker on the identity block at index 1, which the
|
|
604
|
+
// package would otherwise hardcode to "1h". Ahead of the plugin's
|
|
605
|
+
// role-scoped 5m blocks that produced the [5m, 1h, 5m, 5m] sequence and
|
|
606
|
+
// the "system.1.cache_control.ttl: a ttl='1h' cache_control block must not
|
|
607
|
+
// come after a ttl='5m' cache_control block" rejection on subagent turns.
|
|
608
|
+
// Passing the resolved ttl fixes the ordering WITHOUT suppressing the
|
|
609
|
+
// block, so "Fix #3: Identity block has cache_control" — and the system
|
|
610
|
+
// prompt cache hit that depends on it — is preserved.
|
|
611
|
+
cacheControl: { ttl: cacheTtl },
|
|
612
|
+
extraHeaderPolicy: "dropConflicting",
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
if (stainlessHelper) transport.stainlessHelper = stainlessHelper;
|
|
616
|
+
if (env.CLAUDE_CODE_CONTAINER_ID) transport.claudeRemoteContainerId = env.CLAUDE_CODE_CONTAINER_ID;
|
|
617
|
+
if (env.CLAUDE_CODE_REMOTE_SESSION_ID) transport.claudeRemoteSessionId = env.CLAUDE_CODE_REMOTE_SESSION_ID;
|
|
618
|
+
if (env.CLAUDE_AGENT_SDK_CLIENT_APP) transport.clientApp = env.CLAUDE_AGENT_SDK_CLIENT_APP;
|
|
619
|
+
if (isTruthyEnv(env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) transport.anthropicAdditionalProtection = "true";
|
|
620
|
+
// Opt-OUT switch: only an explicitly falsy value disables the billing block,
|
|
621
|
+
// matching how the plugin reads its other negative env flags. Unset means the
|
|
622
|
+
// attribution stays, which is the default Claude Code behaviour.
|
|
623
|
+
if (env.CLAUDE_CODE_ATTRIBUTION_HEADER !== undefined && isFalsyEnv(env.CLAUDE_CODE_ATTRIBUTION_HEADER)) {
|
|
624
|
+
transport.suppressBillingBlock = true;
|
|
625
|
+
}
|
|
626
|
+
// Lean system prompt for non-main requests (`token_economy.lean_system_non_main`).
|
|
627
|
+
//
|
|
628
|
+
// On the LEGACY path this decision lives in `buildSystemPromptBlocks`
|
|
629
|
+
// (`leanNonMain` in lib/mimicry/system-prompt.mjs), which returns the
|
|
630
|
+
// sanitized blocks before the billing header and the identity prefix are
|
|
631
|
+
// prepended. On the ADAPTER path those two blocks are no longer the plugin's
|
|
632
|
+
// to withhold — `suppressCanonicalBlocks` hands their composition to the
|
|
633
|
+
// package — so the same decision has to be re-expressed as the package's two
|
|
634
|
+
// root seams, or the opt-in silently degrades to a no-op.
|
|
635
|
+
//
|
|
636
|
+
// The gate is the SAME conjunction as the legacy one: opt-in flag, a "title"
|
|
637
|
+
// or "small" role, and not a title-generator turn (that shape gets its own
|
|
638
|
+
// canonical system prompt swap and keeps the attribution).
|
|
639
|
+
if (
|
|
640
|
+
tokenEconomy.lean_system_non_main === true &&
|
|
641
|
+
(state.requestRole === "title" || state.requestRole === "small") &&
|
|
642
|
+
state.isTitleGenerator !== true
|
|
643
|
+
) {
|
|
644
|
+
transport.suppressBillingBlock = true;
|
|
645
|
+
// NOTE: the ROOT seam, sibling of `suppressBillingBlock`, which omits the
|
|
646
|
+
// identity block entirely. NOT `cacheControl.suppressIdentityBlock`, which
|
|
647
|
+
// only drops that block's `cache_control` marker and keeps its text.
|
|
648
|
+
transport.suppressIdentityBlock = true;
|
|
649
|
+
}
|
|
650
|
+
// WHY: the package derives `adaptiveThinking` from the model id alone, so on
|
|
651
|
+
// an adaptive model it always emits `thinking: {type: "adaptive"}` and the
|
|
652
|
+
// user-facing `OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING` switch became a
|
|
653
|
+
// silent no-op once the adapter owned the request. `capabilities` is the only
|
|
654
|
+
// seam that can reach that decision; a `true -> false` DOWNGRADE is permitted
|
|
655
|
+
// (an upgrade is `UNSUPPORTED_CAPABILITY`). Turning it off pushes
|
|
656
|
+
// `resolveThinking` into its `else` branch, restoring the explicit
|
|
657
|
+
// `{budget_tokens, type: "enabled"}` shape the plugin used to produce.
|
|
658
|
+
//
|
|
659
|
+
// The key is emitted ONLY when the switch is on: absent means "package
|
|
660
|
+
// decides", which keeps the request byte-identical for everyone else.
|
|
661
|
+
if (isTruthyEnv(env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
|
|
662
|
+
transport.capabilities = { adaptiveThinking: false };
|
|
663
|
+
}
|
|
664
|
+
if (additionalBetas) transport.additionalBetas = additionalBetas;
|
|
665
|
+
if (suppressBetas) transport.suppressBetas = suppressBetas;
|
|
666
|
+
if (metadataOverrides) transport.metadataOverrides = metadataOverrides;
|
|
667
|
+
if (extraHeaders) transport.extraHeaders = extraHeaders;
|
|
668
|
+
if (profileOverride) transport.profileOverride = profileOverride;
|
|
669
|
+
|
|
670
|
+
return { applicable: true, transport };
|
|
671
|
+
}
|