opencode-anthropic-fix 0.1.32 → 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.
- package/README.md +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- 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
|
+
});
|