opencode-anthropic-fix 0.6.0 → 2.0.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 +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
|
@@ -21,12 +21,11 @@ import {
|
|
|
21
21
|
import { resolveCacheTtl, shouldPlaceToolBreakpoint } from "./cache.mjs";
|
|
22
22
|
import {
|
|
23
23
|
isAdaptiveThinkingModel,
|
|
24
|
+
isClaude3Model,
|
|
24
25
|
isOpus46Model,
|
|
25
26
|
isOpus47Model,
|
|
26
27
|
isOpus48Model,
|
|
27
|
-
|
|
28
|
-
CLAUDE_3_MODEL_RE,
|
|
29
|
-
} from "./models.mjs";
|
|
28
|
+
} from "./wire-compat.mjs";
|
|
30
29
|
import {
|
|
31
30
|
resolveMaxTokens,
|
|
32
31
|
extractFirstUserMessageText,
|
|
@@ -34,9 +33,45 @@ import {
|
|
|
34
33
|
stripSlashCommandMessages,
|
|
35
34
|
repairOrphanedToolUseBlocks,
|
|
36
35
|
} from "./request-helpers.mjs";
|
|
37
|
-
import { isFalsyEnv } from "../env.mjs";
|
|
36
|
+
import { isFalsyEnv, isTruthyEnv } from "../env.mjs";
|
|
38
37
|
import { sessionMetrics } from "../session-metrics.mjs";
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Coerce an incoming `thinking` block to the shape the model actually accepts.
|
|
41
|
+
*
|
|
42
|
+
* Lives here rather than in a model module because it is a BODY transform, not
|
|
43
|
+
* a model query: the only model knowledge it needs is `isAdaptiveThinkingModel`,
|
|
44
|
+
* which now comes from the wire-compat seam. This module is its sole caller.
|
|
45
|
+
*
|
|
46
|
+
* @param {any} thinking
|
|
47
|
+
* @param {string} model
|
|
48
|
+
* @returns {any}
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeThinkingBlock(thinking, model) {
|
|
51
|
+
// If thinking is absent or not an object, pass through
|
|
52
|
+
if (!thinking || typeof thinking !== "object") {
|
|
53
|
+
return thinking;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Adaptive thinking models always get { type: "adaptive" }
|
|
57
|
+
// regardless of what format the incoming thinking block has
|
|
58
|
+
if (isAdaptiveThinkingModel(model)) {
|
|
59
|
+
// Check for env-var override to force budget_tokens fallback
|
|
60
|
+
if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
|
|
61
|
+
// Fallback: return as-is if already budget_tokens shape, otherwise default
|
|
62
|
+
if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
|
|
63
|
+
return thinking;
|
|
64
|
+
}
|
|
65
|
+
const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
|
|
66
|
+
return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
|
|
67
|
+
}
|
|
68
|
+
return { type: "adaptive" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Non-adaptive models: pass through unchanged
|
|
72
|
+
return thinking;
|
|
73
|
+
}
|
|
74
|
+
|
|
40
75
|
// Core tool names (CC PascalCase) that are always eager-loaded.
|
|
41
76
|
export const CORE_TOOL_NAMES = new Set([
|
|
42
77
|
"Bash",
|
|
@@ -59,11 +94,14 @@ export const CORE_TOOL_NAMES = new Set([
|
|
|
59
94
|
* @param {string | undefined} body
|
|
60
95
|
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string, useAdapter?: boolean}} signature
|
|
61
96
|
* @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
|
|
62
|
-
* @param {
|
|
97
|
+
* @param {boolean} [taskBudgetsActive] - Whether `task-budgets-2026-03-13` is active for this
|
|
98
|
+
* request. Derived by the caller from the same gates the adapter applies to a custom beta
|
|
99
|
+
* (signature emulation, shortcut resolution, `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`); this
|
|
100
|
+
* used to be the full pre-computed `anthropic-beta` header string, substring-tested here.
|
|
63
101
|
* @param {import('../config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
|
|
64
102
|
* @returns {string | undefined}
|
|
65
103
|
*/
|
|
66
|
-
export function transformRequestBody(body, signature, runtime,
|
|
104
|
+
export function transformRequestBody(body, signature, runtime, taskBudgetsActive, config) {
|
|
67
105
|
if (!body || typeof body !== "string") return body;
|
|
68
106
|
|
|
69
107
|
try {
|
|
@@ -157,7 +195,7 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
|
|
|
157
195
|
// ⊆ beta invariant, so the 400 never fires.
|
|
158
196
|
if (
|
|
159
197
|
config?.token_economy?.context_management &&
|
|
160
|
-
!
|
|
198
|
+
!isClaude3Model(parsed.model || "") &&
|
|
161
199
|
!parsed.context_management
|
|
162
200
|
) {
|
|
163
201
|
parsed.context_management = {
|
|
@@ -331,13 +369,10 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
|
|
|
331
369
|
// Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
|
|
332
370
|
// SDK daemon callers spawning cron subprocesses). Empty string -> omit.
|
|
333
371
|
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,
|
|
339
372
|
};
|
|
340
|
-
//
|
|
373
|
+
// Host system-prompt policy: sanitize, compact, dedupe, anti-verbosity, and
|
|
374
|
+
// the prompt-cache breakpoint. The canonical billing + identity prefix is
|
|
375
|
+
// NOT built here — the shared package composes it (see system-prompt.mjs).
|
|
341
376
|
parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
|
|
342
377
|
|
|
343
378
|
// Strategy 5 — System prompt tailing: after N turns, trim large system blocks
|
|
@@ -581,7 +616,9 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
|
|
|
581
616
|
// Task budgets: when the task-budgets beta is active, preserve or inject output_config.
|
|
582
617
|
// The beta unlocks output_config.max_output_tokens for per-task budget control.
|
|
583
618
|
// Model-router compatibility: the beta header + output_config body are forwarded as-is.
|
|
584
|
-
|
|
619
|
+
// The signal is a boolean derived by the caller — this used to substring-test a
|
|
620
|
+
// pre-computed `anthropic-beta` header string built solely for this line.
|
|
621
|
+
if (taskBudgetsActive) {
|
|
585
622
|
if (!parsed.output_config) {
|
|
586
623
|
// Default: set a reasonable per-task output budget for long-running agentic tasks.
|
|
587
624
|
// Claude Code tasks typically need generous output budgets.
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { isOpus46Model, isOpus47Model, isOpus48Model } from "./models.mjs";
|
|
4
|
-
|
|
5
|
-
const BILLING_HASH_SALT = "59cf53e54c78";
|
|
6
|
-
const BILLING_HASH_INDICES = [4, 7, 20];
|
|
1
|
+
import { isTruthyEnv } from "../env.mjs";
|
|
2
|
+
import { isOpus46Model, isOpus47Model, isOpus48Model } from "./wire-compat.mjs";
|
|
7
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The canonical Claude Code identity text.
|
|
6
|
+
*
|
|
7
|
+
* The PLUGIN NO LONGER EMITS IT. `@tormentalabs/claude-code-wire-compat`
|
|
8
|
+
* composes the canonical prefix (billing block + identity block) itself and
|
|
9
|
+
* drops any caller block byte-equal to the identity text, so the prefix lands
|
|
10
|
+
* on the wire exactly once. The constant survives here as a host-policy probe:
|
|
11
|
+
* `compactSystemText` strips a duplicated identity prefix a host prompt may
|
|
12
|
+
* carry, before the array ever reaches the package.
|
|
13
|
+
*/
|
|
8
14
|
export const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
9
|
-
/** Marker that identifies
|
|
15
|
+
/** Marker that identifies a canonical billing block inside a system array. */
|
|
10
16
|
export const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
17
|
+
/**
|
|
18
|
+
* Every canonical identity text the wire recognises.
|
|
19
|
+
*
|
|
20
|
+
* The package drops a caller block byte-equal to its own `IDENTITY_TEXT`, but
|
|
21
|
+
* only that one, and only when the block stands alone. The host pipeline joins
|
|
22
|
+
* blocks before the package sees them, so a host-supplied identity block has to
|
|
23
|
+
* be dropped HERE or it would end up embedded mid-string in the joined run and
|
|
24
|
+
* put the canonical prefix on the wire twice.
|
|
25
|
+
*/
|
|
11
26
|
const KNOWN_IDENTITY_STRINGS = new Set([
|
|
12
27
|
CLAUDE_CODE_IDENTITY_STRING,
|
|
13
28
|
"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
|
|
@@ -46,29 +61,6 @@ const ANTI_VERBOSITY_SYSTEM_PROMPT = [
|
|
|
46
61
|
"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.",
|
|
47
62
|
].join("\n");
|
|
48
63
|
|
|
49
|
-
// Max system prompt length that passes CC billing validation.
|
|
50
|
-
// The server pattern-matches the system prompt against the real CC prompt.
|
|
51
|
-
// Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
|
|
52
|
-
const MAX_SAFE_SYSTEM_TEXT_LENGTH = 5000;
|
|
53
|
-
|
|
54
|
-
// A5: Subagent CC-prefix cache.
|
|
55
|
-
//
|
|
56
|
-
// Context: opencode/packages/opencode/src/session/llm.ts:110 uses
|
|
57
|
-
// `input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(model)`
|
|
58
|
-
// so any agent with a custom prompt (explore, fast, title, summary, etc.)
|
|
59
|
-
// fires WITHOUT the base CC prompt — the server-side fingerprint match fails
|
|
60
|
-
// and the request is billed as pay-as-you-go credits instead of Max-plan usage.
|
|
61
|
-
//
|
|
62
|
-
// Fix: on the first main-agent call (where the anchor is present), cache the
|
|
63
|
-
// sanitized CC prefix. On subsequent subagent calls (anchor missing), prepend
|
|
64
|
-
// the cached prefix to the sanitized blocks so the fingerprint matches again.
|
|
65
|
-
//
|
|
66
|
-
// The cache lives at module scope because buildSystemPromptBlocks is re-entered
|
|
67
|
-
// per request. It gets populated exactly once per process on the first main call.
|
|
68
|
-
const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
|
|
69
|
-
export const SUBAGENT_CC_ANCHOR = "You are an interactive";
|
|
70
|
-
let cachedCCPrompt = null;
|
|
71
|
-
|
|
72
64
|
// Perf: module-scope regexes reused across per-request hot paths. None use the
|
|
73
65
|
// `/g` flag and all are consumed via `.test()`, so a single shared instance is
|
|
74
66
|
// stateless and safe (no `lastIndex` to reset between calls).
|
|
@@ -79,19 +71,6 @@ const TAIL_LIST_ITEM_RE = /^\s*[-*]\s/;
|
|
|
79
71
|
const OPENCODE_ENV_CONTEXT_PREFIX = "Here is some useful information about the environment you are running in:";
|
|
80
72
|
const CC_ENV_CONTEXT_PREFIX = "Here is useful information about the environment you are running in:";
|
|
81
73
|
|
|
82
|
-
/**
|
|
83
|
-
* Compute the billing cache hash (cch) matching Claude Code's NP1() function.
|
|
84
|
-
* SHA256(salt + chars_at_indices[4,7,20]_from_first_user_msg + version).slice(0,3)
|
|
85
|
-
* @param {string} firstUserMessage
|
|
86
|
-
* @param {string} version
|
|
87
|
-
* @returns {string}
|
|
88
|
-
*/
|
|
89
|
-
export function computeBillingCacheHash(firstUserMessage, version) {
|
|
90
|
-
const chars = BILLING_HASH_INDICES.map((i) => firstUserMessage[i] || "0").join("");
|
|
91
|
-
const input = `${BILLING_HASH_SALT}${chars}${version}`;
|
|
92
|
-
return createHashCrypto("sha256").update(input).digest("hex").slice(0, 3);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
74
|
/**
|
|
96
75
|
* Models eligible for the "simple system prompt" mode that real CC ships in
|
|
97
76
|
* v2.1.133+ under GrowthBook flag `tengu_velvet_cascade` (or forced via
|
|
@@ -112,48 +91,6 @@ export function isSimpleSystemPromptEligible(model) {
|
|
|
112
91
|
return false;
|
|
113
92
|
}
|
|
114
93
|
|
|
115
|
-
/**
|
|
116
|
-
* Build the billing header block for Claude Code system prompt injection.
|
|
117
|
-
* Claude Code v2.1.97: cc_version includes 3-char fingerprint hash (not model ID).
|
|
118
|
-
* cch is a static "00000" placeholder (xxHash64 attestation removed in v2.1.97).
|
|
119
|
-
*
|
|
120
|
-
* Real CC (system.ts:78): version = `${MACRO.VERSION}.${fingerprint}`
|
|
121
|
-
* Real CC (system.ts:82): cch = ' cch=00000;' (static, no longer computed)
|
|
122
|
-
*
|
|
123
|
-
* @param {string} version - CLI version (e.g., "2.1.97")
|
|
124
|
-
* @param {string} [firstUserMessage] - First user message text for fingerprint computation
|
|
125
|
-
* @param {string} [workloadOverride] - Explicit workload tag from config (`signature_emulation.workload`).
|
|
126
|
-
* Takes precedence over `CLAUDE_CODE_WORKLOAD` env var. Mirrors real CC's `--workload <tag>` flag.
|
|
127
|
-
* @returns {string}
|
|
128
|
-
*/
|
|
129
|
-
export function buildAnthropicBillingHeader(version, firstUserMessage, workloadOverride) {
|
|
130
|
-
if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
|
|
131
|
-
// Real CC sends cc_entrypoint=cli (confirmed via proxy capture).
|
|
132
|
-
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
|
|
133
|
-
// Fix #1: cc_version suffix is the 3-char fingerprint hash, NOT the model ID.
|
|
134
|
-
// computeBillingCacheHash() computes SHA256(salt + msg[4]+msg[7]+msg[20] + version)[:3]
|
|
135
|
-
// which matches computeFingerprint() in the real CC source (utils/fingerprint.ts).
|
|
136
|
-
// Always call the hash function — even for empty messages the real CC computes
|
|
137
|
-
// the hash from "000" chars (indices 4,7,20 all missing → fallback "0").
|
|
138
|
-
const fingerprint = computeBillingCacheHash(firstUserMessage || "", version);
|
|
139
|
-
const ccVersion = `${version}.${fingerprint}`;
|
|
140
|
-
// cch: v2.1.97 sends static "cch=00000" — xxHash64 attestation was removed.
|
|
141
|
-
// The server uses the PRESENCE of cch=00000 as a CC identification signal.
|
|
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;";
|
|
145
|
-
// Build workload part (upstream concatenates directly, no regex replace).
|
|
146
|
-
// Config wins over env so `signature_emulation.workload` is portable across hosts.
|
|
147
|
-
let workloadPart = "";
|
|
148
|
-
const workload = workloadOverride || process.env.CLAUDE_CODE_WORKLOAD;
|
|
149
|
-
if (workload) {
|
|
150
|
-
// QA fix M5: sanitize workload value to prevent header injection.
|
|
151
|
-
const safeWorkload = String(workload).replace(/[;\s\r\n]/g, "_");
|
|
152
|
-
if (safeWorkload) workloadPart = ` cc_workload=${safeWorkload};`;
|
|
153
|
-
}
|
|
154
|
-
return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=${entrypoint};${cchPart}${workloadPart}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
94
|
export function sanitizeSystemText(text) {
|
|
158
95
|
// QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
|
|
159
96
|
let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
|
|
@@ -348,36 +285,14 @@ export function normalizeSystemTextBlocks(system) {
|
|
|
348
285
|
return output;
|
|
349
286
|
}
|
|
350
287
|
|
|
351
|
-
/**
|
|
352
|
-
* Determine the identity string prefix, matching real CC's getCLISyspromptPrefix().
|
|
353
|
-
* Real CC selects based on isNonInteractive + hasAppendSystemPrompt flags.
|
|
354
|
-
* OpenCode is always interactive CLI, so DEFAULT_PREFIX is almost always correct.
|
|
355
|
-
* We check for Agent SDK signals from the environment to match non-interactive cases.
|
|
356
|
-
*
|
|
357
|
-
* @returns {string}
|
|
358
|
-
*/
|
|
359
|
-
export function getCLISyspromptPrefix() {
|
|
360
|
-
// Agent SDK preset: when running within the Claude Agent SDK with CC preset
|
|
361
|
-
if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
362
|
-
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "";
|
|
363
|
-
if (entrypoint === "agent-sdk" || entrypoint === "sdk") {
|
|
364
|
-
return "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.";
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
// Non-interactive agent without CC preset
|
|
368
|
-
if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && !isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
369
|
-
return "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
370
|
-
}
|
|
371
|
-
return CLAUDE_CODE_IDENTITY_STRING;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
288
|
/**
|
|
375
289
|
* Compute the cache_control object for a given cache scope and policy.
|
|
376
|
-
* Mirrors real CC getCacheControl() (src/services/api/claude.ts:358-374).
|
|
377
290
|
*
|
|
378
|
-
*
|
|
379
|
-
* -
|
|
380
|
-
*
|
|
291
|
+
* HOST cache policy: the ttl is the one `request-body.mjs` resolved for this
|
|
292
|
+
* request (role-scoped / subagent-aware), not a fixed CC value.
|
|
293
|
+
*
|
|
294
|
+
* - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: <resolved>}
|
|
295
|
+
* - scope 'org' → {type: 'ephemeral', ttl?: <resolved>} (org is internal, NOT on wire)
|
|
381
296
|
* - scope null → block gets NO cache_control at all (caller should omit it)
|
|
382
297
|
*
|
|
383
298
|
* @param {'global' | 'org' | null} cacheScope
|
|
@@ -396,66 +311,42 @@ export function getCacheControlForScope(cacheScope, cachePolicy) {
|
|
|
396
311
|
}
|
|
397
312
|
|
|
398
313
|
/**
|
|
399
|
-
*
|
|
400
|
-
* matching real CC splitSysPromptPrefix() (src/utils/api.ts:321-435).
|
|
314
|
+
* Place the host prompt-cache breakpoints over the sanitized system blocks.
|
|
401
315
|
*
|
|
402
|
-
*
|
|
316
|
+
* This is the cache half of what used to be `splitSysPromptPrefix`. The prefix
|
|
317
|
+
* half (billing block + identity block) is gone: the shared package composes
|
|
318
|
+
* the canonical prefix itself. What remains is host policy the package cannot
|
|
319
|
+
* infer — WHERE the cache breakpoint goes and which ttl/scope it carries.
|
|
403
320
|
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* - rest (joined) → cacheScope: 'org'
|
|
408
|
-
* Wire result: identical to Path C (org → ephemeral without scope field)
|
|
321
|
+
* The blocks are joined here, not left loose, because the package joins a run
|
|
322
|
+
* of caller blocks that share a `cache_control` anyway. Joining first is what
|
|
323
|
+
* makes the breakpoint land on one deterministic block.
|
|
409
324
|
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* - dynamic blocks (after boundary, joined) → cacheScope: null
|
|
415
|
-
*
|
|
416
|
-
* Path C (fallback): no global cache feature or no boundary
|
|
417
|
-
* - billing → cacheScope: null
|
|
418
|
-
* - identity → cacheScope: 'org'
|
|
419
|
-
* - rest (joined) → cacheScope: 'org'
|
|
325
|
+
* Boundary mode (`cache_policy.boundary_marker`, or
|
|
326
|
+
* `CLAUDE_CODE_FORCE_GLOBAL_CACHE`): everything before the marker is the static
|
|
327
|
+
* half and takes a `scope: 'global'` breakpoint; everything after is the
|
|
328
|
+
* dynamic half and takes none.
|
|
420
329
|
*
|
|
421
330
|
* @param {Array<{text: string}>} blocks - Already sanitized/filtered text blocks
|
|
422
|
-
* @param {
|
|
423
|
-
* @param {string} identityString - The identity prefix string
|
|
424
|
-
* @param {boolean} useBoundaryMode - Whether to use Path B (global cache with boundary)
|
|
331
|
+
* @param {boolean} useBoundaryMode
|
|
425
332
|
* @returns {Array<{text: string, cacheScope: 'global' | 'org' | null}>}
|
|
426
333
|
*/
|
|
427
|
-
export function
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (block.text.startsWith("x-anthropic-billing-header:")) continue;
|
|
434
|
-
if (KNOWN_IDENTITY_STRINGS.has(block.text)) continue;
|
|
435
|
-
// Skip the boundary marker itself (real CC skips it in Path A, processes it in Path B)
|
|
436
|
-
if (block.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
|
|
437
|
-
rest.push(block.text);
|
|
438
|
-
}
|
|
334
|
+
export function splitSystemCacheScopes(blocks, useBoundaryMode) {
|
|
335
|
+
const isDroppedBlock = (text) =>
|
|
336
|
+
!text ||
|
|
337
|
+
text.startsWith(BILLING_HEADER_PREFIX) ||
|
|
338
|
+
KNOWN_IDENTITY_STRINGS.has(text) ||
|
|
339
|
+
text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
|
|
439
340
|
|
|
440
|
-
// ====================================================================
|
|
441
|
-
// Path B: Global cache with boundary marker
|
|
442
|
-
// Real CC (utils/api.ts:219-262): when shouldUseGlobalCacheScope() &&
|
|
443
|
-
// boundary marker is found in the system prompt array.
|
|
444
|
-
// ====================================================================
|
|
445
341
|
if (useBoundaryMode) {
|
|
446
|
-
// Find boundary marker in the ORIGINAL block array (before filtering)
|
|
447
342
|
const boundaryIndex = blocks.findIndex((b) => b.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
|
|
448
343
|
|
|
449
344
|
if (boundaryIndex !== -1) {
|
|
450
|
-
// Classify blocks as static (before boundary) or dynamic (after boundary)
|
|
451
345
|
const staticBlocks = [];
|
|
452
346
|
const dynamicBlocks = [];
|
|
453
347
|
for (let i = 0; i < blocks.length; i++) {
|
|
454
348
|
const text = blocks[i].text;
|
|
455
|
-
if (
|
|
456
|
-
if (text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
|
|
457
|
-
if (text.startsWith("x-anthropic-billing-header:")) continue;
|
|
458
|
-
if (KNOWN_IDENTITY_STRINGS.has(text)) continue;
|
|
349
|
+
if (isDroppedBlock(text)) continue;
|
|
459
350
|
if (i < boundaryIndex) {
|
|
460
351
|
staticBlocks.push(text);
|
|
461
352
|
} else {
|
|
@@ -464,34 +355,40 @@ export function splitSysPromptPrefix(blocks, attributionHeader, identityString,
|
|
|
464
355
|
}
|
|
465
356
|
|
|
466
357
|
const result = [];
|
|
467
|
-
if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
|
|
468
|
-
// Identity: cacheScope null in boundary mode (real CC behavior)
|
|
469
|
-
result.push({ text: identityString, cacheScope: null });
|
|
470
358
|
const staticJoined = staticBlocks.join("\n");
|
|
471
359
|
if (staticJoined) result.push({ text: staticJoined, cacheScope: "global" });
|
|
472
360
|
const dynamicJoined = dynamicBlocks.join("\n");
|
|
473
361
|
if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null });
|
|
474
362
|
return result;
|
|
475
363
|
}
|
|
476
|
-
// Boundary marker not found — fall through to
|
|
364
|
+
// Boundary marker not found — fall through to the single-breakpoint shape.
|
|
477
365
|
}
|
|
478
366
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const result = [];
|
|
485
|
-
if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
|
|
486
|
-
result.push({ text: identityString, cacheScope: "org" });
|
|
367
|
+
const rest = [];
|
|
368
|
+
for (const block of blocks) {
|
|
369
|
+
if (isDroppedBlock(block.text)) continue;
|
|
370
|
+
rest.push(block.text);
|
|
371
|
+
}
|
|
487
372
|
const restJoined = rest.join("\n");
|
|
488
|
-
|
|
489
|
-
return result;
|
|
373
|
+
return restJoined ? [{ text: restJoined, cacheScope: "org" }] : [];
|
|
490
374
|
}
|
|
491
375
|
|
|
492
376
|
/**
|
|
377
|
+
* Apply the HOST system-prompt policy to an already-normalized block array.
|
|
378
|
+
*
|
|
379
|
+
* This is host policy ONLY. The Claude Code canonical prefix (billing block +
|
|
380
|
+
* identity block) is composed by `@tormentalabs/claude-code-wire-compat`, which
|
|
381
|
+
* also drops any caller block byte-equal to the identity text — so the prefix
|
|
382
|
+
* reaches the wire exactly once and the plugin never emits it.
|
|
383
|
+
*
|
|
384
|
+
* What stays here, in order:
|
|
385
|
+
* 1. sanitize (OpenCode -> Claude Code) + compaction
|
|
386
|
+
* 2. title-generator swap, or dedupe of near-identical blocks
|
|
387
|
+
* 3. anti-verbosity injection (Opus 4.6/4.7, unless simple-system-prompt mode)
|
|
388
|
+
* 4. host prompt-cache breakpoint placement
|
|
389
|
+
*
|
|
493
390
|
* @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
|
|
494
|
-
* @param {{
|
|
391
|
+
* @param {{promptCompactionMode: 'minimal' | 'off', modelId?: string, simpleSystemPrompt?: boolean, antiVerbosity?: {enabled?: boolean}, cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
|
|
495
392
|
* @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
|
|
496
393
|
*/
|
|
497
394
|
export function buildSystemPromptBlocks(system, signature) {
|
|
@@ -502,40 +399,6 @@ export function buildSystemPromptBlocks(system, signature) {
|
|
|
502
399
|
text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
|
|
503
400
|
}));
|
|
504
401
|
|
|
505
|
-
// A5: Subagent CC-prefix cache/inject (see constant declaration above for context).
|
|
506
|
-
//
|
|
507
|
-
// After sanitize, main-agent blocks start with "You are an interactive..." because
|
|
508
|
-
// sanitizeSystemText() strips everything before that anchor. Subagent blocks
|
|
509
|
-
// (custom prompts from input.agent.prompt) do NOT start with the anchor —
|
|
510
|
-
// they start with whatever the agent template says (e.g., "You are a file search
|
|
511
|
-
// specialist.").
|
|
512
|
-
//
|
|
513
|
-
// This logic runs ONLY for Anthropic requests with signature enabled (signature.enabled
|
|
514
|
-
// is false for non-Anthropic providers), and skips the title-generator fast path
|
|
515
|
-
// because that one is replaced wholesale with COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT below.
|
|
516
|
-
if (signature.enabled && !titleGeneratorRequest && sanitized.length > 0) {
|
|
517
|
-
const firstText = typeof sanitized[0]?.text === "string" ? sanitized[0].text : "";
|
|
518
|
-
const hasCcAnchor = firstText.startsWith(SUBAGENT_CC_ANCHOR);
|
|
519
|
-
|
|
520
|
-
if (hasCcAnchor) {
|
|
521
|
-
// Main-agent path: cache the prefix on the first hit so subagents can reuse it.
|
|
522
|
-
// We slice to MAX_SUBAGENT_CC_PREFIX to avoid unbounded growth if the upstream
|
|
523
|
-
// sanitize limit is ever raised.
|
|
524
|
-
if (!cachedCCPrompt) {
|
|
525
|
-
cachedCCPrompt = firstText.slice(0, MAX_SUBAGENT_CC_PREFIX);
|
|
526
|
-
}
|
|
527
|
-
} else if (cachedCCPrompt) {
|
|
528
|
-
// Subagent path: prepend the cached CC prefix so the fingerprint matches.
|
|
529
|
-
// We prepend, not concatenate, so the original subagent prompt stays as a
|
|
530
|
-
// separate block — dedupeSystemBlocks and splitSysPromptPrefix handle the
|
|
531
|
-
// join on their own downstream.
|
|
532
|
-
sanitized = [{ type: "text", text: cachedCCPrompt }, ...sanitized];
|
|
533
|
-
}
|
|
534
|
-
// If !hasCcAnchor && !cachedCCPrompt: no-op. The cache primes on the very
|
|
535
|
-
// first main call in a process. In practice opencode always fires a main
|
|
536
|
-
// call before any subagent, so this branch is only hit in synthetic tests.
|
|
537
|
-
}
|
|
538
|
-
|
|
539
402
|
if (titleGeneratorRequest) {
|
|
540
403
|
sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
|
|
541
404
|
} else if (signature.promptCompactionMode !== "off") {
|
|
@@ -567,62 +430,14 @@ export function buildSystemPromptBlocks(system, signature) {
|
|
|
567
430
|
}
|
|
568
431
|
}
|
|
569
432
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// Lean system prompt for non-main requests (title-gen is already handled
|
|
575
|
-
// above via the COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT swap). For "title"
|
|
576
|
-
// and "small" request roles — one-off queries that don't belong to the main
|
|
577
|
-
// REPL thread — we skip billing identity + CC identity injection. This
|
|
578
|
-
// matches the spirit of real CC's querySource gates: identity context is
|
|
579
|
-
// for interactive main-thread conversations, not fire-and-forget calls.
|
|
580
|
-
// Opt-in (default off) because it changes the system-prompt shape.
|
|
581
|
-
const leanNonMain =
|
|
582
|
-
signature.leanNonMain === true &&
|
|
583
|
-
(signature.requestRole === "title" || signature.requestRole === "small") &&
|
|
584
|
-
!titleGeneratorRequest;
|
|
585
|
-
if (leanNonMain) {
|
|
586
|
-
return sanitized;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Build attribution header
|
|
590
|
-
const billingHeader = buildAnthropicBillingHeader(
|
|
591
|
-
signature.claudeCliVersion,
|
|
592
|
-
signature.firstUserMessage,
|
|
593
|
-
signature.workload,
|
|
594
|
-
);
|
|
595
|
-
|
|
596
|
-
// Select the identity string (matches real CC getCLISyspromptPrefix())
|
|
597
|
-
const identityString = getCLISyspromptPrefix();
|
|
598
|
-
|
|
599
|
-
// Determine cache policy
|
|
433
|
+
// Host prompt-cache breakpoint. The canonical prefix is NOT built here — the
|
|
434
|
+
// package prepends its own billing + identity blocks and drops a caller block
|
|
435
|
+
// equal to the identity text, so the prefix lands on the wire exactly once.
|
|
600
436
|
const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
601
|
-
|
|
602
|
-
// Determine if we should use boundary mode (Path B)
|
|
603
|
-
// Real CC: shouldUseGlobalCacheScope() is a GrowthBook feature flag.
|
|
604
|
-
// We simulate it via config: boundary_marker=true or CLAUDE_CODE_FORCE_GLOBAL_CACHE=1.
|
|
605
437
|
const useBoundaryMode =
|
|
606
438
|
effectiveCachePolicy.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
|
|
607
439
|
|
|
608
|
-
|
|
609
|
-
const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
|
|
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
|
-
|
|
623
|
-
// Convert scoped blocks to wire format using getCacheControlForScope
|
|
624
|
-
// (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
|
|
625
|
-
return emittedBlocks.map((block) => {
|
|
440
|
+
return splitSystemCacheScopes(sanitized, useBoundaryMode).map((block) => {
|
|
626
441
|
const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
|
|
627
442
|
return {
|
|
628
443
|
type: "text",
|
|
@@ -631,39 +446,3 @@ export function buildSystemPromptBlocks(system, signature) {
|
|
|
631
446
|
};
|
|
632
447
|
});
|
|
633
448
|
}
|
|
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
|
-
|
|
663
|
-
export function getCachedCCPrompt() {
|
|
664
|
-
return cachedCCPrompt;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
export function resetCachedCCPrompt() {
|
|
668
|
-
cachedCCPrompt = null;
|
|
669
|
-
}
|