opencode-anthropic-fix 0.1.31 → 0.2.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 (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. package/package.json +4 -1
@@ -0,0 +1,530 @@
1
+ import { isTruthyEnv, isFalsyEnv } from "../env.mjs";
2
+ import { CLAUDE_3_MODEL_RE, hasOneMillionContext } from "./models.mjs";
3
+ import { EXPERIMENTAL_BETA_FLAGS, BETA_SHORTCUTS, buildExtendedUserAgent } from "../request-headers.mjs";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ export const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
7
+ export const _EFFORT_BETA_FLAG = "effort-2025-11-24";
8
+ export const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
9
+ export const TOKEN_COUNTING_BETA_FLAG = "token-counting-2024-11-01";
10
+ export const BEDROCK_UNSUPPORTED_BETAS = new Set([
11
+ "interleaved-thinking-2025-05-14",
12
+ "context-1m-2025-08-07",
13
+ "tool-search-tool-2025-10-19",
14
+ ]);
15
+ export const HOST_SDK_BETAS_BLOCKLIST = new Set([
16
+ "fine-grained-tool-streaming-2025-05-14",
17
+ "structured-outputs-2025-11-13",
18
+ ]);
19
+ export const STAINLESS_HELPER_KEYS = [
20
+ "x_stainless_helper",
21
+ "x-stainless-helper",
22
+ "stainless_helper",
23
+ "stainlessHelper",
24
+ "_stainless_helper",
25
+ ];
26
+ export const _EFFORT_EXCLUDED_MODELS = [
27
+ /claude-opus-4-0/i,
28
+ /claude-opus-4-1/i,
29
+ /claude-sonnet-4-0/i,
30
+ /claude-sonnet-4-5/i,
31
+ /claude-haiku-4-5/i,
32
+ ];
33
+
34
+ export function isNonInteractiveMode() {
35
+ if (isTruthyEnv(process.env.CI)) return true;
36
+ return !process.stdout.isTTY;
37
+ }
38
+
39
+ export function parseAnthropicCustomHeaders() {
40
+ const raw = process.env.ANTHROPIC_CUSTOM_HEADERS;
41
+ if (!raw) return {};
42
+
43
+ /** @type {Record<string, string>} */
44
+ const headers = {};
45
+ for (const line of raw.split(/\r?\n/)) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed) continue;
48
+ const sep = trimmed.indexOf(":");
49
+ if (sep <= 0) continue;
50
+ const key = trimmed.slice(0, sep).trim();
51
+ const value = trimmed.slice(sep + 1).trim();
52
+ if (!key || !value) continue;
53
+ headers[key] = value;
54
+ }
55
+
56
+ return headers;
57
+ }
58
+
59
+ export function isHaikuModel(model) {
60
+ return /haiku/i.test(model);
61
+ }
62
+
63
+ export function supportsStructuredOutputs(model) {
64
+ if (!/claude|sonnet|opus|haiku/i.test(model)) return false;
65
+ return !isHaikuModel(model);
66
+ }
67
+
68
+ export function supportsWebSearch(model) {
69
+ return /claude|sonnet|opus|haiku|gpt|gemini/i.test(model);
70
+ }
71
+
72
+ export function detectProvider(requestUrl) {
73
+ // Match Claude Code provider precedence first (env-driven), then URL fallback.
74
+ if (isTruthyEnv(process.env.CLAUDE_CODE_USE_BEDROCK)) return "bedrock";
75
+ if (isTruthyEnv(process.env.CLAUDE_CODE_USE_FOUNDRY)) return "foundry";
76
+ if (isTruthyEnv(process.env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return "anthropicAws";
77
+ if (isTruthyEnv(process.env.CLAUDE_CODE_USE_MANTLE)) return "mantle";
78
+ if (isTruthyEnv(process.env.CLAUDE_CODE_USE_VERTEX)) return "vertex";
79
+
80
+ if (!requestUrl) return "anthropic";
81
+ const host = requestUrl.hostname.toLowerCase();
82
+ if (host.includes("mantle")) return "mantle";
83
+ if (host.includes("anthropicaws")) return "anthropicAws";
84
+ if (host.includes("bedrock") || host.includes("amazonaws.com")) return "bedrock";
85
+ if (host.includes("aiplatform") || host.includes("vertex")) return "vertex";
86
+ if (host.includes("foundry") || host.includes("azure")) return "foundry";
87
+ return "anthropic";
88
+ }
89
+
90
+ export function parseRequestBodyMetadata(body, parsedBody) {
91
+ const parsed =
92
+ parsedBody ||
93
+ (typeof body === "string"
94
+ ? (() => {
95
+ try {
96
+ return JSON.parse(body);
97
+ } catch {
98
+ return null;
99
+ }
100
+ })()
101
+ : null);
102
+ if (!parsed) {
103
+ return { model: "", tools: [], messages: [], hasFileReferences: false };
104
+ }
105
+
106
+ const model = typeof parsed?.model === "string" ? parsed.model : "";
107
+ const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
108
+ const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
109
+ const hasFileReferences = extractFileIds(parsed).length > 0;
110
+ return { model, tools, messages, hasFileReferences };
111
+ }
112
+
113
+ export function buildStainlessHelperHeader(tools, messages) {
114
+ const helpers = new Set();
115
+
116
+ const collect = (value) => {
117
+ if (!value || typeof value !== "object") return;
118
+
119
+ for (const key of STAINLESS_HELPER_KEYS) {
120
+ if (typeof value[key] === "string" && value[key]) {
121
+ helpers.add(value[key]);
122
+ }
123
+ }
124
+
125
+ if (Array.isArray(value.content)) {
126
+ for (const contentBlock of value.content) {
127
+ collect(contentBlock);
128
+ }
129
+ }
130
+ };
131
+
132
+ for (const tool of tools) collect(tool);
133
+ for (const message of messages) collect(message);
134
+
135
+ return Array.from(helpers).join(", ");
136
+ }
137
+
138
+ export function isEffortCapableModel(model) {
139
+ if (!model) return false;
140
+ if (CLAUDE_3_MODEL_RE.test(model)) return false;
141
+ return !_EFFORT_EXCLUDED_MODELS.some((re) => re.test(model));
142
+ }
143
+
144
+ export function buildAnthropicBetaHeader(
145
+ incomingBeta,
146
+ signatureEnabled,
147
+ model,
148
+ provider,
149
+ customBetas,
150
+ strategy,
151
+ requestPath,
152
+ hasFileReferences,
153
+ adaptiveOverride,
154
+ tokenEconomy,
155
+ microcompactBetas, // NEW 11th param
156
+ fastModeActive, // NEW 12th param — see @param above
157
+ ) {
158
+ const incomingBetasList = incomingBeta
159
+ .split(",")
160
+ .map((b) => b.trim())
161
+ .filter(Boolean);
162
+
163
+ const betas = ["oauth-2025-04-20"];
164
+ const disableExperimentalBetas = isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS);
165
+ const isMessagesCountTokensPath = requestPath === "/v1/messages/count_tokens";
166
+ const isFilesEndpoint = requestPath?.startsWith("/v1/files") ?? false;
167
+
168
+ if (!signatureEnabled) {
169
+ betas.push("interleaved-thinking-2025-05-14");
170
+ if (isMessagesCountTokensPath) {
171
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
172
+ }
173
+ let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
174
+ if (disableExperimentalBetas) {
175
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
176
+ }
177
+ return mergedBetas.join(",");
178
+ }
179
+
180
+ const nonInteractive = isNonInteractiveMode();
181
+ const haiku = isHaikuModel(model);
182
+ const isRoundRobin = strategy === "round-robin";
183
+ const te = tokenEconomy || {};
184
+
185
+ // === ALWAYS-ON BETAS (Claude Code v2.1.90 base set) ===
186
+ // These are ALWAYS included regardless of env vars or feature flags.
187
+ // NOTE: Real Claude Code skips this beta for Haiku, but we include it
188
+ // so that Haiku subagents (via model-router delegation) get full mimic
189
+ // behavior from the Anthropic API.
190
+ betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
191
+
192
+ // v2.1.150: advanced-tool-use / tool-search-tool removed from always-on.
193
+ // CC 2.1.150 D5q does not push these per-request. Available via custom betas.
194
+
195
+ // v2.1.150: fast-mode-2026-02-01 removed from always-on (CC sends only when speed feature active).
196
+
197
+ // v2.1.195: effort-2025-11-24 is no longer always-on; it is now a model-gated
198
+ // default emitted in the conditional section below for effort-capable models
199
+ // (mirrors CC's Kw(model)).
200
+
201
+ // Interleaved thinking — real CC's i01 pushes via hv4(model), which is
202
+ // (firstParty && non-Claude-3). Claude 3.x models don't support interleaved
203
+ // thinking and real CC never sends this flag for them, so emitting it
204
+ // diverges the fingerprint for legacy Haiku/Sonnet 3.x requests.
205
+ if (!isTruthyEnv(process.env.DISABLE_INTERLEAVED_THINKING) && !CLAUDE_3_MODEL_RE.test(model)) {
206
+ betas.push("interleaved-thinking-2025-05-14");
207
+ }
208
+
209
+ // Context 1M — when adaptive override is provided, use it; otherwise fall back to static check.
210
+ {
211
+ const use1M =
212
+ adaptiveOverride && typeof adaptiveOverride.use1MContext === "boolean"
213
+ ? adaptiveOverride.use1MContext
214
+ : hasOneMillionContext(model);
215
+ if (use1M) {
216
+ betas.push("context-1m-2025-08-07");
217
+ }
218
+ }
219
+
220
+ // Prompt caching scope — always-on EXCEPT in round-robin (per-workspace state conflicts)
221
+ if (!isRoundRobin) {
222
+ betas.push("prompt-caching-scope-2026-01-05");
223
+ }
224
+ // v2.1.150: extended-cache-ttl for better cache hit rates (opt-out via token_economy.extended_cache_ttl = false).
225
+ if (te.extended_cache_ttl !== false && !isRoundRobin) {
226
+ betas.push("extended-cache-ttl-2025-04-11");
227
+ }
228
+
229
+ // === CONDITIONAL BETAS (model/context-dependent) ===
230
+
231
+ // First-party provider predicate (OAuth path). Mirrors CC's ZO(provider) which
232
+ // excludes bedrock/vertex/mantle. context-management and effort are first-party
233
+ // only here, so they must never be pushed on bedrock/vertex.
234
+ const isFirstPartyProvider = provider !== "vertex" && provider !== "bedrock" && provider !== "mantle";
235
+
236
+ // v2.1.195: context-management-2025-06-27 is default-ON for first-party non-claude-3
237
+ // models (incl. Haiku 4.5), mirroring CC's n0d(model) eligibility path. Earlier
238
+ // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
239
+ // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
240
+ // first-party models. Opt out via token_economy.context_management = false.
241
+ if (te.context_management !== false && isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model)) {
242
+ betas.push("context-management-2025-06-27");
243
+ }
244
+
245
+ // v2.1.195: effort-2025-11-24 is a model-gated default for effort-capable models
246
+ // (Opus 4.5/4.6/4.7/4.8, Sonnet 4.6), mirroring CC's Kw(model). Excluded for
247
+ // claude-3-*, opus-4-0, opus-4-1, sonnet-4-0, sonnet-4-5, haiku-4-5. First-party
248
+ // only; opt out via token_economy.effort = false.
249
+ if (te.effort !== false && isFirstPartyProvider && isEffortCapableModel(model)) {
250
+ betas.push(_EFFORT_BETA_FLAG);
251
+ }
252
+
253
+ // v2.1.150: structured-outputs gated behind opt-in (CC has behind tengu_tool_pear flag).
254
+ if (te.structured_outputs && supportsStructuredOutputs(model)) {
255
+ betas.push("structured-outputs-2025-12-15");
256
+ }
257
+
258
+ // Web search — for models that support it
259
+ if (supportsWebSearch(model)) {
260
+ betas.push("web-search-2025-03-05");
261
+ }
262
+
263
+ // Advisor tool — in CC this is gated by server-side feature flag
264
+ // (tengu_sage_compass2) and firstParty+isLoggedIn. Since we can't check
265
+ // CC's feature flags, include it unconditionally for Claude 4+ models.
266
+ // CC v108 sends it in MITM captures for Max/Pro users.
267
+ if (!CLAUDE_3_MODEL_RE.test(model)) {
268
+ betas.push("advisor-tool-2026-03-01");
269
+ }
270
+
271
+ // context-hint-2026-04-09 — introduced in CC v2.1.110. Paired with the body
272
+ // field `context_hint: { enabled: true }` (injected in transformRequestBody).
273
+ // Real CC gates it on includeFirstPartyBetas + querySource startsWith
274
+ // "repl_main_thread" (i.e. interactive sessions on 1P Anthropic only).
275
+ // Server responds with hints suggesting which messages to forget for
276
+ // token-efficient context compaction. Sticky: disabled permanently on
277
+ // 400/409/529 errors referencing the hint. Users can opt out via
278
+ // token_economy.context_hint = false.
279
+ // Mimicry note: real CC gates context-hint on querySource.startsWith("repl_main_thread").
280
+ // We infer main-thread from body shape via classifyRequestRole and pass it via
281
+ // tokenEconomy.__requestRole. Treat absent marker as "main" for backward compat.
282
+ const _isMainThread = te.__requestRole == null || te.__requestRole === "main";
283
+ if (isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model) && te.context_hint !== false && _isMainThread) {
284
+ betas.push("context-hint-2026-04-09");
285
+ }
286
+
287
+ // Files API — scoped to file endpoints/references
288
+ if (isFilesEndpoint || hasFileReferences) {
289
+ betas.push("files-api-2025-04-14");
290
+ }
291
+
292
+ // Token counting endpoint
293
+ if (isMessagesCountTokensPath) {
294
+ betas.push(TOKEN_COUNTING_BETA_FLAG);
295
+ }
296
+
297
+ // === TOKEN ECONOMY BETAS (on by default for token savings) ===
298
+
299
+ // v2.1.150: redact-thinking is default-ON in CC (first-party, non-SDK, thinking models).
300
+ // Opt out via `/anthropic set redact-thinking off` to see thinking content.
301
+ if (te.redact_thinking !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
302
+ betas.push("redact-thinking-2026-02-12");
303
+ }
304
+
305
+ // v2.1.150: thinking-token-count for token budget tracking (behind tengu_chert_bezel in CC).
306
+ // Default ON for token economy visibility. Opt out via token_economy.thinking_token_count = false.
307
+ if (te.thinking_token_count !== false && !disableExperimentalBetas && !CLAUDE_3_MODEL_RE.test(model)) {
308
+ betas.push("thinking-token-count-2026-05-13");
309
+ }
310
+
311
+ // compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
312
+
313
+ // afk-mode — NOT auto-included (requires user opt-in)
314
+ // Available via: /anthropic betas add afk-mode-2026-01-31
315
+
316
+ // === MICROCOMPACT BETAS (context-aware, Phase 3 Task 3.4) ===
317
+ if (microcompactBetas?.length) {
318
+ for (const mb of microcompactBetas) {
319
+ if (!betas.includes(mb)) betas.push(mb);
320
+ }
321
+ }
322
+
323
+ // Fast-mode beta — the PLUGIN injects speed:"fast" into the body, not the host.
324
+ // Because the host never sends speed:"fast", it also never sends the matching beta.
325
+ // We must add it here, derived structurally from the already-transformed body so
326
+ // it is always in lockstep with the speed field. Added BEFORE the dedupe merge so
327
+ // any duplicate (e.g. via incoming passthrough) is collapsed without duplication.
328
+ // Unlike effort-2025-11-24 / advanced-tool-use / tool-search-tool (which arrive via
329
+ // incoming-header passthrough because the HOST sets those body features), fast-mode
330
+ // is plugin-only and requires this explicit push.
331
+ if (fastModeActive) betas.push(FAST_MODE_BETA_FLAG);
332
+
333
+ // Merge incoming betas from the original request, filtering out host-injected
334
+ // betas (e.g. fine-grained-tool-streaming-2025-05-14, structured-outputs-2025-11-13)
335
+ // that OpenCode's Anthropic SDK adds but real Claude Code never sends.
336
+ const filteredIncoming = incomingBetasList.filter((b) => !HOST_SDK_BETAS_BLOCKLIST.has(b));
337
+ let mergedBetas = [...new Set([...betas, ...filteredIncoming])];
338
+
339
+ // Add custom betas from config
340
+ if (customBetas?.length) {
341
+ for (const custom of customBetas) {
342
+ const resolved = BETA_SHORTCUTS.get(custom) || custom;
343
+ if (resolved && !mergedBetas.includes(resolved)) {
344
+ mergedBetas.push(resolved);
345
+ }
346
+ }
347
+ }
348
+
349
+ // Filter out experimental betas only if explicitly disabled.
350
+ // WARNING: The EXPERIMENTAL_BETA_FLAGS set overlaps with most always-on betas.
351
+ // Enabling CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS effectively strips Claude Code
352
+ // mimicry betas, leaving only oauth-2025-04-20, claude-code-20250219, and effort-*.
353
+ // Use this escape hatch only for debugging or when betas cause API rejections.
354
+ if (disableExperimentalBetas) {
355
+ mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
356
+ }
357
+
358
+ // Remove betas unsupported by Bedrock
359
+ if (provider === "bedrock") {
360
+ mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
361
+ }
362
+
363
+ return mergedBetas.join(",");
364
+ }
365
+
366
+ export function getStainlessOs(value) {
367
+ if (value === "darwin") return "macOS";
368
+ if (value === "win32") return "Windows";
369
+ if (value === "linux") return "Linux";
370
+ return value;
371
+ }
372
+
373
+ export function getStainlessArch(value) {
374
+ if (value === "x64") return "x64";
375
+ if (value === "arm64") return "arm64";
376
+ return value;
377
+ }
378
+
379
+ export function buildRequestHeaders(
380
+ input,
381
+ requestInit,
382
+ accessToken,
383
+ requestBody,
384
+ requestUrl,
385
+ signature,
386
+ adaptiveOverride,
387
+ tokenEconomy,
388
+ ) {
389
+ const requestHeaders = new Headers();
390
+ if (input instanceof Request) {
391
+ input.headers.forEach((value, key) => {
392
+ requestHeaders.set(key, value);
393
+ });
394
+ }
395
+ if (requestInit.headers) {
396
+ if (requestInit.headers instanceof Headers) {
397
+ requestInit.headers.forEach((value, key) => {
398
+ requestHeaders.set(key, value);
399
+ });
400
+ } else if (Array.isArray(requestInit.headers)) {
401
+ for (const [key, value] of requestInit.headers) {
402
+ if (typeof value !== "undefined") {
403
+ requestHeaders.set(key, String(value));
404
+ }
405
+ }
406
+ } else {
407
+ for (const [key, value] of Object.entries(requestInit.headers)) {
408
+ if (typeof value !== "undefined") {
409
+ requestHeaders.set(key, String(value));
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ // Preserve all incoming beta headers while ensuring OAuth requirements
416
+ const incomingBeta = requestHeaders.get("anthropic-beta") || "";
417
+ const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
418
+ const provider = detectProvider(requestUrl);
419
+ // Detect fast mode structurally from the already-transformed body string.
420
+ // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
421
+ // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
422
+ // toast check (~line 3018). This is the correct call site for fast-mode: the
423
+ // computedBetaHeader call at index.mjs:2905 runs BEFORE body transform, so fastModeActive
424
+ // is always false/absent there, keeping the latch free of fast-mode. The
425
+ // requestHeaders.set("anthropic-beta", mergedBetas) call below is what reaches the wire.
426
+ const fastModeActive = typeof requestBody === "string" && requestBody.includes('"speed":"fast"');
427
+ const mergedBetas = buildAnthropicBetaHeader(
428
+ incomingBeta,
429
+ signature.enabled,
430
+ model,
431
+ provider,
432
+ signature.customBetas,
433
+ signature.strategy,
434
+ requestUrl?.pathname,
435
+ hasFileReferences,
436
+ adaptiveOverride,
437
+ tokenEconomy,
438
+ undefined, // microcompactBetas: not available at this call site (handled via computedBetaHeader path at 2905)
439
+ fastModeActive, // NEW 12th param: emit fast-mode-2026-02-01 beta when body contains speed:"fast"
440
+ );
441
+
442
+ const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
443
+ const bearerToken = authTokenOverride || accessToken;
444
+
445
+ requestHeaders.set("authorization", `Bearer ${bearerToken}`);
446
+ requestHeaders.set("anthropic-beta", mergedBetas);
447
+ requestHeaders.set("user-agent", buildExtendedUserAgent(signature.claudeCliVersion));
448
+ if (signature.enabled) {
449
+ requestHeaders.set("anthropic-version", "2023-06-01");
450
+ // Fix #6: x-app is "cli" for interactive mode, "cli-bg" for background tasks.
451
+ // Real CC (client.ts:106): 'x-app': 'cli' (foreground) or 'cli-bg' (background agent).
452
+ requestHeaders.set("x-app", isTruthyEnv(process.env.CLAUDE_CODE_BACKGROUND) ? "cli-bg" : "cli");
453
+ // Fix #3: X-Claude-Code-Session-Id — sent in ALL requests by real CC (client.ts:108).
454
+ // Value matches metadata.user_id.session_id for server-side correlation.
455
+ if (signature.sessionId) {
456
+ requestHeaders.set("X-Claude-Code-Session-Id", signature.sessionId);
457
+ }
458
+ requestHeaders.set("x-stainless-arch", getStainlessArch(process.arch));
459
+ requestHeaders.set("x-stainless-lang", "js");
460
+ requestHeaders.set("x-stainless-os", getStainlessOs(process.platform));
461
+ // Real CC sends 0.81.0 (confirmed via proxy capture), not the internal 0.208.0.
462
+ // WATCH: most-likely-to-drift mimesis constant. Stable v2.1.97 → v2.1.105 (only
463
+ // the minifier identifier renamed, d66 → g86). Re-verify on every upstream bump:
464
+ // rg -n '"0\.\d+\.\d+"' _tmp_claude_pkg/<version>/package/cli.js | rg -C2 stainless
465
+ // See docs/future-improvements.md §7 and claude-code-reverse-engineering.md §16.
466
+ requestHeaders.set("x-stainless-package-version", "0.94.0");
467
+ // Real CC on Windows/Node reports "node" — confirmed via proxy capture.
468
+ requestHeaders.set("x-stainless-runtime", "node");
469
+ requestHeaders.set("x-stainless-runtime-version", process.version);
470
+ const incomingRetryCount = requestHeaders.get("x-stainless-retry-count");
471
+ requestHeaders.set(
472
+ "x-stainless-retry-count",
473
+ incomingRetryCount && !isFalsyEnv(incomingRetryCount) ? incomingRetryCount : "0",
474
+ );
475
+ // x-stainless-timeout: real CC sends 600 on ALL requests (confirmed via proxy capture).
476
+ requestHeaders.set("x-stainless-timeout", "600");
477
+ // anthropic-dangerous-direct-browser-access: real CC sends this on all requests.
478
+ requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
479
+ const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
480
+ if (stainlessHelpers) {
481
+ requestHeaders.set("x-stainless-helper", stainlessHelpers);
482
+ }
483
+
484
+ for (const [key, value] of Object.entries(parseAnthropicCustomHeaders())) {
485
+ requestHeaders.set(key, value);
486
+ }
487
+ if (process.env.CLAUDE_CODE_CONTAINER_ID) {
488
+ requestHeaders.set("x-claude-remote-container-id", process.env.CLAUDE_CODE_CONTAINER_ID);
489
+ }
490
+ if (process.env.CLAUDE_CODE_REMOTE_SESSION_ID) {
491
+ requestHeaders.set("x-claude-remote-session-id", process.env.CLAUDE_CODE_REMOTE_SESSION_ID);
492
+ }
493
+ if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) {
494
+ requestHeaders.set("x-client-app", process.env.CLAUDE_AGENT_SDK_CLIENT_APP);
495
+ }
496
+ if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
497
+ requestHeaders.set("x-anthropic-additional-protection", "true");
498
+ }
499
+
500
+ // x-client-request-id: real CC 2.1.195's first-party fetch middleware (Ukd) sets
501
+ // this to crypto.randomUUID() on every first-party request. Re-emit a random uuid.
502
+ requestHeaders.set("x-client-request-id", randomUUID());
503
+ }
504
+ requestHeaders.delete("x-api-key");
505
+ // x-session-affinity: set by opencode SDK but NOT in real CC. Strip it.
506
+ requestHeaders.delete("x-session-affinity");
507
+
508
+ return requestHeaders;
509
+ }
510
+
511
+ export function extractFileIds(body) {
512
+ const ids = [];
513
+ if (!body || typeof body !== "object") return ids;
514
+ // QA fix L-depth: cap recursion depth to prevent stack overflow on pathological payloads
515
+ const MAX_DEPTH = 20;
516
+ function walk(obj, depth) {
517
+ if (depth > MAX_DEPTH) return;
518
+ if (Array.isArray(obj)) {
519
+ for (const item of obj) walk(item, depth + 1);
520
+ } else if (obj && typeof obj === "object") {
521
+ if (obj.source?.file_id) ids.push(obj.source.file_id);
522
+ for (const val of Object.values(obj)) {
523
+ if (val && typeof val === "object") walk(val, depth + 1);
524
+ }
525
+ }
526
+ }
527
+ walk(body.messages, 0);
528
+ walk(body.system, 0);
529
+ return ids;
530
+ }
@@ -0,0 +1,50 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildAnthropicBetaHeader, buildRequestHeaders, detectProvider, isHaikuModel } from "./headers.mjs";
3
+
4
+ describe("mimicry headers", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs();
7
+ });
8
+
9
+ it("places the OAuth beta first when signature emulation is enabled", () => {
10
+ const header = buildAnthropicBetaHeader("", true, "claude-sonnet-4-6", "anthropic");
11
+
12
+ expect(header.split(",")[0]).toBe("oauth-2025-04-20");
13
+ });
14
+
15
+ it("detects Haiku model IDs", () => {
16
+ expect(isHaikuModel("claude-3-5-haiku-20241022")).toBe(true);
17
+ expect(isHaikuModel("claude-sonnet-4-6")).toBe(false);
18
+ });
19
+
20
+ it("detects the Anthropic provider from the API URL", () => {
21
+ vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "");
22
+ vi.stubEnv("CLAUDE_CODE_USE_FOUNDRY", "");
23
+ vi.stubEnv("CLAUDE_CODE_USE_ANTHROPIC_AWS", "");
24
+ vi.stubEnv("CLAUDE_CODE_USE_MANTLE", "");
25
+ vi.stubEnv("CLAUDE_CODE_USE_VERTEX", "");
26
+
27
+ expect(detectProvider(new URL("https://api.anthropic.com/v1/messages"))).toBe("anthropic");
28
+ });
29
+
30
+ it("builds foreground request headers with a user agent and beta header", () => {
31
+ vi.stubEnv("CLAUDE_CODE_BACKGROUND", "");
32
+ const body = JSON.stringify({
33
+ model: "claude-sonnet-4-6",
34
+ messages: [{ role: "user", content: "Hello" }],
35
+ });
36
+
37
+ const headers = buildRequestHeaders(
38
+ "https://api.anthropic.com/v1/messages",
39
+ { headers: {} },
40
+ "oauth-token",
41
+ body,
42
+ new URL("https://api.anthropic.com/v1/messages"),
43
+ { enabled: true, claudeCliVersion: "2.1.195" },
44
+ );
45
+
46
+ expect(headers.get("user-agent")).toBeTruthy();
47
+ expect(headers.get("anthropic-beta")).toContain("oauth-2025-04-20");
48
+ expect(headers.get("x-app")).toBe("cli");
49
+ });
50
+ });