opencode-anthropic-fix 0.2.0 → 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.
Files changed (43) hide show
  1. package/README.md +31 -6
  2. package/cli.mjs +1 -0
  3. package/index.mjs +160 -231
  4. package/lib/backoff.mjs +1 -1
  5. package/lib/cc-credentials.mjs +1 -1
  6. package/lib/config.mjs +49 -27
  7. package/lib/mimicry/adapter-input.mjs +671 -0
  8. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  9. package/lib/mimicry/headers.mjs +71 -66
  10. package/lib/mimicry/request-body.mjs +23 -24
  11. package/lib/mimicry/system-prompt.mjs +54 -23
  12. package/lib/mimicry/wire-compat.mjs +305 -0
  13. package/lib/rotation.mjs +16 -0
  14. package/package.json +3 -3
  15. package/lib/account-state.test.mjs +0 -109
  16. package/lib/accounts.test.mjs +0 -912
  17. package/lib/adaptive-thinking-models.test.mjs +0 -30
  18. package/lib/backoff.test.mjs +0 -411
  19. package/lib/cc-credentials.test.mjs +0 -391
  20. package/lib/config.test.mjs +0 -757
  21. package/lib/context-hint-persist.mjs +0 -68
  22. package/lib/diagnose.test.mjs +0 -122
  23. package/lib/env.test.mjs +0 -25
  24. package/lib/haiku-call.test.mjs +0 -72
  25. package/lib/message-transform.test.mjs +0 -173
  26. package/lib/mimicry/cache.test.mjs +0 -112
  27. package/lib/mimicry/headers.test.mjs +0 -50
  28. package/lib/mimicry/models.test.mjs +0 -132
  29. package/lib/mimicry/request-body.test.mjs +0 -34
  30. package/lib/mimicry/request-helpers.test.mjs +0 -155
  31. package/lib/mimicry/response-stream.test.mjs +0 -127
  32. package/lib/mimicry/system-prompt.test.mjs +0 -73
  33. package/lib/oauth.test.mjs +0 -343
  34. package/lib/redact.test.mjs +0 -154
  35. package/lib/refresh-lock.test.mjs +0 -131
  36. package/lib/request-headers.test.mjs +0 -164
  37. package/lib/retry/overload-loop.test.mjs +0 -107
  38. package/lib/rotation.test.mjs +0 -346
  39. package/lib/session-metrics.test.mjs +0 -67
  40. package/lib/storage.test.mjs +0 -696
  41. package/lib/token-economy/microcompact.test.mjs +0 -28
  42. package/lib/token-economy/transforms.test.mjs +0 -192
  43. 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
+ }