opencode-anthropic-fix 0.1.31 → 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.mjs +9 -10
- 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/oauth.mjs +1 -1
- package/lib/oauth.test.mjs +8 -4
- 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,684 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSystemPromptBlocks,
|
|
3
|
+
normalizeSystemTextBlocks,
|
|
4
|
+
compactToolDescription,
|
|
5
|
+
tailSystemBlock,
|
|
6
|
+
isTitleGeneratorSystemBlocks,
|
|
7
|
+
} from "./system-prompt.mjs";
|
|
8
|
+
import {
|
|
9
|
+
applyTtlThinkingStrip,
|
|
10
|
+
applySessionToolResultDedupe,
|
|
11
|
+
applyProactiveMicrocompact,
|
|
12
|
+
applyTrailingSummaryTrim,
|
|
13
|
+
applyToolResultDedupe,
|
|
14
|
+
applyStableToolOrdering,
|
|
15
|
+
applyToolSchemaDeferral,
|
|
16
|
+
applyAdaptiveThinkingZero,
|
|
17
|
+
estimatePromptTokensFromParsed,
|
|
18
|
+
parseNaturalLanguageBudget,
|
|
19
|
+
injectTokenBudgetBlock,
|
|
20
|
+
} from "../token-economy/transforms.mjs";
|
|
21
|
+
import { resolveCacheTtl, shouldPlaceToolBreakpoint } from "./cache.mjs";
|
|
22
|
+
import {
|
|
23
|
+
isAdaptiveThinkingModel,
|
|
24
|
+
isOpus46Model,
|
|
25
|
+
isOpus47Model,
|
|
26
|
+
isOpus48Model,
|
|
27
|
+
normalizeThinkingBlock,
|
|
28
|
+
CLAUDE_3_MODEL_RE,
|
|
29
|
+
} from "./models.mjs";
|
|
30
|
+
import {
|
|
31
|
+
resolveMaxTokens,
|
|
32
|
+
extractFirstUserMessageText,
|
|
33
|
+
buildRequestMetadata,
|
|
34
|
+
stripSlashCommandMessages,
|
|
35
|
+
repairOrphanedToolUseBlocks,
|
|
36
|
+
} from "./request-helpers.mjs";
|
|
37
|
+
import { isFalsyEnv } from "../env.mjs";
|
|
38
|
+
import { sessionMetrics } from "../session-metrics.mjs";
|
|
39
|
+
|
|
40
|
+
// Core tool names (CC PascalCase) that are always eager-loaded.
|
|
41
|
+
export const CORE_TOOL_NAMES = new Set([
|
|
42
|
+
"Bash",
|
|
43
|
+
"Read",
|
|
44
|
+
"Glob",
|
|
45
|
+
"Grep",
|
|
46
|
+
"Edit",
|
|
47
|
+
"Write",
|
|
48
|
+
"WebFetch",
|
|
49
|
+
"TodoWrite",
|
|
50
|
+
"Skill",
|
|
51
|
+
"Task",
|
|
52
|
+
"Compress",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Transform the request body: system prompt sanitization and tool prefixing.
|
|
57
|
+
* Preserves behaviors E1-E7.
|
|
58
|
+
*
|
|
59
|
+
* @param {string | undefined} body
|
|
60
|
+
* @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
|
|
61
|
+
* @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
|
|
62
|
+
* @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
|
|
63
|
+
* @param {import('../config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
|
|
64
|
+
* @returns {string | undefined}
|
|
65
|
+
*/
|
|
66
|
+
export function transformRequestBody(body, signature, runtime, betaHeader, config) {
|
|
67
|
+
if (!body || typeof body !== "string") return body;
|
|
68
|
+
|
|
69
|
+
const TOOL_PREFIX = "mcp_";
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(body);
|
|
73
|
+
// Diagnostic: log the incoming model + thinking/speed shape so model-routing
|
|
74
|
+
// issues (e.g. an unrecognized fast variant, or "No available account"
|
|
75
|
+
// symptoms tied to a specific model) are observable with /anthropic set debug on.
|
|
76
|
+
// NOTE: transformRequestBody is a top-level function outside the plugin
|
|
77
|
+
// closure where debugLog() lives, so we mirror debugLog's behaviour directly
|
|
78
|
+
// (console.error + same prefix, gated on config.debug).
|
|
79
|
+
if (config?.debug) {
|
|
80
|
+
console.error("[opencode-anthropic-auth]", "transformRequestBody: incoming model", {
|
|
81
|
+
model: parsed.model,
|
|
82
|
+
adaptive: isAdaptiveThinkingModel(parsed.model || ""),
|
|
83
|
+
thinkingType: parsed.thinking?.type,
|
|
84
|
+
hasEffort: parsed.effort !== undefined || parsed.output_config?.effort !== undefined,
|
|
85
|
+
speed: parsed.speed,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// Output cap: resolve max_tokens before any other body transforms
|
|
89
|
+
if (config?.output_cap?.enabled) {
|
|
90
|
+
parsed.max_tokens = resolveMaxTokens(parsed, config);
|
|
91
|
+
}
|
|
92
|
+
// Bedrock requires betas in the body as "anthropic_beta" (underscore) since it
|
|
93
|
+
// doesn't forward custom HTTP headers. First-party API rejects "betas" in body
|
|
94
|
+
// with "Extra inputs are not permitted" — betas are header-only for first-party.
|
|
95
|
+
if (signature.enabled && betaHeader && signature.provider === "bedrock") {
|
|
96
|
+
const betaArray = betaHeader
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((b) => b.trim())
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.filter((b) => b !== "oauth-2025-04-20");
|
|
101
|
+
parsed.anthropic_beta = betaArray;
|
|
102
|
+
}
|
|
103
|
+
// Strip any incoming "betas" field — API rejects it as unknown
|
|
104
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "betas")) {
|
|
105
|
+
delete parsed.betas;
|
|
106
|
+
}
|
|
107
|
+
// Normalize thinking block for adaptive (Opus 4.6 / Sonnet 4.6) vs manual (older models).
|
|
108
|
+
// Real CC always sends thinking:{type:"adaptive"} for adaptive models even if the
|
|
109
|
+
// upstream SDK didn't include it. Inject it when missing to match the fingerprint.
|
|
110
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "thinking")) {
|
|
111
|
+
parsed.thinking = normalizeThinkingBlock(parsed.thinking, parsed.model || "");
|
|
112
|
+
} else if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
113
|
+
parsed.thinking = { type: "adaptive" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fingerprint fix: real Claude Code v2.1.87+ nests the effort control inside
|
|
117
|
+
// `output_config.effort` (via Lyz() in cli.js). opencode's provider transform
|
|
118
|
+
// for variant=max on Opus 4.6 / Sonnet 4.6 sets `effort` at the top level,
|
|
119
|
+
// which causes Anthropic's server to fingerprint the body as non-CC and bill
|
|
120
|
+
// it as pay-as-you-go — surfacing as "You're out of extra usage" even on a
|
|
121
|
+
// valid Max plan. Move it into output_config when we're talking to an
|
|
122
|
+
// adaptive-thinking model so the wire shape matches real CC.
|
|
123
|
+
if (typeof parsed.effort === "string" && parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
124
|
+
if (!parsed.output_config || typeof parsed.output_config !== "object") {
|
|
125
|
+
parsed.output_config = {};
|
|
126
|
+
}
|
|
127
|
+
if (!("effort" in parsed.output_config)) {
|
|
128
|
+
parsed.output_config.effort = parsed.effort;
|
|
129
|
+
}
|
|
130
|
+
delete parsed.effort;
|
|
131
|
+
} else if (Object.prototype.hasOwnProperty.call(parsed, "effort")) {
|
|
132
|
+
// Non-adaptive models never carry a top-level effort in real CC — strip it
|
|
133
|
+
// to avoid polluting the fingerprint for models like Haiku.
|
|
134
|
+
delete parsed.effort;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Claude Code v2.1.117+: default effort for Pro/Max on adaptive models is
|
|
138
|
+
// "high" (was "medium"). If the host omits effort entirely, inject the
|
|
139
|
+
// default so the wire shape matches real CC.
|
|
140
|
+
if (parsed.model && isAdaptiveThinkingModel(parsed.model)) {
|
|
141
|
+
if (!parsed.output_config || typeof parsed.output_config !== "object") {
|
|
142
|
+
parsed.output_config = {};
|
|
143
|
+
}
|
|
144
|
+
if (!("effort" in parsed.output_config)) {
|
|
145
|
+
parsed.output_config.effort = "high";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Claude Code temperature rule: when extended thinking is active (any type),
|
|
150
|
+
// temperature must be omitted (undefined). Otherwise default to 1.
|
|
151
|
+
const thinkingActive =
|
|
152
|
+
parsed.thinking &&
|
|
153
|
+
typeof parsed.thinking === "object" &&
|
|
154
|
+
(parsed.thinking.type === "adaptive" || parsed.thinking.type === "enabled");
|
|
155
|
+
if (thinkingActive) {
|
|
156
|
+
// Anthropic API rejects temperature when thinking is enabled
|
|
157
|
+
delete parsed.temperature;
|
|
158
|
+
|
|
159
|
+
// Claude Code v2.1.84: inject the context_management body field ONLY when the
|
|
160
|
+
// user has explicitly opted in via token_economy.context_management. Note: as of
|
|
161
|
+
// v2.1.195 the context-management *beta header* is default-ON for first-party
|
|
162
|
+
// non-claude-3 models (see buildAnthropicBetaHeader), but the body field stays
|
|
163
|
+
// opt-in. A top-level context_management field WITHOUT the beta is rejected with
|
|
164
|
+
// "context_management: Extra inputs are not permitted"; the beta being present
|
|
165
|
+
// without the field is fine. Gating the field on explicit opt-in keeps the field
|
|
166
|
+
// ⊆ beta invariant, so the 400 never fires.
|
|
167
|
+
if (
|
|
168
|
+
config?.token_economy?.context_management &&
|
|
169
|
+
!CLAUDE_3_MODEL_RE.test(parsed.model || "") &&
|
|
170
|
+
!parsed.context_management
|
|
171
|
+
) {
|
|
172
|
+
parsed.context_management = {
|
|
173
|
+
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
|
|
178
|
+
parsed.temperature = 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Strip leaked /anthropic slash command messages from conversation history.
|
|
182
|
+
// OpenCode may include command text and sendCommandMessage output as regular
|
|
183
|
+
// user messages even when output.noReply = true was set. Filter them out
|
|
184
|
+
// so the agent never sees /anthropic commands in its context.
|
|
185
|
+
if (Array.isArray(parsed.messages)) {
|
|
186
|
+
parsed.messages = stripSlashCommandMessages(parsed.messages);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// === Token economy: layered message/history compaction ===
|
|
190
|
+
// Only applies to main-thread requests (subagents/title-gen stay untouched).
|
|
191
|
+
// Strategies stack in order: TTL thinking strip → proactive microcompact →
|
|
192
|
+
// trailing-summary trim → tool_result dedupe. Each is independently gated.
|
|
193
|
+
const te = config?.token_economy || {};
|
|
194
|
+
const tes = runtime?.tokenEconomySession;
|
|
195
|
+
const isMainRole = runtime?.requestRole === "main" || runtime?.requestRole == null;
|
|
196
|
+
|
|
197
|
+
// `conservative` (default ON) disables all history-rewriting and tool-array
|
|
198
|
+
// transforms. These optimizations shrink each request body but cause the
|
|
199
|
+
// prompt-cache prefix to change turn-to-turn, invalidating the 1h cache
|
|
200
|
+
// and forcing a fresh cache_write each turn — which costs 2x base input
|
|
201
|
+
// tokens. For long opencode sessions, cache reuse dominates; flip to
|
|
202
|
+
// `false` only if you have measurements showing otherwise. Adaptive
|
|
203
|
+
// thinking zero-out remains active (affects only the thinking budget,
|
|
204
|
+
// not cached content).
|
|
205
|
+
const conservative = te.conservative !== false;
|
|
206
|
+
|
|
207
|
+
if (!conservative && isMainRole && Array.isArray(parsed.messages) && tes) {
|
|
208
|
+
// (1) TTL-based thinking strip
|
|
209
|
+
if (te.ttl_thinking_strip !== false) {
|
|
210
|
+
const ttlMs = signature?.cachePolicy?.ttl === "5m" ? 5 * 60_000 : 60 * 60_000;
|
|
211
|
+
const res = applyTtlThinkingStrip(parsed.messages, {
|
|
212
|
+
lastClearMs: tes.lastThinkingStripMs,
|
|
213
|
+
ttlMs,
|
|
214
|
+
});
|
|
215
|
+
if (res.changed) {
|
|
216
|
+
parsed.messages = res.messages;
|
|
217
|
+
tes.lastThinkingStripMs = res.ranStripAt;
|
|
218
|
+
tes.thinkingStripped += res.cleared;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// (1b) Session-wide reproducible-tool result dedupe (Phase C C3, opt-in)
|
|
223
|
+
// Pure over message history; runs before microcompact so dedup'd stubs
|
|
224
|
+
// are visible to downstream size estimation. Gated by
|
|
225
|
+
// token_economy_strategies.tool_result_dedupe_session_wide (default off).
|
|
226
|
+
if (config?.token_economy_strategies?.tool_result_dedupe_session_wide === true) {
|
|
227
|
+
const res = applySessionToolResultDedupe(parsed.messages);
|
|
228
|
+
if (res.changed) parsed.messages = res.messages;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// (2) Proactive microcompact (client-side, pre-422)
|
|
232
|
+
if (te.proactive_microcompact !== false) {
|
|
233
|
+
const estimated = estimatePromptTokensFromParsed(parsed);
|
|
234
|
+
const cw = 200_000; // conservative — 1M models still benefit
|
|
235
|
+
const res = applyProactiveMicrocompact(parsed.messages, {
|
|
236
|
+
estimatedTokens: estimated,
|
|
237
|
+
contextWindow: cw,
|
|
238
|
+
percent: te.microcompact_percent ?? 70,
|
|
239
|
+
keepRecent: te.microcompact_keep_recent ?? 8,
|
|
240
|
+
});
|
|
241
|
+
if (res.changed) {
|
|
242
|
+
parsed.messages = res.messages;
|
|
243
|
+
tes.lastMicrocompactMs = Date.now();
|
|
244
|
+
tes.toolResultsCompacted += res.cleared;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// (3) Trailing-summary trim (opt-in)
|
|
249
|
+
if (te.trailing_summary_trim === true) {
|
|
250
|
+
const res = applyTrailingSummaryTrim(parsed.messages);
|
|
251
|
+
if (res.changed) parsed.messages = res.messages;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// (4) Cross-turn tool_result dedupe (opt-in)
|
|
255
|
+
if (te.tool_result_dedupe === true) {
|
|
256
|
+
const SAFE_READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "BashOutput"]);
|
|
257
|
+
const res = applyToolResultDedupe(parsed.messages, {
|
|
258
|
+
seen: tes.seenContentHashes,
|
|
259
|
+
safeTools: SAFE_READ_TOOLS,
|
|
260
|
+
});
|
|
261
|
+
if (res.changed) parsed.messages = res.messages;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// === Token economy: tool-array transforms (stable ordering, deferral) ===
|
|
266
|
+
if (!conservative && isMainRole && Array.isArray(parsed.tools)) {
|
|
267
|
+
if (te.stable_tool_ordering !== false) {
|
|
268
|
+
parsed.tools = applyStableToolOrdering(parsed.tools);
|
|
269
|
+
}
|
|
270
|
+
if (Array.isArray(te.deferred_tool_names) && te.deferred_tool_names.length > 0) {
|
|
271
|
+
// "Invoked" means any assistant message in the convo has used the tool.
|
|
272
|
+
const invoked = new Set();
|
|
273
|
+
if (Array.isArray(parsed.messages)) {
|
|
274
|
+
for (const m of parsed.messages) {
|
|
275
|
+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
276
|
+
for (const b of m.content) {
|
|
277
|
+
if (b?.type === "tool_use" && typeof b.name === "string") invoked.add(b.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const res = applyToolSchemaDeferral(parsed.tools, {
|
|
282
|
+
deferred: new Set(te.deferred_tool_names),
|
|
283
|
+
invoked,
|
|
284
|
+
});
|
|
285
|
+
parsed.tools = res.tools;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// === Token economy: adaptive thinking zero-out for trivial follow-ups ===
|
|
290
|
+
if (isMainRole && te.adaptive_thinking_zero_simple !== false) {
|
|
291
|
+
applyAdaptiveThinkingZero(parsed);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// QA fix H2: avoid mutating the signature parameter; capture modelId locally
|
|
295
|
+
const modelId = parsed.model || "";
|
|
296
|
+
// Extract first user message text for billing hash computation (cch)
|
|
297
|
+
const firstUserMessage = extractFirstUserMessageText(parsed.messages);
|
|
298
|
+
// Resolve the prompt-cache TTL ONCE so system blocks, tools, and messages
|
|
299
|
+
// all share the SAME ttl. Anthropic processes cache_control blocks in the
|
|
300
|
+
// order tools → system → messages and rejects any ttl='1h' block that comes
|
|
301
|
+
// AFTER a ttl='5m' block. Before this fix the role-scoped 5m downgrade was
|
|
302
|
+
// applied only to tools/messages (resolveCacheTtl in the breakpoint loop)
|
|
303
|
+
// while the system blocks kept the configured 1h — so subagent requests
|
|
304
|
+
// (5m tools, then 1h system) tripped:
|
|
305
|
+
// "system.1.cache_control.ttl: a ttl='1h' cache_control block must not
|
|
306
|
+
// come after a ttl='5m' cache_control block".
|
|
307
|
+
// Threading it through buildSystemPromptBlocks keeps every cache_control ttl
|
|
308
|
+
// consistent (real CC derives the ttl uniformly from querySource).
|
|
309
|
+
const baseCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
|
|
310
|
+
const cachingEnabledForTtl = baseCachePolicy.ttl !== "off" && baseCachePolicy.ttl_supported !== false;
|
|
311
|
+
const resolvedCacheTtl = cachingEnabledForTtl
|
|
312
|
+
? resolveCacheTtl({
|
|
313
|
+
configuredTtl: baseCachePolicy.ttl || "1h",
|
|
314
|
+
roleScopedTtl: config?.token_economy?.role_scoped_cache_ttl !== false,
|
|
315
|
+
isMainForCache: runtime?.requestRole === "main" || runtime?.requestRole == null,
|
|
316
|
+
isSubagent: runtime?.isSubagent === true,
|
|
317
|
+
env: process.env,
|
|
318
|
+
})
|
|
319
|
+
: baseCachePolicy.ttl;
|
|
320
|
+
const signatureWithModel = {
|
|
321
|
+
...signature,
|
|
322
|
+
modelId,
|
|
323
|
+
// Override cachePolicy.ttl with the role/subagent-resolved ttl so the
|
|
324
|
+
// system blocks match the tool/message breakpoint ttl (see comment above).
|
|
325
|
+
cachePolicy: { ...baseCachePolicy, ttl: resolvedCacheTtl },
|
|
326
|
+
firstUserMessage,
|
|
327
|
+
antiVerbosity: config?.anti_verbosity,
|
|
328
|
+
// Role-aware system-prompt leaning: for non-main-thread requests (title,
|
|
329
|
+
// small, empty shapes) strip billing identity + CC identity injection.
|
|
330
|
+
// Title-gen path is handled separately by isTitleGeneratorSystemBlocks().
|
|
331
|
+
// Default off — opt-in via `token_economy.lean_system_non_main: true`.
|
|
332
|
+
requestRole: runtime?.requestRole,
|
|
333
|
+
leanNonMain: config?.token_economy?.lean_system_non_main === true,
|
|
334
|
+
// Simple-system-prompt mode for Opus 4.7+ (CC v2.1.133+ parity, gated by
|
|
335
|
+
// GrowthBook `tengu_vellum_lantern` in real CC). Plugin gate: model
|
|
336
|
+
// eligibility + opt-in flag. See buildSystemPromptBlocks for what it
|
|
337
|
+
// strips (anti-verbosity boilerplate only; identity/billing untouched).
|
|
338
|
+
simpleSystemPrompt: config?.token_economy?.simple_system_prompt === true,
|
|
339
|
+
// Workload tag for x-anthropic-billing-header `cc_workload=` segment.
|
|
340
|
+
// Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
|
|
341
|
+
// SDK daemon callers spawning cron subprocesses). Empty string -> omit.
|
|
342
|
+
workload: typeof config?.signature_emulation?.workload === "string" ? config.signature_emulation.workload : "",
|
|
343
|
+
};
|
|
344
|
+
// Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
|
|
345
|
+
parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
|
|
346
|
+
|
|
347
|
+
// Strategy 5 — System prompt tailing: after N turns, trim large system blocks
|
|
348
|
+
// to essential sections only. The model has internalized verbose instructions
|
|
349
|
+
// (shell strategy, package manager tables, delegation protocols) by this point.
|
|
350
|
+
// Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
|
|
351
|
+
// IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
|
|
352
|
+
const tailThreshold = signature.systemPromptTailTurns ?? 6;
|
|
353
|
+
if (signature.systemPromptTailing === true && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
|
|
354
|
+
const maxChars = signature.systemPromptTailMaxChars ?? 2000;
|
|
355
|
+
for (let i = 0; i < parsed.system.length; i++) {
|
|
356
|
+
const block = parsed.system[i];
|
|
357
|
+
if (block.type === "text" && block.text && block.text.length > maxChars * 2) {
|
|
358
|
+
block.text = tailSystemBlock(block.text, maxChars, tailThreshold);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Token budget (A9): parse NL budget from last user message, inject status block
|
|
364
|
+
if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
|
|
365
|
+
const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
|
|
366
|
+
if (budgetExpr > 0) {
|
|
367
|
+
sessionMetrics.tokenBudget.limit = budgetExpr;
|
|
368
|
+
} else if (config.token_budget.default > 0 && sessionMetrics.tokenBudget.limit === 0) {
|
|
369
|
+
sessionMetrics.tokenBudget.limit = config.token_budget.default;
|
|
370
|
+
}
|
|
371
|
+
// If budget is active, inject status into system prompt
|
|
372
|
+
if (sessionMetrics.tokenBudget.limit > 0) {
|
|
373
|
+
const threshold = config.token_budget.completion_threshold ?? 0.9;
|
|
374
|
+
parsed.system = injectTokenBudgetBlock(parsed.system, sessionMetrics.tokenBudget, threshold);
|
|
375
|
+
// Soft stop: if we've exceeded the threshold, cap max_tokens to 1
|
|
376
|
+
if (sessionMetrics.tokenBudget.used >= sessionMetrics.tokenBudget.limit * threshold) {
|
|
377
|
+
parsed.max_tokens = 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (signature.enabled) {
|
|
383
|
+
const currentMetadata =
|
|
384
|
+
parsed.metadata && typeof parsed.metadata === "object" && !Array.isArray(parsed.metadata)
|
|
385
|
+
? parsed.metadata
|
|
386
|
+
: {};
|
|
387
|
+
parsed.metadata = {
|
|
388
|
+
...currentMetadata,
|
|
389
|
+
...buildRequestMetadata({
|
|
390
|
+
persistentUserId: runtime.persistentUserId,
|
|
391
|
+
accountId: runtime.accountId,
|
|
392
|
+
sessionId: runtime.sessionId,
|
|
393
|
+
}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Cache breakpoint optimization: add cache_control to the last content block
|
|
398
|
+
// of each user/assistant message for maximum prefix caching.
|
|
399
|
+
// Skip for round-robin strategy (cache defeated by account rotation).
|
|
400
|
+
// Skip for title generators / fire-and-forget queries: these are one-shot
|
|
401
|
+
// requests that don't benefit from caching and would pollute the cache pool.
|
|
402
|
+
const isTitleGen = isTitleGeneratorSystemBlocks(parsed.system || []);
|
|
403
|
+
if (
|
|
404
|
+
signature.enabled &&
|
|
405
|
+
signature.cachePolicy?.ttl !== "off" &&
|
|
406
|
+
signature.cachePolicy?.ttl_supported !== false &&
|
|
407
|
+
!isTitleGen
|
|
408
|
+
) {
|
|
409
|
+
// Strip ALL incoming cache_control from tools and messages to prevent
|
|
410
|
+
// TTL ordering violations (host SDK may set ttl=5m which conflicts with
|
|
411
|
+
// our system prompt ttl=1h). Then add our own to the last user message
|
|
412
|
+
// (matching real CC behavior seen in proxy capture).
|
|
413
|
+
//
|
|
414
|
+
// Role-scoped TTL: real CC's REH(querySource) decides the `ttl:"1h"` field.
|
|
415
|
+
// Decompiled from CC 2.1.154 (function REH, offset 225174828), precedence:
|
|
416
|
+
// 1. FORCE_PROMPT_CACHING_5M => 5m (highest priority)
|
|
417
|
+
// 2. ENABLE_PROMPT_CACHING_1H => 1h
|
|
418
|
+
// 3. no tengu support / overage => 5m
|
|
419
|
+
// 4. querySource ∈ allowlist [repl_main_thread*, sdk, auto_mode, ...] => 1h, else 5m
|
|
420
|
+
// We map step 4 via classifyRequestRole: main (interactive/auto_mode thread)
|
|
421
|
+
// → 1h; side-queries (title-gen, etc.) → 5m. The env vars are honored
|
|
422
|
+
// exactly as CC does, giving the user a manual override + mimicry fidelity.
|
|
423
|
+
// Reuse the request-wide resolved ttl (computed above where
|
|
424
|
+
// signatureWithModel is built) so tools/messages match the system blocks
|
|
425
|
+
// and never trip the 1h-after-5m ordering rule on subagent requests.
|
|
426
|
+
const ccTtl = resolvedCacheTtl;
|
|
427
|
+
if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
|
|
428
|
+
for (const tool of parsed.tools) {
|
|
429
|
+
if (tool.cache_control) delete tool.cache_control;
|
|
430
|
+
}
|
|
431
|
+
// Adaptive breakpoint placement (opt-in, runtime-driven). Real CC always
|
|
432
|
+
// pins the breakpoint on the last tool because CC controls a STABLE tool
|
|
433
|
+
// array. opencode may reorder/add/remove tools between turns, which moves
|
|
434
|
+
// the "last tool" and invalidates the cached prefix after it. When the
|
|
435
|
+
// detector reports the tool boundary is thrashing while the system prompt
|
|
436
|
+
// is stable, we SKIP the volatile last-tool breakpoint and let the stable
|
|
437
|
+
// system-prompt breakpoint (added by buildSystemPromptBlocks) anchor the
|
|
438
|
+
// cache. Falls back to exact CC behavior when no stability data exists.
|
|
439
|
+
const placeToolBreakpoint = shouldPlaceToolBreakpoint(runtime?.cacheBoundaryStability);
|
|
440
|
+
if (placeToolBreakpoint) {
|
|
441
|
+
parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (Array.isArray(parsed.messages)) {
|
|
445
|
+
for (const msg of parsed.messages) {
|
|
446
|
+
if (Array.isArray(msg.content)) {
|
|
447
|
+
for (const block of msg.content) {
|
|
448
|
+
// CONTRACT GUARD: `thinking` / `redacted_thinking` blocks MUST be
|
|
449
|
+
// round-tripped byte-identical to the model's original response
|
|
450
|
+
// (Anthropic extended-thinking rules). ANY mutation — including
|
|
451
|
+
// `delete block.cache_control` — triggers the 400 error
|
|
452
|
+
// "thinking or redacted_thinking blocks in the latest assistant
|
|
453
|
+
// message cannot be modified". `cache_control` is not even a valid
|
|
454
|
+
// field on a thinking block, so we never strip nor add it here;
|
|
455
|
+
// we leave the block exactly as received. This is required for
|
|
456
|
+
// adaptive-thinking models (Opus 4.6/4.7/4.8, Sonnet 4.6) on
|
|
457
|
+
// tool-continuation turns. See docs/mimese-http-header-system-prompt.md.
|
|
458
|
+
if (!block || typeof block !== "object") continue;
|
|
459
|
+
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (block.cache_control) delete block.cache_control;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Add cache_control to last user message (real CC does this).
|
|
467
|
+
// (User messages never contain thinking blocks, so the guard above
|
|
468
|
+
// does not affect this breakpoint.)
|
|
469
|
+
for (let i = parsed.messages.length - 1; i >= 0; i--) {
|
|
470
|
+
const msg = parsed.messages[i];
|
|
471
|
+
if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
|
|
472
|
+
const lastBlock = msg.content[msg.content.length - 1];
|
|
473
|
+
if (lastBlock && typeof lastBlock === "object") {
|
|
474
|
+
lastBlock.cache_control = { type: "ephemeral", ttl: ccTtl };
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Tool name sanitization: Anthropic's server blocklists known non-CC tool names.
|
|
482
|
+
// opencode uses lowercase names while CC uses PascalCase. While only "todowrite"
|
|
483
|
+
// is currently confirmed blocklisted, we rename ALL core opencode tools to match
|
|
484
|
+
// CC's naming convention as a preventive measure against future blocklist additions.
|
|
485
|
+
const OC_TO_CC_TOOL_NAMES = {
|
|
486
|
+
bash: "Bash",
|
|
487
|
+
read: "Read",
|
|
488
|
+
glob: "Glob",
|
|
489
|
+
grep: "Grep",
|
|
490
|
+
edit: "Edit",
|
|
491
|
+
write: "Write",
|
|
492
|
+
webfetch: "WebFetch",
|
|
493
|
+
todowrite: "TodoWrite",
|
|
494
|
+
skill: "Skill",
|
|
495
|
+
task: "Task",
|
|
496
|
+
compress: "Compress",
|
|
497
|
+
};
|
|
498
|
+
if (Array.isArray(parsed.tools)) {
|
|
499
|
+
for (const tool of parsed.tools) {
|
|
500
|
+
if (tool.name && OC_TO_CC_TOOL_NAMES[tool.name]) {
|
|
501
|
+
tool.name = OC_TO_CC_TOOL_NAMES[tool.name];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Also rename in tool_use blocks in messages (assistant responses referencing the tool)
|
|
506
|
+
if (Array.isArray(parsed.messages)) {
|
|
507
|
+
for (const msg of parsed.messages) {
|
|
508
|
+
if (!Array.isArray(msg.content)) continue;
|
|
509
|
+
for (const block of msg.content) {
|
|
510
|
+
if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES[block.name]) {
|
|
511
|
+
block.name = OC_TO_CC_TOOL_NAMES[block.name];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Track which tools the model has used (from assistant tool_use blocks).
|
|
517
|
+
// Names are already CC PascalCase after renaming above.
|
|
518
|
+
if (Array.isArray(parsed.messages)) {
|
|
519
|
+
for (const msg of parsed.messages) {
|
|
520
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
521
|
+
for (const block of msg.content) {
|
|
522
|
+
if (block.type === "tool_use" && block.name) {
|
|
523
|
+
runtime.usedTools.add(block.name);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Strategy 4 — Adaptive tool set: after turn 3, defer non-core tools that
|
|
531
|
+
// the model hasn't used yet. This saves schema bytes on turns where tools
|
|
532
|
+
// are unlikely to be needed. Core tools stay eager. Resets on /clear.
|
|
533
|
+
if (
|
|
534
|
+
Array.isArray(parsed.tools) &&
|
|
535
|
+
signature.adaptiveToolSet !== false &&
|
|
536
|
+
runtime.turns >= 3 &&
|
|
537
|
+
parsed.model &&
|
|
538
|
+
!/claude-3-|haiku/i.test(parsed.model)
|
|
539
|
+
) {
|
|
540
|
+
const used = runtime.usedTools;
|
|
541
|
+
for (const tool of parsed.tools) {
|
|
542
|
+
if (tool.name && !used.has(tool.name) && !CORE_TOOL_NAMES.has(tool.name)) {
|
|
543
|
+
tool.defer_loading = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Tool description compaction: apply the same compaction logic used for
|
|
549
|
+
// system prompts (strip examples, collapse whitespace, dedup lines) to tool
|
|
550
|
+
// descriptions. The top 4 tools (Bash 10.6K, TodoWrite 9.7K, Task 5.5K,
|
|
551
|
+
// Compress 4.5K) account for 30KB. Compaction typically saves 30-50%.
|
|
552
|
+
if (Array.isArray(parsed.tools) && signature.toolDescriptionCompaction !== false) {
|
|
553
|
+
for (const tool of parsed.tools) {
|
|
554
|
+
if (tool.description && tool.description.length > 500) {
|
|
555
|
+
tool.description = compactToolDescription(tool.description);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// MCP tool deferral: mark non-core tools with defer_loading: true.
|
|
561
|
+
// CC defers all MCP tools by default — the API omits their full schemas from token
|
|
562
|
+
// counting, only sending the tool name. When the model needs a deferred tool, it uses
|
|
563
|
+
// tool_reference to load the schema on demand. This saves ~20KB per turn.
|
|
564
|
+
// Core tools (OC_TO_CC_TOOL_NAMES) are always eager-loaded.
|
|
565
|
+
if (
|
|
566
|
+
Array.isArray(parsed.tools) &&
|
|
567
|
+
signature.toolDeferral !== false &&
|
|
568
|
+
parsed.model &&
|
|
569
|
+
!/claude-3-|haiku/i.test(parsed.model)
|
|
570
|
+
) {
|
|
571
|
+
const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
|
|
572
|
+
for (const tool of parsed.tools) {
|
|
573
|
+
if (tool.name && !coreToolNames.has(tool.name)) {
|
|
574
|
+
tool.defer_loading = true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Task budgets: when the task-budgets beta is active, preserve or inject output_config.
|
|
580
|
+
// The beta unlocks output_config.max_output_tokens for per-task budget control.
|
|
581
|
+
// Model-router compatibility: the beta header + output_config body are forwarded as-is.
|
|
582
|
+
if (betaHeader && betaHeader.includes("task-budgets-2026-03-13")) {
|
|
583
|
+
if (!parsed.output_config) {
|
|
584
|
+
// Default: set a reasonable per-task output budget for long-running agentic tasks.
|
|
585
|
+
// Claude Code tasks typically need generous output budgets.
|
|
586
|
+
parsed.output_config = { max_output_tokens: 16384 };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Context hint (CC v2.1.110+): pair the context-hint-2026-04-09 beta with
|
|
591
|
+
// the body field context_hint:{enabled:true}. Extracted from CC bundle
|
|
592
|
+
// buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
|
|
593
|
+
// When active, the server responds with optional hints suggesting which
|
|
594
|
+
// messages to drop for token-efficient context compaction.
|
|
595
|
+
if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
|
|
596
|
+
parsed.context_hint = { enabled: true };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Fast mode: inject speed parameter for fast-mode-eligible models.
|
|
600
|
+
// Per Anthropic fast-mode docs, `speed: "fast"` (beta fast-mode-2026-02-01) is
|
|
601
|
+
// supported on Opus 4.6, Opus 4.7, and Opus 4.8 (research preview). Opus 4.7
|
|
602
|
+
// is the /fast default in real Claude Code v2.1.142+. Sonnet is NOT eligible.
|
|
603
|
+
// NOTE: switching speed invalidates system + message prompt caches (per
|
|
604
|
+
// Anthropic fast-mode docs), so only flip it deliberately.
|
|
605
|
+
// When the selected account's fast pool is cooling down (a prior fast 429),
|
|
606
|
+
// suppress speed:"fast" entirely so this turn runs at standard speed on the
|
|
607
|
+
// same account rather than re-hitting the exhausted fast pool.
|
|
608
|
+
const fastPoolAvailable = !signature.fastRateLimited;
|
|
609
|
+
const isFastModeEligibleModel = (m) =>
|
|
610
|
+
fastPoolAvailable && (isOpus46Model(m) || isOpus47Model(m) || isOpus48Model(m));
|
|
611
|
+
const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
|
|
612
|
+
let fastModeAutoApplied = false;
|
|
613
|
+
if (
|
|
614
|
+
!fastModeEnabled &&
|
|
615
|
+
te.fast_mode_auto === true &&
|
|
616
|
+
isMainRole &&
|
|
617
|
+
parsed.model &&
|
|
618
|
+
isFastModeEligibleModel(parsed.model) &&
|
|
619
|
+
Array.isArray(parsed.messages) &&
|
|
620
|
+
parsed.messages.length >= 2
|
|
621
|
+
) {
|
|
622
|
+
// Simple exchange heuristic: last user message is short, no tool_result,
|
|
623
|
+
// no file references. Suggests a follow-up question that doesn't need
|
|
624
|
+
// deep reasoning.
|
|
625
|
+
const last = parsed.messages[parsed.messages.length - 1];
|
|
626
|
+
if (last && last.role === "user") {
|
|
627
|
+
let txt = "";
|
|
628
|
+
let hasToolResult = false;
|
|
629
|
+
if (typeof last.content === "string") txt = last.content;
|
|
630
|
+
else if (Array.isArray(last.content)) {
|
|
631
|
+
for (const b of last.content) {
|
|
632
|
+
if (b?.type === "tool_result") hasToolResult = true;
|
|
633
|
+
if (b?.type === "text" && typeof b.text === "string") txt += b.text;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (!hasToolResult && txt.length < 240 && !/\bfile:|\.md\b|\.mjs\b|\.ts\b/i.test(txt)) {
|
|
637
|
+
fastModeAutoApplied = true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isFastModeEligibleModel(parsed.model)) {
|
|
642
|
+
parsed.speed = "fast";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Guard: repair orphaned tool_use blocks anywhere in the message array.
|
|
646
|
+
// The Anthropic API requires that every assistant message containing tool_use
|
|
647
|
+
// blocks is immediately followed by a user message with matching tool_result
|
|
648
|
+
// blocks. When OpenCode crashes/hangs mid-tool-execution, the conversation
|
|
649
|
+
// state may be saved with unpaired tool_use blocks. This causes:
|
|
650
|
+
// "messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
651
|
+
// immediately after: toolu_XXXXX"
|
|
652
|
+
// We scan the full array and synthesize missing tool_result messages.
|
|
653
|
+
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
654
|
+
parsed.messages = repairOrphanedToolUseBlocks(parsed.messages);
|
|
655
|
+
|
|
656
|
+
// Also ensure the array never ends with an assistant message (prefill guard).
|
|
657
|
+
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
658
|
+
if (lastMsg && lastMsg.role === "assistant") {
|
|
659
|
+
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
660
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
661
|
+
if (toolUseBlocks.length > 0) {
|
|
662
|
+
parsed.messages.push({
|
|
663
|
+
role: "user",
|
|
664
|
+
content: toolUseBlocks.map((tu) => ({
|
|
665
|
+
type: "tool_result",
|
|
666
|
+
tool_use_id: tu.id,
|
|
667
|
+
content: "[Result unavailable — conversation was restructured]",
|
|
668
|
+
})),
|
|
669
|
+
});
|
|
670
|
+
} else {
|
|
671
|
+
parsed.messages.push({
|
|
672
|
+
role: "user",
|
|
673
|
+
content: [{ type: "text", text: "Continue." }],
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return JSON.stringify(parsed);
|
|
680
|
+
} catch {
|
|
681
|
+
// ignore parse errors
|
|
682
|
+
return body;
|
|
683
|
+
}
|
|
684
|
+
}
|