opencode-anthropic-fix 0.2.1 → 0.3.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 (39) hide show
  1. package/README.md +24 -0
  2. package/index.mjs +155 -149
  3. package/lib/config.mjs +47 -25
  4. package/lib/mimicry/adapter-input.mjs +671 -0
  5. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  6. package/lib/mimicry/headers.mjs +71 -64
  7. package/lib/mimicry/request-body.mjs +23 -22
  8. package/lib/mimicry/system-prompt.mjs +54 -23
  9. package/lib/mimicry/wire-compat.mjs +305 -0
  10. package/package.json +3 -3
  11. package/lib/account-state.test.mjs +0 -109
  12. package/lib/accounts.test.mjs +0 -912
  13. package/lib/adaptive-thinking-models.test.mjs +0 -30
  14. package/lib/backoff.test.mjs +0 -411
  15. package/lib/cc-credentials.test.mjs +0 -391
  16. package/lib/config.test.mjs +0 -764
  17. package/lib/context-hint-persist.mjs +0 -68
  18. package/lib/diagnose.test.mjs +0 -122
  19. package/lib/env.test.mjs +0 -25
  20. package/lib/haiku-call.test.mjs +0 -72
  21. package/lib/message-transform.test.mjs +0 -173
  22. package/lib/mimicry/cache.test.mjs +0 -112
  23. package/lib/mimicry/headers.test.mjs +0 -50
  24. package/lib/mimicry/models.test.mjs +0 -132
  25. package/lib/mimicry/request-body.test.mjs +0 -34
  26. package/lib/mimicry/request-helpers.test.mjs +0 -155
  27. package/lib/mimicry/response-stream.test.mjs +0 -127
  28. package/lib/mimicry/system-prompt.test.mjs +0 -73
  29. package/lib/oauth.test.mjs +0 -343
  30. package/lib/redact.test.mjs +0 -154
  31. package/lib/refresh-lock.test.mjs +0 -131
  32. package/lib/request-headers.test.mjs +0 -164
  33. package/lib/retry/overload-loop.test.mjs +0 -107
  34. package/lib/rotation.test.mjs +0 -392
  35. package/lib/session-metrics.test.mjs +0 -67
  36. package/lib/storage.test.mjs +0 -696
  37. package/lib/token-economy/microcompact.test.mjs +0 -28
  38. package/lib/token-economy/transforms.test.mjs +0 -192
  39. 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,
@@ -226,17 +248,16 @@ export function buildAnthropicBetaHeader(
226
248
 
227
249
  // === CONDITIONAL BETAS (model/context-dependent) ===
228
250
 
229
- // First-party provider predicate (OAuth path). Mirrors CC's ZO(provider) which
230
- // excludes bedrock/vertex/mantle. context-management and effort are first-party
231
- // only here, so they must never be pushed on bedrock/vertex.
232
- 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.
233
254
 
234
255
  // v2.1.195: context-management-2025-06-27 is default-ON for first-party non-claude-3
235
256
  // models (incl. Haiku 4.5), mirroring CC's n0d(model) eligibility path. Earlier
236
257
  // analyses read only the separate USE_API_CONTEXT_MANAGEMENT env term (hardcoded
237
258
  // && false) and recorded it as off; 2.1.195 confirms it ships by default for modern
238
259
  // first-party models. Opt out via token_economy.context_management = false.
239
- if (te.context_management !== false && isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model)) {
260
+ if (te.context_management !== false && !CLAUDE_3_MODEL_RE.test(model)) {
240
261
  betas.push("context-management-2025-06-27");
241
262
  }
242
263
 
@@ -244,11 +265,12 @@ export function buildAnthropicBetaHeader(
244
265
  // (Opus 4.5/4.6/4.7/4.8, Sonnet 4.6), mirroring CC's Kw(model). Excluded for
245
266
  // claude-3-*, opus-4-0, opus-4-1, sonnet-4-0, sonnet-4-5, haiku-4-5. First-party
246
267
  // only; opt out via token_economy.effort = false.
247
- if (te.effort !== false && isFirstPartyProvider && isEffortCapableModel(model)) {
268
+ if (te.effort !== false && isEffortCapableModel(model)) {
248
269
  betas.push(_EFFORT_BETA_FLAG);
249
270
  }
250
271
 
251
- // 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.
252
274
  if (te.structured_outputs && supportsStructuredOutputs(model)) {
253
275
  betas.push("structured-outputs-2025-12-15");
254
276
  }
@@ -266,21 +288,13 @@ export function buildAnthropicBetaHeader(
266
288
  betas.push("advisor-tool-2026-03-01");
267
289
  }
268
290
 
269
- // context-hint-2026-04-09 — introduced in CC v2.1.110. Paired with the body
270
- // field `context_hint: { enabled: true }` (injected in transformRequestBody).
271
- // Real CC gates it on includeFirstPartyBetas + querySource startsWith
272
- // "repl_main_thread" (i.e. interactive sessions on 1P Anthropic only).
273
- // Server responds with hints suggesting which messages to forget for
274
- // token-efficient context compaction. Sticky: disabled permanently on
275
- // 400/409/529 errors referencing the hint. Users can opt out via
276
- // token_economy.context_hint = false.
277
- // Mimicry note: real CC gates context-hint on querySource.startsWith("repl_main_thread").
278
- // We infer main-thread from body shape via classifyRequestRole and pass it via
279
- // tokenEconomy.__requestRole. Treat absent marker as "main" for backward compat.
280
- const _isMainThread = te.__requestRole == null || te.__requestRole === "main";
281
- if (isFirstPartyProvider && !CLAUDE_3_MODEL_RE.test(model) && te.context_hint !== false && _isMainThread) {
282
- betas.push("context-hint-2026-04-09");
283
- }
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.
284
298
 
285
299
  // Files API — scoped to file endpoints/references
286
300
  if (isFilesEndpoint || hasFileReferences) {
@@ -353,11 +367,6 @@ export function buildAnthropicBetaHeader(
353
367
  mergedBetas = mergedBetas.filter((beta) => !EXPERIMENTAL_BETA_FLAGS.has(beta));
354
368
  }
355
369
 
356
- // Remove betas unsupported by Bedrock
357
- if (provider === "bedrock") {
358
- mergedBetas = mergedBetas.filter((beta) => !BEDROCK_UNSUPPORTED_BETAS.has(beta));
359
- }
360
-
361
370
  return mergedBetas.join(",");
362
371
  }
363
372
 
@@ -413,7 +422,6 @@ export function buildRequestHeaders(
413
422
  // Preserve all incoming beta headers while ensuring OAuth requirements
414
423
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
415
424
  const { model, tools, messages, hasFileReferences } = parseRequestBodyMetadata(requestBody);
416
- const provider = detectProvider(requestUrl);
417
425
  // Detect fast mode structurally from the already-transformed body string.
418
426
  // transformRequestBody runs before buildRequestHeaders (see index.mjs:2986 vs 3039),
419
427
  // so speed:"fast" is already present when fast mode fired. Same detection idiom as the
@@ -426,7 +434,6 @@ export function buildRequestHeaders(
426
434
  incomingBeta,
427
435
  signature.enabled,
428
436
  model,
429
- provider,
430
437
  signature.customBetas,
431
438
  signature.strategy,
432
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
@@ -87,17 +87,10 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
87
87
  if (config?.output_cap?.enabled) {
88
88
  parsed.max_tokens = resolveMaxTokens(parsed, config);
89
89
  }
90
- // Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
91
- // doesn't forward custom HTTP headers. First-party API rejects "betas" in body
92
- // with "Extra inputs are not permitted" — betas are header-only for first-party.
93
- if (signature.enabled && betaHeader && signature.provider === "bedrock") {
94
- const betaArray = betaHeader
95
- .split(",")
96
- .map((b) => b.trim())
97
- .filter(Boolean)
98
- .filter((b) => b !== "oauth-2025-04-20");
99
- parsed.anthropic_beta = betaArray;
100
- }
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.
101
94
  // Strip any incoming "betas" field — API rejects it as unknown
102
95
  if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
103
96
  delete parsed.betas;
@@ -330,7 +323,7 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
330
323
  requestRole: runtime?.requestRole,
331
324
  leanNonMain: config?.token_economy?.lean_system_non_main === true,
332
325
  // Simple-system-prompt mode for Opus 4.7+ (CC v2.1.133+ parity, gated by
333
- // GrowthBook `tengu_vellum_lantern` in real CC). Plugin gate: model
326
+ // GrowthBook `tengu_velvet_cascade` in real CC). Plugin gate: model
334
327
  // eligibility + opt-in flag. See buildSystemPromptBlocks for what it
335
328
  // strips (anti-verbosity boilerplate only; identity/billing untouched).
336
329
  simpleSystemPrompt: config?.token_economy?.simple_system_prompt === true,
@@ -338,6 +331,11 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
338
331
  // Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
339
332
  // SDK daemon callers spawning cron subprocesses). Empty string -> omit.
340
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,
341
339
  };
342
340
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
343
341
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
@@ -377,7 +375,13 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
377
375
  }
378
376
  }
379
377
 
380
- 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) {
381
385
  const currentMetadata =
382
386
  parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
383
387
  ? parsed.metadata
@@ -585,14 +589,11 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
585
589
  }
586
590
  }
587
591
 
588
- // Context hint (CC v2.1.110+): pair the context-hint-2026-04-09 beta with
589
- // the body field context_hint:{enabled:true}. Extracted from CC bundle
590
- // buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
591
- // When active, the server responds with optional hints suggesting which
592
- // messages to drop for token-efficient context compaction.
593
- if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
594
- parsed.context_hint = { enabled: true };
595
- }
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.
596
597
 
597
598
  // Fast mode: inject speed parameter for fast-mode-eligible models.
598
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
  }