opencode-anthropic-fix 0.1.32 → 0.2.1

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