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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. 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
+ }