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,127 @@
1
+ /**
2
+ * Claude Code 2.1.195 context-hint token accounting.
3
+ *
4
+ * The native bundle calls these values Pac, gao, Acp, aNn, and Ecp,
5
+ * respectively. Its internal transcript checks `type === "assistant"`; the
6
+ * plugin receives the equivalent wire shape as `role === "assistant"`.
7
+ *
8
+ * Genuine Claude Code also filters tool uses through `Hcp.has(block.name)`.
9
+ * In Claude Code 2.1.195, `Hcp` contains Read, Bash, PowerShell, Grep, Glob,
10
+ * WebSearch, WebFetch, Edit, and Write.
11
+ *
12
+ * @typedef {{ type?: string, id?: string, name?: string, tool_use_id?: string, content?: unknown, text?: unknown }} ContextBlock
13
+ * @typedef {{ role?: string, content?: unknown }} ContextMessage
14
+ */
15
+
16
+ export const KEPT_TOOL_RESULT_GROUPS = 5;
17
+ export const CONTEXT_HINT_TOKEN_THRESHOLD = 20_000;
18
+ export const MEDIA_BLOCK_TOKENS = 2_000;
19
+ export const CLEARED_MARKER = "[Old tool result content cleared]";
20
+ export const PERSISTED_PREFIX = "<persisted-output>";
21
+ export const CONTEXT_HINT_TOOL_NAMES = new Set([
22
+ "Read",
23
+ "Bash",
24
+ "PowerShell",
25
+ "Grep",
26
+ "Glob",
27
+ "WebSearch",
28
+ "WebFetch",
29
+ "Edit",
30
+ "Write",
31
+ ]);
32
+
33
+ /**
34
+ * Genuine `If` estimator. This intentionally uses Math.round, unlike the
35
+ * plugin's other token estimators.
36
+ *
37
+ * @param {unknown} value
38
+ * @returns {number}
39
+ */
40
+ export function estimateTextTokens(value) {
41
+ if (typeof value !== "string") return 0;
42
+ return Math.round(value.length / 4);
43
+ }
44
+
45
+ /**
46
+ * Genuine `Tcp` tool-result estimator.
47
+ *
48
+ * @param {ContextBlock} block
49
+ * @returns {number}
50
+ */
51
+ export function estimateToolResultTokens(block) {
52
+ if (!block.content) return 0;
53
+ if (typeof block.content === "string") return estimateTextTokens(block.content);
54
+ if (!Array.isArray(block.content)) return 0;
55
+
56
+ return block.content.reduce((sum, item) => {
57
+ if (item?.type === "text") return sum + estimateTextTokens(item.text);
58
+ if (item?.type === "image" || item?.type === "document") return sum + MEDIA_BLOCK_TOKENS;
59
+ return sum;
60
+ }, 0);
61
+ }
62
+
63
+ /**
64
+ * Genuine `wcp` cleared-result check.
65
+ *
66
+ * @param {unknown} content
67
+ * @returns {boolean}
68
+ */
69
+ export function isAlreadyCleared(content) {
70
+ return typeof content === "string" && (content === CLEARED_MARKER || content.startsWith(PERSISTED_PREFIX));
71
+ }
72
+
73
+ /**
74
+ * Genuine `vcp` collection order, adapted from the internal transcript's
75
+ * `type` discriminator to the wire message's `role` discriminator. Tool names
76
+ * are matched case-sensitively against Claude Code 2.1.195's native `Hcp` set.
77
+ *
78
+ * @param {ContextMessage[]} messages
79
+ * @returns {string[]}
80
+ */
81
+ export function collectClearableToolUseIds(messages) {
82
+ const ids = [];
83
+
84
+ for (const message of messages) {
85
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
86
+ for (const block of message.content) {
87
+ if (block?.type === "tool_use" && CONTEXT_HINT_TOOL_NAMES.has(block.name)) ids.push(block.id);
88
+ }
89
+ }
90
+
91
+ return ids;
92
+ }
93
+
94
+ /**
95
+ * Genuine `hao(messages, Pac)` saved-token calculation.
96
+ *
97
+ * @param {ContextMessage[]} messages
98
+ * @returns {number}
99
+ */
100
+ export function computeContextHintTokensSaved(messages) {
101
+ const ids = collectClearableToolUseIds(messages);
102
+ const keep = Math.max(1, KEPT_TOOL_RESULT_GROUPS);
103
+ const keepSet = new Set(ids.slice(-keep));
104
+ const clearSet = new Set(ids.filter((id) => !keepSet.has(id)));
105
+ let tokensSaved = 0;
106
+
107
+ if (clearSet.size > 0) {
108
+ for (const message of messages) {
109
+ if (message?.role !== "user" || !Array.isArray(message.content)) continue;
110
+ for (const block of message.content) {
111
+ if (block?.type === "tool_result" && clearSet.has(block.tool_use_id) && !isAlreadyCleared(block.content)) {
112
+ tokensSaved += estimateToolResultTokens(block);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return tokensSaved;
119
+ }
120
+
121
+ /**
122
+ * @param {ContextMessage[]} messages
123
+ * @returns {boolean}
124
+ */
125
+ export function shouldEmitContextHintBody(messages) {
126
+ return computeContextHintTokensSaved(messages) >= CONTEXT_HINT_TOKEN_THRESHOLD;
127
+ }
@@ -7,11 +7,6 @@ export const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
7
7
  export const _EFFORT_BETA_FLAG = "effort-2025-11-24";
8
8
  export const FAST_MODE_BETA_FLAG = "fast-mode-2026-02-01";
9
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
10
  export const HOST_SDK_BETAS_BLOCKLIST = new Set([
16
11
  "fine-grained-tool-streaming-2025-05-14",
17
12
  "structured-outputs-2025-11-13",
@@ -69,24 +64,6 @@ export function supportsWebSearch(model) {
69
64
  return /claude|sonnet|opus|haiku|gpt|gemini/i.test(model);
70
65
  }
71
66
 
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
67
  export function parseRequestBodyMetadata(body, parsedBody) {
91
68
  const parsed =
92
69
  parsedBody ||
@@ -110,29 +87,75 @@ export function parseRequestBodyMetadata(body, parsedBody) {
110
87
  return { model, tools, messages, hasFileReferences };
111
88
  }
112
89
 
90
+ /**
91
+ * Walk every object that may carry a stainless helper marker: a tool object, a
92
+ * message object, and each block nested under `.content`. Shared by the header
93
+ * builder and the body stripper on purpose — what is READ to build
94
+ * `x-stainless-helper` and what is REMOVED from the body must be the exact same
95
+ * traversal, or the two would drift and a marker would leak to the wire.
96
+ *
97
+ * @param {unknown[]} tools
98
+ * @param {unknown[]} messages
99
+ * @param {(carrier: Record<string, unknown>) => void} visit
100
+ */
101
+ function walkStainlessHelperCarriers(tools, messages, visit) {
102
+ const walk = (value) => {
103
+ if (!value || typeof value !== "object") return;
104
+
105
+ visit(value);
106
+
107
+ if (Array.isArray(value.content)) {
108
+ for (const contentBlock of value.content) {
109
+ walk(contentBlock);
110
+ }
111
+ }
112
+ };
113
+
114
+ if (Array.isArray(tools)) for (const tool of tools) walk(tool);
115
+ if (Array.isArray(messages)) for (const message of messages) walk(message);
116
+ }
117
+
113
118
  export function buildStainlessHelperHeader(tools, messages) {
114
119
  const helpers = new Set();
115
120
 
116
- const collect = (value) => {
117
- if (!value || typeof value !== "object") return;
118
-
121
+ walkStainlessHelperCarriers(tools, messages, (value) => {
119
122
  for (const key of STAINLESS_HELPER_KEYS) {
120
123
  if (typeof value[key] === "string" && value[key]) {
121
124
  helpers.add(value[key]);
122
125
  }
123
126
  }
127
+ });
124
128
 
125
- if (Array.isArray(value.content)) {
126
- for (const contentBlock of value.content) {
127
- collect(contentBlock);
129
+ return Array.from(helpers).join(", ");
130
+ }
131
+
132
+ /**
133
+ * Remove the stainless helper markers from the request body, in place.
134
+ *
135
+ * The marker is an INTERNAL signal whose only purpose is to compute the
136
+ * `x-stainless-helper` header. It is not part of the Anthropic wire contract:
137
+ * the API has never known these keys, and the shared wire-compat package
138
+ * rejects the whole request with `INVALID_INPUT` when one reaches it. Call this
139
+ * AFTER `buildStainlessHelperHeader` so the header is still derived from the
140
+ * untouched body, and before the body is handed to the package or to `fetch`.
141
+ *
142
+ * @param {unknown[]} tools
143
+ * @param {unknown[]} messages
144
+ * @returns {number} how many marker keys were removed
145
+ */
146
+ export function stripStainlessHelperMarkers(tools, messages) {
147
+ let removed = 0;
148
+
149
+ walkStainlessHelperCarriers(tools, messages, (value) => {
150
+ for (const key of STAINLESS_HELPER_KEYS) {
151
+ if (key in value) {
152
+ delete value[key];
153
+ removed += 1;
128
154
  }
129
155
  }
130
- };
156
+ });
131
157
 
132
- for (const tool of tools) collect(tool);
133
- for (const message of messages) collect(message);
134
-
135
- return Array.from(helpers).join(", ");
158
+ return removed;
136
159
  }
137
160
 
138
161
  export function isEffortCapableModel(model) {
@@ -145,7 +168,6 @@ export function buildAnthropicBetaHeader(
145
168
  incomingBeta,
146
169
  signatureEnabled,
147
170
  model,
148
- provider,
149
171
  customBetas,
150
172
  strategy,
151
173
  requestPath,
@@ -177,8 +199,6 @@ export function buildAnthropicBetaHeader(
177
199
  return mergedBetas.join(",");
178
200
  }
179
201
 
180
- const nonInteractive = isNonInteractiveMode();
181
- const haiku = isHaikuModel(model);
182
202
  const isRoundRobin = strategy === "round-robin";
183
203
  const te = tokenEconomy || {};
184
204
 
@@ -228,17 +248,16 @@ export function buildAnthropicBetaHeader(
228
248
 
229
249
  // === CONDITIONAL BETAS (model/context-dependent) ===
230
250
 
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";
251
+ // The plugin is first-party OAuth only; Bedrock/Vertex/Foundry/Mantle support
252
+ // was removed, so the former isFirstPartyProvider predicate is always true and
253
+ // the gates below no longer test it.
235
254
 
236
255
  // v2.1.195: context-management-2025-06-27 is default-ON for first-party non-claude-3
237
256
  // models (incl. Haiku 4.5), mirroring CC's n0d(model) eligibility path. Earlier
238
257
  // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
239
258
  // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
240
259
  // first-party models. Opt out via token_economy.context_management = false.
241
- if (te.context_management !== false && isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model)) {
260
+ if (te.context_management !== false && !CLAUDE_3_MODEL_RE.test(model)) {
242
261
  betas.push("context-management-2025-06-27");
243
262
  }
244
263
 
@@ -246,11 +265,12 @@ export function buildAnthropicBetaHeader(
246
265
  // (Opus 4.5/4.6/4.7/4.8, Sonnet 4.6), mirroring CC's Kw(model). Excluded for
247
266
  // claude-3-*, opus-4-0, opus-4-1, sonnet-4-0, sonnet-4-5, haiku-4-5. First-party
248
267
  // only; opt out via token_economy.effort = false.
249
- if (te.effort !== false && isFirstPartyProvider && isEffortCapableModel(model)) {
268
+ if (te.effort !== false && isEffortCapableModel(model)) {
250
269
  betas.push(_EFFORT_BETA_FLAG);
251
270
  }
252
271
 
253
- // v2.1.150: structured-outputs gated behind opt-in (CC has behind tengu_tool_pear flag).
272
+ // structured-outputs is gated by the caller supplying an output format.
273
+ // `tengu_tool_pear` instead gates `tool.strict = true` on the tool-schema path.
254
274
  if (te.structured_outputs && supportsStructuredOutputs(model)) {
255
275
  betas.push("structured-outputs-2025-12-15");
256
276
  }
@@ -268,21 +288,13 @@ export function buildAnthropicBetaHeader(
268
288
  betas.push("advisor-tool-2026-03-01");
269
289
  }
270
290
 
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
- }
291
+ // context-hint-2026-04-09 is NOT emitted, on any path, under any knob.
292
+ // The profile derived from the real Claude Code 2.1.195 binary sends neither
293
+ // this beta nor its paired `context_hint` body field. Emitting either makes
294
+ // the request distinguishable from the genuine client — the exact opposite of
295
+ // what this plugin exists to do. `token_economy.context_hint` is therefore
296
+ // deprecated rather than honoured: validateConfig warns on explicit opt-in so
297
+ // the switch is not a silent no-op. See lib/config.mjs.
286
298
 
287
299
  // Files API — scoped to file endpoints/references
288
300
  if (isFilesEndpoint || hasFileReferences) {
@@ -355,11 +367,6 @@ export function buildAnthropicBetaHeader(
355
367
  mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
356
368
  }
357
369
 
358
- // Remove betas unsupported by Bedrock
359
- if (provider === "bedrock") {
360
- mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
361
- }
362
-
363
370
  return mergedBetas.join(",");
364
371
  }
365
372
 
@@ -415,7 +422,6 @@ export function buildRequestHeaders(
415
422
  // Preserve all incoming beta headers while ensuring OAuth requirements
416
423
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
417
424
  const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
418
- const provider = detectProvider(requestUrl);
419
425
  // Detect fast mode structurally from the already-transformed body string.
420
426
  // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
421
427
  // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
@@ -428,7 +434,6 @@ export function buildRequestHeaders(
428
434
  incomingBeta,
429
435
  signature.enabled,
430
436
  model,
431
- provider,
432
437
  signature.customBetas,
433
438
  signature.strategy,
434
439
  requestUrl?.pathname,
@@ -57,7 +57,7 @@ export const CORE_TOOL_NAMES = new Set([
57
57
  * Preserves behaviors E1-E7.
58
58
  *
59
59
  * @param {string | undefined} body
60
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
60
+ * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string, useAdapter?: boolean}} signature
61
61
  * @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
62
62
  * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
63
63
  * @param {import('../config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
@@ -66,8 +66,6 @@ export const CORE_TOOL_NAMES = new Set([
66
66
  export function transformRequestBody(body, signature, runtime, betaHeader, config) {
67
67
  if (!body || typeof body !== "string") return body;
68
68
 
69
- const TOOL_PREFIX = "mcp_";
70
-
71
69
  try {
72
70
  const parsed = JSON.parse(body);
73
71
  // Diagnostic: log the incoming model + thinking/speed shape so model-routing
@@ -89,17 +87,10 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
89
87
  if (config?.output_cap?.enabled) {
90
88
  parsed.max_tokens = resolveMaxTokens(parsed, config);
91
89
  }
92
- // Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
93
- // doesn't forward custom HTTP headers. First-party API rejects "betas" in body
94
- // with "Extra inputs are not permitted" — betas are header-only for first-party.
95
- if (signature.enabled && betaHeader && signature.provider === "bedrock") {
96
- const betaArray = betaHeader
97
- .split(",")
98
- .map((b) => b.trim())
99
- .filter(Boolean)
100
- .filter((b) => b !== "oauth-2025-04-20");
101
- parsed.anthropic_beta = betaArray;
102
- }
90
+ // The body-level `anthropic_beta` array existed only for Bedrock, which does
91
+ // not forward custom HTTP headers. Multi-provider support was removed, and
92
+ // the first-party API rejects betas in the body with "Extra inputs are not
93
+ // permitted" — they are header-only here.
103
94
  // Strip any incoming "betas" field — API rejects it as unknown
104
95
  if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
105
96
  delete parsed.betas;
@@ -332,7 +323,7 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
332
323
  requestRole: runtime?.requestRole,
333
324
  leanNonMain: config?.token_economy?.lean_system_non_main === true,
334
325
  // Simple-system-prompt mode for Opus 4.7+ (CC v2.1.133+ parity, gated by
335
- // GrowthBook `tengu_vellum_lantern` in real CC). Plugin gate: model
326
+ // GrowthBook `tengu_velvet_cascade` in real CC). Plugin gate: model
336
327
  // eligibility + opt-in flag. See buildSystemPromptBlocks for what it
337
328
  // strips (anti-verbosity boilerplate only; identity/billing untouched).
338
329
  simpleSystemPrompt: config?.token_economy?.simple_system_prompt === true,
@@ -340,6 +331,11 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
340
331
  // Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
341
332
  // SDK daemon callers spawning cron subprocesses). Empty string -> omit.
342
333
  workload: typeof config?.signature_emulation?.workload === "string" ? config.signature_emulation.workload : "",
334
+ // Adapter path only: suppress the canonical billing + identity blocks
335
+ // because the shared package prepends its own. Every other stage of the
336
+ // system pipeline (anti-verbosity, subagent CC-prefix, title-generator
337
+ // swap, dedupe, compaction, tailing, token-budget block) still runs.
338
+ suppressCanonicalBlocks: signature.useAdapter === true,
343
339
  };
344
340
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
345
341
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
@@ -379,7 +375,13 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
379
375
  }
380
376
  }
381
377
 
382
- if (signature.enabled) {
378
+ // Adapter path only: the shared package composes `metadata.user_id` itself
379
+ // from the runtime correlation triple, and the two env features become
380
+ // `metadataOverrides` on the transport instead (see adapter-input.mjs).
381
+ // Writing metadata here as well would collide with the package's own
382
+ // composition. CONDITIONAL, never unconditional: the legacy path still
383
+ // needs buildRequestMetadata.
384
+ if (signature.enabled && signature.useAdapter !== true) {
383
385
  const currentMetadata =
384
386
  parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
385
387
  ? parsed.metadata
@@ -587,14 +589,11 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
587
589
  }
588
590
  }
589
591
 
590
- // Context hint (CC v2.1.110+): pair the context-hint-2026-04-09 beta with
591
- // the body field context_hint:{enabled:true}. Extracted from CC bundle
592
- // buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
593
- // When active, the server responds with optional hints suggesting which
594
- // messages to drop for token-efficient context compaction.
595
- if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
596
- parsed.context_hint = { enabled: true };
597
- }
592
+ // The `context_hint` body field is never injected, on either path. The
593
+ // profile derived from the real Claude Code 2.1.195 binary does not send it
594
+ // (nor the paired context-hint-2026-04-09 beta), so emitting it would be a
595
+ // fingerprint. `token_economy.context_hint` is deprecated in lib/config.mjs,
596
+ // which warns on explicit opt-in rather than silently doing nothing.
598
597
 
599
598
  // Fast mode: inject speed parameter for fast-mode-eligible models.
600
599
  // Per Anthropic fast-mode docs, `speed: "fast"` (beta fast-mode-2026-02-01) is
@@ -6,6 +6,8 @@ const BILLING_HASH_SALT = "59cf53e54c78";
6
6
  const BILLING_HASH_INDICES = [4, 7, 20];
7
7
 
8
8
  export const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
9
+ /** Marker that identifies the canonical billing block inside a system array. */
10
+ export const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
9
11
  const KNOWN_IDENTITY_STRINGS = new Set([
10
12
  CLAUDE_CODE_IDENTITY_STRING,
11
13
  "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
@@ -25,11 +27,12 @@ const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
25
27
 
26
28
  /**
27
29
  * Anti-verbosity system prompt text.
28
- * Extracted from CC v2.1.100 (gated on quiet_salted_ember A/B test for Opus 4.6).
30
+ * Text verified against the CC v2.1.195 binary; the heading corrects a stale
31
+ * v2.1.100 value.
29
32
  * Significantly reduces output token count by instructing the model to be concise.
30
33
  */
31
34
  const ANTI_VERBOSITY_SYSTEM_PROMPT = [
32
- "# Communication style",
35
+ "# Text output (does not apply to tool calls)",
33
36
  "Assume users can't see most tool calls or thinking — only your text output. Before your first tool call, state in one sentence what you're about to do. While working, give short updates at key moments: when you find something, when you change direction, or when you hit a blocker. Brief is good — silent is not. One sentence per update is almost always enough.",
34
37
  "",
35
38
  "Don't narrate your internal deliberation. User-facing text should be relevant communication to the user, not a running commentary on your thought process. State results and decisions directly, and focus user-facing text on relevant updates for the user.",
@@ -43,13 +46,6 @@ const ANTI_VERBOSITY_SYSTEM_PROMPT = [
43
46
  "In code: default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max. Don't create planning, decision, or analysis documents unless the user asks for them — work from conversation context, not intermediate files.",
44
47
  ].join("\n");
45
48
 
46
- /**
47
- * Numeric length anchors text.
48
- * Extracted from CC v2.1.100. Hard word-count limits for output.
49
- */
50
- const NUMERIC_LENGTH_ANCHORS_PROMPT =
51
- "Length limits: keep text between tool calls to ≤25 words. Keep final responses to ≤100 words unless the task requires more detail.";
52
-
53
49
  // Max system prompt length that passes CC billing validation.
54
50
  // The server pattern-matches the system prompt against the real CC prompt.
55
51
  // Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
@@ -98,7 +94,7 @@ export function computeBillingCacheHash(firstUserMessage, version) {
98
94
 
99
95
  /**
100
96
  * Models eligible for the "simple system prompt" mode that real CC ships in
101
- * v2.1.133+ under GrowthBook flag `tengu_vellum_lantern` (or forced via
97
+ * v2.1.133+ under GrowthBook flag `tengu_velvet_cascade` (or forced via
102
98
  * `CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT=1`).
103
99
  *
104
100
  * Real CC's `sR9` matches: `claude-opus-4-7`, any model with `-eap` (early
@@ -112,7 +108,7 @@ export function isSimpleSystemPromptEligible(model) {
112
108
  if (!model) return false;
113
109
  if (isOpus47Model(model) || isOpus48Model(model)) return true;
114
110
  // -eap suffix variant, e.g. "claude-opus-4-7-eap" or "claude-opus-4-7-eap[1m]"
115
- if (/-eap(?:\[|$|-)/i.test(model)) return true;
111
+ if (/-eap($|\[)/i.test(model)) return true;
116
112
  return false;
117
113
  }
118
114
 
@@ -126,12 +122,11 @@ export function isSimpleSystemPromptEligible(model) {
126
122
  *
127
123
  * @param {string} version - CLI version (e.g., "2.1.97")
128
124
  * @param {string} [firstUserMessage] - First user message text for fingerprint computation
129
- * @param {string} [provider] - API provider ("anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle")
130
125
  * @param {string} [workloadOverride] - Explicit workload tag from config (`signature_emulation.workload`).
131
126
  * Takes precedence over `CLAUDE_CODE_WORKLOAD` env var. Mirrors real CC's `--workload <tag>` flag.
132
127
  * @returns {string}
133
128
  */
134
- export function buildAnthropicBillingHeader(version, firstUserMessage, provider, workloadOverride) {
129
+ export function buildAnthropicBillingHeader(version, firstUserMessage, workloadOverride) {
135
130
  if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
136
131
  // Real CC sends cc_entrypoint=cli (confirmed via proxy capture).
137
132
  const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
@@ -144,15 +139,15 @@ export function buildAnthropicBillingHeader(version, firstUserMessage, provider,
144
139
  const ccVersion = `${version}.${fingerprint}`;
145
140
  // cch: v2.1.97 sends static "cch=00000" — xxHash64 attestation was removed.
146
141
  // The server uses the PRESENCE of cch=00000 as a CC identification signal.
147
- const cchDisabled = provider === "bedrock" || provider === "anthropicAws" || provider === "mantle";
148
- const cchPart = cchDisabled ? "" : " cch=00000;";
142
+ // The plugin is first-party only, so cch is always emitted; the former
143
+ // bedrock/anthropicAws/mantle suppression went away with multi-provider support.
144
+ const cchPart = " cch=00000;";
149
145
  // Build workload part (upstream concatenates directly, no regex replace).
150
146
  // Config wins over env so `signature_emulation.workload` is portable across hosts.
151
147
  let workloadPart = "";
152
148
  const workload = workloadOverride || process.env.CLAUDE_CODE_WORKLOAD;
153
- if (workload && !cchDisabled) {
149
+ if (workload) {
154
150
  // QA fix M5: sanitize workload value to prevent header injection.
155
- // Real CC skips cc_workload on bedrock/anthropicAws/mantle (same gate as cch).
156
151
  const safeWorkload = String(workload).replace(/[;\s\r\n]/g, "_");
157
152
  if (safeWorkload) workloadPart = ` cc_workload=${safeWorkload};`;
158
153
  }
@@ -550,7 +545,7 @@ export function buildSystemPromptBlocks(system, signature) {
550
545
  // Anti-verbosity injection (CC v2.1.100 quiet_salted_ember equivalent).
551
546
  // Applies to Opus 4.6 / 4.7 for non-title-generator requests.
552
547
  //
553
- // Simple-system-prompt gate (CC v2.1.133+ `tengu_vellum_lantern` equivalent):
548
+ // Simple-system-prompt gate (CC v2.1.133+ `tengu_velvet_cascade` equivalent):
554
549
  // when `signature.simpleSystemPrompt` is true AND the model is eligible
555
550
  // (Opus 4.7, -eap variants), skip the anti-verbosity boilerplate. Real CC's
556
551
  // simple-prompt mode strips this kind of post-hoc behavioral hand-holding on
@@ -570,9 +565,6 @@ export function buildSystemPromptBlocks(system, signature) {
570
565
  if (avConfig?.enabled !== false) {
571
566
  sanitized.push({ type: "text", text: ANTI_VERBOSITY_SYSTEM_PROMPT });
572
567
  }
573
- if (avConfig?.length_anchors !== false) {
574
- sanitized.push({ type: "text", text: NUMERIC_LENGTH_ANCHORS_PROMPT });
575
- }
576
568
  }
577
569
 
578
570
  if (!signature.enabled) {
@@ -598,7 +590,6 @@ export function buildSystemPromptBlocks(system, signature) {
598
590
  const billingHeader = buildAnthropicBillingHeader(
599
591
  signature.claudeCliVersion,
600
592
  signature.firstUserMessage,
601
- signature.provider,
602
593
  signature.workload,
603
594
  );
604
595
 
@@ -617,9 +608,21 @@ export function buildSystemPromptBlocks(system, signature) {
617
608
  // Run the real CC splitSysPromptPrefix algorithm to get blocks with cacheScope
618
609
  const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
619
610
 
611
+ // Adapter path only: the shared package prepends its OWN billing block
612
+ // (index 0) and identity block (index 1), each with its own cache_control
613
+ // marker. Emitting ours too would duplicate both blocks on the wire.
614
+ //
615
+ // This is CONDITIONAL, never unconditional: the legacy path (any request the
616
+ // signature-emulation adapter declines — non-Anthropic provider, or emulation
617
+ // off) still builds the request through buildRequestHeaders and depends on
618
+ // these two blocks being present. Dropping them for every caller would break
619
+ // that path silently — no error, just a wrong request on the wire.
620
+ const emittedBlocks =
621
+ signature.suppressCanonicalBlocks === true ? dropCanonicalPrefixBlocks(scopedBlocks, identityString) : scopedBlocks;
622
+
620
623
  // Convert scoped blocks to wire format using getCacheControlForScope
621
624
  // (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
622
- return scopedBlocks.map((block) => {
625
+ return emittedBlocks.map((block) => {
623
626
  const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
624
627
  return {
625
628
  type: "text",
@@ -629,6 +632,34 @@ export function buildSystemPromptBlocks(system, signature) {
629
632
  });
630
633
  }
631
634
 
635
+ /**
636
+ * Drop the two canonical prefix blocks the shared package prepends itself.
637
+ *
638
+ * Both are matched with the SAME predicates `splitSysPromptPrefix` uses to
639
+ * recognise them on input, and only the FIRST occurrence of each is removed, so
640
+ * a caller-supplied block that happens to repeat the identity string survives.
641
+ *
642
+ * @param {{text: string, cacheScope: string | null}[]} blocks
643
+ * @param {string} identityString
644
+ * @returns {{text: string, cacheScope: string | null}[]}
645
+ */
646
+ function dropCanonicalPrefixBlocks(blocks, identityString) {
647
+ let billingDropped = false;
648
+ let identityDropped = false;
649
+ return blocks.filter((block) => {
650
+ const text = typeof block.text === "string" ? block.text : "";
651
+ if (!billingDropped && text.startsWith(BILLING_HEADER_PREFIX)) {
652
+ billingDropped = true;
653
+ return false;
654
+ }
655
+ if (!identityDropped && text === identityString) {
656
+ identityDropped = true;
657
+ return false;
658
+ }
659
+ return true;
660
+ });
661
+ }
662
+
632
663
  export function getCachedCCPrompt() {
633
664
  return cachedCCPrompt;
634
665
  }