opencode-anthropic-fix 0.1.32 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- package/package.json +4 -1
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { createHash as createHashCrypto } from "node:crypto";
|
|
2
|
+
import { isTruthyEnv, isFalsyEnv } from "../env.mjs";
|
|
3
|
+
import { isOpus46Model, isOpus47Model, isOpus48Model } from "./models.mjs";
|
|
4
|
+
|
|
5
|
+
const BILLING_HASH_SALT = "59cf53e54c78";
|
|
6
|
+
const BILLING_HASH_INDICES = [4, 7, 20];
|
|
7
|
+
|
|
8
|
+
export const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
9
|
+
const KNOWN_IDENTITY_STRINGS = new Set([
|
|
10
|
+
CLAUDE_CODE_IDENTITY_STRING,
|
|
11
|
+
"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
|
|
12
|
+
"You are a Claude agent, built on Anthropic's Claude Agent SDK.",
|
|
13
|
+
]);
|
|
14
|
+
const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
|
|
15
|
+
const COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT = [
|
|
16
|
+
"You are a title generator. You output ONLY a thread title. Nothing else.",
|
|
17
|
+
"",
|
|
18
|
+
"Rules:",
|
|
19
|
+
"- Use the same language as the user message.",
|
|
20
|
+
"- Output exactly one line.",
|
|
21
|
+
"- Keep the title at or below 50 characters.",
|
|
22
|
+
"- No explanations, prefixes, or suffixes.",
|
|
23
|
+
"- Keep important technical terms, numbers, and filenames when present.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Anti-verbosity system prompt text.
|
|
28
|
+
* Extracted from CC v2.1.100 (gated on quiet_salted_ember A/B test for Opus 4.6).
|
|
29
|
+
* Significantly reduces output token count by instructing the model to be concise.
|
|
30
|
+
*/
|
|
31
|
+
const ANTI_VERBOSITY_SYSTEM_PROMPT = [
|
|
32
|
+
"# Communication style",
|
|
33
|
+
"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
|
+
"",
|
|
35
|
+
"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.",
|
|
36
|
+
"",
|
|
37
|
+
"When you do write updates, write so the reader can pick up cold: complete sentences, no unexplained jargon or shorthand from earlier in the session. But keep it tight — a clear sentence is better than a clear paragraph.",
|
|
38
|
+
"",
|
|
39
|
+
"End-of-turn summary: one or two sentences. What changed and what's next. Nothing else.",
|
|
40
|
+
"",
|
|
41
|
+
"Match responses to the task: a simple question gets a direct answer, not headers and sections.",
|
|
42
|
+
"",
|
|
43
|
+
"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
|
+
].join("\n");
|
|
45
|
+
|
|
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
|
+
// Max system prompt length that passes CC billing validation.
|
|
54
|
+
// The server pattern-matches the system prompt against the real CC prompt.
|
|
55
|
+
// Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
|
|
56
|
+
const MAX_SAFE_SYSTEM_TEXT_LENGTH = 5000;
|
|
57
|
+
|
|
58
|
+
// A5: Subagent CC-prefix cache.
|
|
59
|
+
//
|
|
60
|
+
// Context: opencode/packages/opencode/src/session/llm.ts:110 uses
|
|
61
|
+
// `input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(model)`
|
|
62
|
+
// so any agent with a custom prompt (explore, fast, title, summary, etc.)
|
|
63
|
+
// fires WITHOUT the base CC prompt — the server-side fingerprint match fails
|
|
64
|
+
// and the request is billed as pay-as-you-go credits instead of Max-plan usage.
|
|
65
|
+
//
|
|
66
|
+
// Fix: on the first main-agent call (where the anchor is present), cache the
|
|
67
|
+
// sanitized CC prefix. On subsequent subagent calls (anchor missing), prepend
|
|
68
|
+
// the cached prefix to the sanitized blocks so the fingerprint matches again.
|
|
69
|
+
//
|
|
70
|
+
// The cache lives at module scope because buildSystemPromptBlocks is re-entered
|
|
71
|
+
// per request. It gets populated exactly once per process on the first main call.
|
|
72
|
+
const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
|
|
73
|
+
export const SUBAGENT_CC_ANCHOR = "You are an interactive";
|
|
74
|
+
let cachedCCPrompt = null;
|
|
75
|
+
|
|
76
|
+
// Perf: module-scope regexes reused across per-request hot paths. None use the
|
|
77
|
+
// `/g` flag and all are consumed via `.test()`, so a single shared instance is
|
|
78
|
+
// stateless and safe (no `lastIndex` to reset between calls).
|
|
79
|
+
const TAIL_IMPORTANT_RE = /\b(MUST|NEVER|CRITICAL|IMPORTANT|REQUIRED|DO NOT|ALWAYS|FORBIDDEN)\b/i;
|
|
80
|
+
const TAIL_HEADER_RE = /^#{1,4}\s/;
|
|
81
|
+
const TAIL_LIST_ITEM_RE = /^\s*[-*]\s/;
|
|
82
|
+
|
|
83
|
+
const OPENCODE_ENV_CONTEXT_PREFIX = "Here is some useful information about the environment you are running in:";
|
|
84
|
+
const CC_ENV_CONTEXT_PREFIX = "Here is useful information about the environment you are running in:";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute the billing cache hash (cch) matching Claude Code's NP1() function.
|
|
88
|
+
* SHA256(salt + chars_at_indices[4,7,20]_from_first_user_msg + version).slice(0,3)
|
|
89
|
+
* @param {string} firstUserMessage
|
|
90
|
+
* @param {string} version
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function computeBillingCacheHash(firstUserMessage, version) {
|
|
94
|
+
const chars = BILLING_HASH_INDICES.map((i) => firstUserMessage[i] || "0").join("");
|
|
95
|
+
const input = `${BILLING_HASH_SALT}${chars}${version}`;
|
|
96
|
+
return createHashCrypto("sha256").update(input).digest("hex").slice(0, 3);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 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
|
|
102
|
+
* `CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT=1`).
|
|
103
|
+
*
|
|
104
|
+
* Real CC's `sR9` matches: `claude-opus-4-7`, any model with `-eap` (early
|
|
105
|
+
* access) suffix, plus a small GrowthBook-controlled allowlist we can't read.
|
|
106
|
+
* Plugin mirrors the deterministic subset only.
|
|
107
|
+
*
|
|
108
|
+
* @param {string | undefined} model
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function isSimpleSystemPromptEligible(model) {
|
|
112
|
+
if (!model) return false;
|
|
113
|
+
if (isOpus47Model(model) || isOpus48Model(model)) return true;
|
|
114
|
+
// -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;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the billing header block for Claude Code system prompt injection.
|
|
121
|
+
* Claude Code v2.1.97: cc_version includes 3-char fingerprint hash (not model ID).
|
|
122
|
+
* cch is a static "00000" placeholder (xxHash64 attestation removed in v2.1.97).
|
|
123
|
+
*
|
|
124
|
+
* Real CC (system.ts:78): version = `${MACRO.VERSION}.${fingerprint}`
|
|
125
|
+
* Real CC (system.ts:82): cch = ' cch=00000;' (static, no longer computed)
|
|
126
|
+
*
|
|
127
|
+
* @param {string} version - CLI version (e.g., "2.1.97")
|
|
128
|
+
* @param {string} [firstUserMessage] - First user message text for fingerprint computation
|
|
129
|
+
* @param {string} [provider] - API provider ("anthropic" | "bedrock" | "vertex" | "foundry" | "anthropicAws" | "mantle")
|
|
130
|
+
* @param {string} [workloadOverride] - Explicit workload tag from config (`signature_emulation.workload`).
|
|
131
|
+
* Takes precedence over `CLAUDE_CODE_WORKLOAD` env var. Mirrors real CC's `--workload <tag>` flag.
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function buildAnthropicBillingHeader(version, firstUserMessage, provider, workloadOverride) {
|
|
135
|
+
if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
|
|
136
|
+
// Real CC sends cc_entrypoint=cli (confirmed via proxy capture).
|
|
137
|
+
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
|
|
138
|
+
// Fix #1: cc_version suffix is the 3-char fingerprint hash, NOT the model ID.
|
|
139
|
+
// computeBillingCacheHash() computes SHA256(salt + msg[4]+msg[7]+msg[20] + version)[:3]
|
|
140
|
+
// which matches computeFingerprint() in the real CC source (utils/fingerprint.ts).
|
|
141
|
+
// Always call the hash function — even for empty messages the real CC computes
|
|
142
|
+
// the hash from "000" chars (indices 4,7,20 all missing → fallback "0").
|
|
143
|
+
const fingerprint = computeBillingCacheHash(firstUserMessage || "", version);
|
|
144
|
+
const ccVersion = `${version}.${fingerprint}`;
|
|
145
|
+
// cch: v2.1.97 sends static "cch=00000" — xxHash64 attestation was removed.
|
|
146
|
+
// 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;";
|
|
149
|
+
// Build workload part (upstream concatenates directly, no regex replace).
|
|
150
|
+
// Config wins over env so `signature_emulation.workload` is portable across hosts.
|
|
151
|
+
let workloadPart = "";
|
|
152
|
+
const workload = workloadOverride || process.env.CLAUDE_CODE_WORKLOAD;
|
|
153
|
+
if (workload && !cchDisabled) {
|
|
154
|
+
// 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
|
+
const safeWorkload = String(workload).replace(/[;\s\r\n]/g, "_");
|
|
157
|
+
if (safeWorkload) workloadPart = ` cc_workload=${safeWorkload};`;
|
|
158
|
+
}
|
|
159
|
+
return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=${entrypoint};${cchPart}${workloadPart}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function sanitizeSystemText(text) {
|
|
163
|
+
// QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
|
|
164
|
+
let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
|
|
165
|
+
// Strip non-CC custom prefixes before the standard CC prompt.
|
|
166
|
+
const ccStandardStart = sanitized.indexOf("You are an interactive");
|
|
167
|
+
if (ccStandardStart > 0) {
|
|
168
|
+
sanitized = sanitized.slice(ccStandardStart);
|
|
169
|
+
}
|
|
170
|
+
// NOTE: truncation removed — real CC v2.1.107 sends 26K+ char system prompts.
|
|
171
|
+
// The server checks for CC identity/billing markers, not exact prompt length.
|
|
172
|
+
return sanitized;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string} text
|
|
177
|
+
* @param {'minimal' | 'off'} mode
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
export function tailSystemBlock(text, maxChars, turnThreshold) {
|
|
181
|
+
const lines = text.split("\n");
|
|
182
|
+
const kept = [];
|
|
183
|
+
let charCount = 0;
|
|
184
|
+
const importantRe = TAIL_IMPORTANT_RE;
|
|
185
|
+
const headerRe = TAIL_HEADER_RE;
|
|
186
|
+
const listItemRe = TAIL_LIST_ITEM_RE;
|
|
187
|
+
// Always keep the first paragraph (identity/role definition)
|
|
188
|
+
let firstParaEnd = 0;
|
|
189
|
+
for (let j = 0; j < lines.length; j++) {
|
|
190
|
+
if (lines[j].trim() === "" && j > 0) {
|
|
191
|
+
firstParaEnd = j;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (firstParaEnd === 0) firstParaEnd = Math.min(5, lines.length);
|
|
196
|
+
for (let j = 0; j <= firstParaEnd; j++) {
|
|
197
|
+
kept.push(lines[j]);
|
|
198
|
+
charCount += (lines[j]?.length || 0) + 1;
|
|
199
|
+
}
|
|
200
|
+
// Scan remaining lines: keep headers, important constraints, short list items
|
|
201
|
+
for (let j = firstParaEnd + 1; j < lines.length; j++) {
|
|
202
|
+
const line = lines[j];
|
|
203
|
+
const isHeader = headerRe.test(line);
|
|
204
|
+
const isImportant = importantRe.test(line);
|
|
205
|
+
const isShortListItem = listItemRe.test(line) && line.length < 120;
|
|
206
|
+
if (isHeader || isImportant || isShortListItem) {
|
|
207
|
+
if (charCount + line.length + 1 > maxChars) break;
|
|
208
|
+
kept.push(line);
|
|
209
|
+
charCount += line.length + 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
kept.push("", "[Verbose instructions trimmed after turn " + turnThreshold + ". Key constraints preserved above.]");
|
|
213
|
+
return kept.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function compactToolDescription(text) {
|
|
217
|
+
return text
|
|
218
|
+
.replace(/<example[\s\S]*?<\/example>/gi, "")
|
|
219
|
+
.replace(/\|[\s|:-]+\|/g, "")
|
|
220
|
+
.replace(/^\|.*\|$/gm, "")
|
|
221
|
+
.replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => m.slice(0, 200) + "...")
|
|
222
|
+
.replace(/^(#{1,3}\s+)/gm, "")
|
|
223
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
224
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
225
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
226
|
+
.trim();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function compactSystemText(text, mode) {
|
|
230
|
+
const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}\n`)
|
|
231
|
+
? text.slice(CLAUDE_CODE_IDENTITY_STRING.length).trimStart()
|
|
232
|
+
: text;
|
|
233
|
+
|
|
234
|
+
if (mode === "off") {
|
|
235
|
+
return withoutDuplicateIdentityPrefix.trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const compacted = withoutDuplicateIdentityPrefix.replace(/<example>[\s\S]*?<\/example>/gi, "\n");
|
|
239
|
+
|
|
240
|
+
const dedupedLines = [];
|
|
241
|
+
let prevNormalized = "";
|
|
242
|
+
for (const line of compacted.split("\n")) {
|
|
243
|
+
const normalized = line.trim();
|
|
244
|
+
if (normalized && normalized === prevNormalized) continue;
|
|
245
|
+
dedupedLines.push(line);
|
|
246
|
+
prevNormalized = normalized;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return dedupedLines
|
|
250
|
+
.join("\n")
|
|
251
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
252
|
+
.trim();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} text
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
export function normalizeSystemTextForComparison(text) {
|
|
260
|
+
return text
|
|
261
|
+
.replace(/\r\n/g, "\n")
|
|
262
|
+
.split("\n")
|
|
263
|
+
.map((line) => line.trim())
|
|
264
|
+
.join("\n")
|
|
265
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
266
|
+
.trim();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
|
|
271
|
+
* @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
|
|
272
|
+
*/
|
|
273
|
+
export function dedupeSystemBlocks(system) {
|
|
274
|
+
const exactSeen = new Set();
|
|
275
|
+
const exactDeduped = [];
|
|
276
|
+
|
|
277
|
+
for (const item of system) {
|
|
278
|
+
const normalized = normalizeSystemTextForComparison(item.text);
|
|
279
|
+
const key = `${item.type}:${normalized}`;
|
|
280
|
+
if (exactSeen.has(key)) continue;
|
|
281
|
+
exactSeen.add(key);
|
|
282
|
+
exactDeduped.push(item);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const normalizedBlocks = exactDeduped.map((item) => normalizeSystemTextForComparison(item.text));
|
|
286
|
+
return exactDeduped.filter((_, index) => {
|
|
287
|
+
const current = normalizedBlocks[index];
|
|
288
|
+
if (current.length < 80) return true;
|
|
289
|
+
|
|
290
|
+
for (let otherIndex = 0; otherIndex < normalizedBlocks.length; otherIndex += 1) {
|
|
291
|
+
if (otherIndex === index) continue;
|
|
292
|
+
const other = normalizedBlocks[otherIndex];
|
|
293
|
+
if (other.length <= current.length + 20) continue;
|
|
294
|
+
if (other.includes(current)) return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return true;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} text
|
|
303
|
+
* @returns {boolean}
|
|
304
|
+
*/
|
|
305
|
+
export function isTitleGeneratorSystemText(text) {
|
|
306
|
+
const normalized = text.trim().toLowerCase();
|
|
307
|
+
return normalized.includes("you are a title generator") || normalized.includes("generate a brief title");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
|
|
312
|
+
* @returns {boolean}
|
|
313
|
+
*/
|
|
314
|
+
export function isTitleGeneratorSystemBlocks(system) {
|
|
315
|
+
return system.some(
|
|
316
|
+
(item) => item.type === "text" && typeof item.text === "string" && isTitleGeneratorSystemText(item.text),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function rewriteEnvContextPhrasing(text) {
|
|
321
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
322
|
+
if (text.indexOf(OPENCODE_ENV_CONTEXT_PREFIX) === -1) return text;
|
|
323
|
+
return text.split(OPENCODE_ENV_CONTEXT_PREFIX).join(CC_ENV_CONTEXT_PREFIX);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function normalizeSystemTextBlocks(system) {
|
|
327
|
+
const output = [];
|
|
328
|
+
if (!Array.isArray(system)) return output;
|
|
329
|
+
|
|
330
|
+
for (const item of system) {
|
|
331
|
+
if (typeof item === "string") {
|
|
332
|
+
output.push({ type: "text", text: rewriteEnvContextPhrasing(item) });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!item || typeof item !== "object") continue;
|
|
337
|
+
if (typeof item.text !== "string") continue;
|
|
338
|
+
|
|
339
|
+
const normalized = {
|
|
340
|
+
type: typeof item.type === "string" ? item.type : "text",
|
|
341
|
+
text: rewriteEnvContextPhrasing(item.text),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Intentionally strip cache_control from incoming system blocks.
|
|
345
|
+
// The plugin controls cache placement: only the identity block and
|
|
346
|
+
// boundary-split blocks get cache_control (added in buildSystemPromptBlocks).
|
|
347
|
+
// Passing through upstream markers can cause "maximum of 4 blocks with
|
|
348
|
+
// cache_control" API errors when combined with our own markers.
|
|
349
|
+
|
|
350
|
+
output.push(normalized);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return output;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Determine the identity string prefix, matching real CC's getCLISyspromptPrefix().
|
|
358
|
+
* Real CC selects based on isNonInteractive + hasAppendSystemPrompt flags.
|
|
359
|
+
* OpenCode is always interactive CLI, so DEFAULT_PREFIX is almost always correct.
|
|
360
|
+
* We check for Agent SDK signals from the environment to match non-interactive cases.
|
|
361
|
+
*
|
|
362
|
+
* @returns {string}
|
|
363
|
+
*/
|
|
364
|
+
export function getCLISyspromptPrefix() {
|
|
365
|
+
// Agent SDK preset: when running within the Claude Agent SDK with CC preset
|
|
366
|
+
if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
367
|
+
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "";
|
|
368
|
+
if (entrypoint === "agent-sdk" || entrypoint === "sdk") {
|
|
369
|
+
return "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Non-interactive agent without CC preset
|
|
373
|
+
if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && !isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
374
|
+
return "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
375
|
+
}
|
|
376
|
+
return CLAUDE_CODE_IDENTITY_STRING;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Compute the cache_control object for a given cache scope and policy.
|
|
381
|
+
* Mirrors real CC getCacheControl() (src/services/api/claude.ts:358-374).
|
|
382
|
+
*
|
|
383
|
+
* Real CC behavior:
|
|
384
|
+
* - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: '1h'}
|
|
385
|
+
* - scope 'org' → {type: 'ephemeral', ttl?: '1h'} (org is internal, NOT on wire)
|
|
386
|
+
* - scope null → block gets NO cache_control at all (caller should omit it)
|
|
387
|
+
*
|
|
388
|
+
* @param {'global' | 'org' | null} cacheScope
|
|
389
|
+
* @param {{ttl: string, ttl_supported: boolean}} cachePolicy
|
|
390
|
+
* @returns {{type: string, ttl?: string, scope?: string} | null} null means "no cache_control"
|
|
391
|
+
*/
|
|
392
|
+
export function getCacheControlForScope(cacheScope, cachePolicy) {
|
|
393
|
+
if (cacheScope === null) return null; // no cache_control for this block
|
|
394
|
+
|
|
395
|
+
const hasTtl = cachePolicy.ttl !== "off" && cachePolicy.ttl_supported !== false;
|
|
396
|
+
const result = { type: "ephemeral" };
|
|
397
|
+
if (hasTtl) result.ttl = cachePolicy.ttl;
|
|
398
|
+
// Only 'global' scope is emitted on the wire; 'org' is internal-only
|
|
399
|
+
if (cacheScope === "global") result.scope = "global";
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Split system prompt blocks into structured blocks with cache scoping,
|
|
405
|
+
* matching real CC splitSysPromptPrefix() (src/utils/api.ts:321-435).
|
|
406
|
+
*
|
|
407
|
+
* Real CC has 3 paths that produce 2 distinct wire formats:
|
|
408
|
+
*
|
|
409
|
+
* Path A (tool-based cache): skipGlobalCacheForSystemPrompt=true
|
|
410
|
+
* - billing → cacheScope: null
|
|
411
|
+
* - identity → cacheScope: 'org'
|
|
412
|
+
* - rest (joined) → cacheScope: 'org'
|
|
413
|
+
* Wire result: identical to Path C (org → ephemeral without scope field)
|
|
414
|
+
*
|
|
415
|
+
* Path B (boundary mode): shouldUseGlobalCacheScope() && boundary marker found
|
|
416
|
+
* - billing → cacheScope: null
|
|
417
|
+
* - identity → cacheScope: null (NO cache_control in boundary mode!)
|
|
418
|
+
* - static blocks (before boundary, joined) → cacheScope: 'global'
|
|
419
|
+
* - dynamic blocks (after boundary, joined) → cacheScope: null
|
|
420
|
+
*
|
|
421
|
+
* Path C (fallback): no global cache feature or no boundary
|
|
422
|
+
* - billing → cacheScope: null
|
|
423
|
+
* - identity → cacheScope: 'org'
|
|
424
|
+
* - rest (joined) → cacheScope: 'org'
|
|
425
|
+
*
|
|
426
|
+
* @param {Array<{text: string}>} blocks - Already sanitized/filtered text blocks
|
|
427
|
+
* @param {string | undefined} attributionHeader - The billing header text (or undefined)
|
|
428
|
+
* @param {string} identityString - The identity prefix string
|
|
429
|
+
* @param {boolean} useBoundaryMode - Whether to use Path B (global cache with boundary)
|
|
430
|
+
* @returns {Array<{text: string, cacheScope: 'global' | 'org' | null}>}
|
|
431
|
+
*/
|
|
432
|
+
export function splitSysPromptPrefix(blocks, attributionHeader, identityString, useBoundaryMode) {
|
|
433
|
+
// Separate known blocks from rest, matching real CC's parsing loop
|
|
434
|
+
const rest = [];
|
|
435
|
+
for (const block of blocks) {
|
|
436
|
+
if (!block.text) continue;
|
|
437
|
+
// Skip if it's a billing header or identity string (already extracted)
|
|
438
|
+
if (block.text.startsWith("x-anthropic-billing-header:")) continue;
|
|
439
|
+
if (KNOWN_IDENTITY_STRINGS.has(block.text)) continue;
|
|
440
|
+
// Skip the boundary marker itself (real CC skips it in Path A, processes it in Path B)
|
|
441
|
+
if (block.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
|
|
442
|
+
rest.push(block.text);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ====================================================================
|
|
446
|
+
// Path B: Global cache with boundary marker
|
|
447
|
+
// Real CC (utils/api.ts:219-262): when shouldUseGlobalCacheScope() &&
|
|
448
|
+
// boundary marker is found in the system prompt array.
|
|
449
|
+
// ====================================================================
|
|
450
|
+
if (useBoundaryMode) {
|
|
451
|
+
// Find boundary marker in the ORIGINAL block array (before filtering)
|
|
452
|
+
const boundaryIndex = blocks.findIndex((b) => b.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
|
|
453
|
+
|
|
454
|
+
if (boundaryIndex !== -1) {
|
|
455
|
+
// Classify blocks as static (before boundary) or dynamic (after boundary)
|
|
456
|
+
const staticBlocks = [];
|
|
457
|
+
const dynamicBlocks = [];
|
|
458
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
459
|
+
const text = blocks[i].text;
|
|
460
|
+
if (!text) continue;
|
|
461
|
+
if (text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
|
|
462
|
+
if (text.startsWith("x-anthropic-billing-header:")) continue;
|
|
463
|
+
if (KNOWN_IDENTITY_STRINGS.has(text)) continue;
|
|
464
|
+
if (i < boundaryIndex) {
|
|
465
|
+
staticBlocks.push(text);
|
|
466
|
+
} else {
|
|
467
|
+
dynamicBlocks.push(text);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const result = [];
|
|
472
|
+
if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
|
|
473
|
+
// Identity: cacheScope null in boundary mode (real CC behavior)
|
|
474
|
+
result.push({ text: identityString, cacheScope: null });
|
|
475
|
+
const staticJoined = staticBlocks.join("\n");
|
|
476
|
+
if (staticJoined) result.push({ text: staticJoined, cacheScope: "global" });
|
|
477
|
+
const dynamicJoined = dynamicBlocks.join("\n");
|
|
478
|
+
if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null });
|
|
479
|
+
return result;
|
|
480
|
+
}
|
|
481
|
+
// Boundary marker not found — fall through to Path C
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ====================================================================
|
|
485
|
+
// Path C (fallback) / Path A (tool-based): no boundary or no global cache
|
|
486
|
+
// Real CC (utils/api.ts:264-289): identity and rest get cacheScope 'org'
|
|
487
|
+
// Path A produces identical wire output to Path C.
|
|
488
|
+
// ====================================================================
|
|
489
|
+
const result = [];
|
|
490
|
+
if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
|
|
491
|
+
result.push({ text: identityString, cacheScope: "org" });
|
|
492
|
+
const restJoined = rest.join("\n");
|
|
493
|
+
if (restJoined) result.push({ text: restJoined, cacheScope: "org" });
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
|
|
499
|
+
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
|
|
500
|
+
* @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
|
|
501
|
+
*/
|
|
502
|
+
export function buildSystemPromptBlocks(system, signature) {
|
|
503
|
+
const titleGeneratorRequest = isTitleGeneratorSystemBlocks(system);
|
|
504
|
+
|
|
505
|
+
let sanitized = system.map((item) => ({
|
|
506
|
+
...item,
|
|
507
|
+
text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
|
|
508
|
+
}));
|
|
509
|
+
|
|
510
|
+
// A5: Subagent CC-prefix cache/inject (see constant declaration above for context).
|
|
511
|
+
//
|
|
512
|
+
// After sanitize, main-agent blocks start with "You are an interactive..." because
|
|
513
|
+
// sanitizeSystemText() strips everything before that anchor. Subagent blocks
|
|
514
|
+
// (custom prompts from input.agent.prompt) do NOT start with the anchor —
|
|
515
|
+
// they start with whatever the agent template says (e.g., "You are a file search
|
|
516
|
+
// specialist.").
|
|
517
|
+
//
|
|
518
|
+
// This logic runs ONLY for Anthropic requests with signature enabled (signature.enabled
|
|
519
|
+
// is false for non-Anthropic providers), and skips the title-generator fast path
|
|
520
|
+
// because that one is replaced wholesale with COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT below.
|
|
521
|
+
if (signature.enabled && !titleGeneratorRequest && sanitized.length > 0) {
|
|
522
|
+
const firstText = typeof sanitized[0]?.text === "string" ? sanitized[0].text : "";
|
|
523
|
+
const hasCcAnchor = firstText.startsWith(SUBAGENT_CC_ANCHOR);
|
|
524
|
+
|
|
525
|
+
if (hasCcAnchor) {
|
|
526
|
+
// Main-agent path: cache the prefix on the first hit so subagents can reuse it.
|
|
527
|
+
// We slice to MAX_SUBAGENT_CC_PREFIX to avoid unbounded growth if the upstream
|
|
528
|
+
// sanitize limit is ever raised.
|
|
529
|
+
if (!cachedCCPrompt) {
|
|
530
|
+
cachedCCPrompt = firstText.slice(0, MAX_SUBAGENT_CC_PREFIX);
|
|
531
|
+
}
|
|
532
|
+
} else if (cachedCCPrompt) {
|
|
533
|
+
// Subagent path: prepend the cached CC prefix so the fingerprint matches.
|
|
534
|
+
// We prepend, not concatenate, so the original subagent prompt stays as a
|
|
535
|
+
// separate block — dedupeSystemBlocks and splitSysPromptPrefix handle the
|
|
536
|
+
// join on their own downstream.
|
|
537
|
+
sanitized = [{ type: "text", text: cachedCCPrompt }, ...sanitized];
|
|
538
|
+
}
|
|
539
|
+
// If !hasCcAnchor && !cachedCCPrompt: no-op. The cache primes on the very
|
|
540
|
+
// first main call in a process. In practice opencode always fires a main
|
|
541
|
+
// call before any subagent, so this branch is only hit in synthetic tests.
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (titleGeneratorRequest) {
|
|
545
|
+
sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
|
|
546
|
+
} else if (signature.promptCompactionMode !== "off") {
|
|
547
|
+
sanitized = dedupeSystemBlocks(sanitized);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Anti-verbosity injection (CC v2.1.100 quiet_salted_ember equivalent).
|
|
551
|
+
// Applies to Opus 4.6 / 4.7 for non-title-generator requests.
|
|
552
|
+
//
|
|
553
|
+
// Simple-system-prompt gate (CC v2.1.133+ `tengu_vellum_lantern` equivalent):
|
|
554
|
+
// when `signature.simpleSystemPrompt` is true AND the model is eligible
|
|
555
|
+
// (Opus 4.7, -eap variants), skip the anti-verbosity boilerplate. Real CC's
|
|
556
|
+
// simple-prompt mode strips this kind of post-hoc behavioral hand-holding on
|
|
557
|
+
// models that don't need it. Conservative implementation: gates ONLY the
|
|
558
|
+
// anti-verbosity push, not the identity / billing / sanitized blocks. Saves
|
|
559
|
+
// ~600-1500 tokens per request on eligible models without touching the CC
|
|
560
|
+
// fingerprint markers the server checks.
|
|
561
|
+
const skipAntiVerbosity = signature.simpleSystemPrompt === true && isSimpleSystemPromptEligible(signature.modelId);
|
|
562
|
+
|
|
563
|
+
if (
|
|
564
|
+
!titleGeneratorRequest &&
|
|
565
|
+
!skipAntiVerbosity &&
|
|
566
|
+
signature.modelId &&
|
|
567
|
+
(isOpus46Model(signature.modelId) || isOpus47Model(signature.modelId))
|
|
568
|
+
) {
|
|
569
|
+
const avConfig = signature.antiVerbosity;
|
|
570
|
+
if (avConfig?.enabled !== false) {
|
|
571
|
+
sanitized.push({ type: "text", text: ANTI_VERBOSITY_SYSTEM_PROMPT });
|
|
572
|
+
}
|
|
573
|
+
if (avConfig?.length_anchors !== false) {
|
|
574
|
+
sanitized.push({ type: "text", text: NUMERIC_LENGTH_ANCHORS_PROMPT });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!signature.enabled) {
|
|
579
|
+
return sanitized;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Lean system prompt for non-main requests (title-gen is already handled
|
|
583
|
+
// above via the COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT swap). For "title"
|
|
584
|
+
// and "small" request roles — one-off queries that don't belong to the main
|
|
585
|
+
// REPL thread — we skip billing identity + CC identity injection. This
|
|
586
|
+
// matches the spirit of real CC's querySource gates: identity context is
|
|
587
|
+
// for interactive main-thread conversations, not fire-and-forget calls.
|
|
588
|
+
// Opt-in (default off) because it changes the system-prompt shape.
|
|
589
|
+
const leanNonMain =
|
|
590
|
+
signature.leanNonMain === true &&
|
|
591
|
+
(signature.requestRole === "title" || signature.requestRole === "small") &&
|
|
592
|
+
!titleGeneratorRequest;
|
|
593
|
+
if (leanNonMain) {
|
|
594
|
+
return sanitized;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Build attribution header
|
|
598
|
+
const billingHeader = buildAnthropicBillingHeader(
|
|
599
|
+
signature.claudeCliVersion,
|
|
600
|
+
signature.firstUserMessage,
|
|
601
|
+
signature.provider,
|
|
602
|
+
signature.workload,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// Select the identity string (matches real CC getCLISyspromptPrefix())
|
|
606
|
+
const identityString = getCLISyspromptPrefix();
|
|
607
|
+
|
|
608
|
+
// Determine cache policy
|
|
609
|
+
const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
610
|
+
|
|
611
|
+
// Determine if we should use boundary mode (Path B)
|
|
612
|
+
// Real CC: shouldUseGlobalCacheScope() is a GrowthBook feature flag.
|
|
613
|
+
// We simulate it via config: boundary_marker=true or CLAUDE_CODE_FORCE_GLOBAL_CACHE=1.
|
|
614
|
+
const useBoundaryMode =
|
|
615
|
+
effectiveCachePolicy.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
|
|
616
|
+
|
|
617
|
+
// Run the real CC splitSysPromptPrefix algorithm to get blocks with cacheScope
|
|
618
|
+
const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
|
|
619
|
+
|
|
620
|
+
// Convert scoped blocks to wire format using getCacheControlForScope
|
|
621
|
+
// (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
|
|
622
|
+
return scopedBlocks.map((block) => {
|
|
623
|
+
const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
|
|
624
|
+
return {
|
|
625
|
+
type: "text",
|
|
626
|
+
text: block.text,
|
|
627
|
+
...(cc !== null && { cache_control: cc }),
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function getCachedCCPrompt() {
|
|
633
|
+
return cachedCCPrompt;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function resetCachedCCPrompt() {
|
|
637
|
+
cachedCCPrompt = null;
|
|
638
|
+
}
|