opencode-anthropic-fix 2.1.1 → 2.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.
@@ -0,0 +1,343 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createAnthropicRuntime } from "./runtime.mjs";
5
+ import { registerAnthropicCommandRpc, registerAnthropicServerCommand } from "./command-rpc.mjs";
6
+ import { setupV2Transport } from "./v2-transport.mjs";
7
+ import { V2_SDK_PACKAGE } from "./v2-language.mjs";
8
+ import { prepareV2Tools } from "./tool-names.mjs";
9
+ import { applyV2MessagePolicies, assertPortableV2History } from "./v2-context.mjs";
10
+ import { isTruthyEnv } from "../env.mjs";
11
+ import { getConfigDir } from "../config.mjs";
12
+ import { redactString } from "../redact.mjs";
13
+ import { hasOneMillionContext, isOpus46Model, isOpus47Model, isOpus48Model } from "../mimicry/wire-compat.mjs";
14
+
15
+ export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
16
+ // Keep the AI SDK route while resolving the shipped factory locally. The host's
17
+ // built-in dynamic hook executes before ours, so a registry spec adds an install.
18
+ export const V2_MODEL_PACKAGE = `aisdk:${V2_SDK_PACKAGE}`;
19
+
20
+ // Shared by plugin instances so reload/disposal and user cancels do not spam stderr.
21
+ let reportedInterruptedFailure = false;
22
+
23
+ /** @param {object} auth */
24
+ function credential(auth) {
25
+ if (!auth?.access || !auth?.refresh) throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
26
+ return {
27
+ type: "oauth",
28
+ methodID: OAUTH_METHOD_ID,
29
+ access: auth.access,
30
+ refresh: auth.refresh,
31
+ expires: auth.expires,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * v2 owns registration and presentation; the shared runtime owns credentials,
37
+ * request construction and retries. No v1 hooks are returned to the v2 host.
38
+ * @param {import('@opencode/plugin').Plugin.Context} context
39
+ * @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport, transportErrorDir?: string}} [dependencies]
40
+ */
41
+ export async function setupV2(context, dependencies = {}) {
42
+ const runtime = (dependencies.createRuntime ?? createAnthropicRuntime)({
43
+ interactiveOAuth: false,
44
+ hostCompatShim: true, // @ai-sdk/anthropic 3.0.111 is pinned, even when the host omits its UA.
45
+ prepareRequest: prepareV2Tools,
46
+ });
47
+ const commands = new AsyncLocalStorage();
48
+ const registrations = [];
49
+ let disposed = false;
50
+ let managed = false;
51
+ let poolAvailable = false;
52
+ let ownedConnection = false;
53
+ const knownOwnedConnections = new Set();
54
+ let fetchPromise;
55
+ let connection;
56
+ let config;
57
+ let transport;
58
+ let refreshStatePromise;
59
+ let stateSignature;
60
+ let warnedAboutHaikuSummary = false;
61
+
62
+ function assertActive() {
63
+ if (disposed) throw new Error("Anthropic plugin is disposed");
64
+ }
65
+
66
+ async function dispose() {
67
+ if (disposed) return;
68
+ disposed = true;
69
+ const failures = [];
70
+ for (const resource of [transport, ...registrations.reverse(), runtime]) {
71
+ try {
72
+ await resource?.dispose();
73
+ } catch (error) {
74
+ failures.push(error);
75
+ }
76
+ }
77
+ if (failures.length) throw new AggregateError(failures, "Failed to dispose Anthropic plugin resources");
78
+ }
79
+
80
+ async function refreshState() {
81
+ assertActive();
82
+ if (refreshStatePromise) return refreshStatePromise;
83
+ refreshStatePromise = (async () => {
84
+ runtime.reloadConfig();
85
+ poolAvailable = Boolean(await runtime.getCurrentAuth({ allowExpired: true }));
86
+ assertActive();
87
+ connection = await context.integration.connection.active("anthropic");
88
+ assertActive();
89
+ // An explicitly selected API key/environment connection remains native.
90
+ // Existing pools can be used before connecting a v2 credential.
91
+ const connectionKey = JSON.stringify(connection);
92
+ ownedConnection = knownOwnedConnections.has(connectionKey);
93
+ if (connection?.type === "credential" && connection.method === "oauth") {
94
+ try {
95
+ const activeCredential = await context.integration.connection.resolve(connection);
96
+ assertActive();
97
+ ownedConnection = activeCredential?.methodID === OAUTH_METHOD_ID;
98
+ if (ownedConnection) knownOwnedConnections.add(connectionKey);
99
+ else knownOwnedConnections.delete(connectionKey);
100
+ } catch (error) {
101
+ if (disposed) throw error;
102
+ // The host may attempt to refresh an expired credential after its
103
+ // account was removed. Keep administration available and preserve a
104
+ // known owned connection's disabled state; never print token errors.
105
+ console.warn(
106
+ "[anthropic] The selected OAuth connection could not be resolved. Reconnect through /anthropic login.",
107
+ );
108
+ }
109
+ }
110
+ managed = poolAvailable && (!connection || ownedConnection);
111
+ config = structuredClone(runtime.getConfig());
112
+ if (managed && config.token_economy_strategies.haiku_rolling_summary && !warnedAboutHaikuSummary) {
113
+ warnedAboutHaikuSummary = true;
114
+ console.warn(
115
+ "[anthropic] haiku_rolling_summary requires a v1 fork hook; OpenCode v2 uses summary compaction instead.",
116
+ );
117
+ }
118
+ const nextSignature = JSON.stringify({
119
+ managed,
120
+ ownedConnection,
121
+ connection,
122
+ limits: config.override_model_limits,
123
+ });
124
+ if (nextSignature !== stateSignature) {
125
+ stateSignature = nextSignature;
126
+ fetchPromise = undefined;
127
+ await context.provider.reload();
128
+ }
129
+ })().finally(() => {
130
+ refreshStatePromise = undefined;
131
+ });
132
+ return refreshStatePromise;
133
+ }
134
+
135
+ try {
136
+ const hooks = await runtime.initialize({
137
+ client: {
138
+ // The runtime persists the rotated token under its cross-process lock.
139
+ // v2 persists the returned credential only through its OAuth callbacks;
140
+ // there is deliberately no invented credential-update API here.
141
+ auth: {
142
+ async set() {
143
+ return undefined;
144
+ },
145
+ },
146
+ session: {
147
+ async prompt({ body }) {
148
+ const output = commands.getStore();
149
+ if (!output) throw new Error("Administrative output requires an Anthropic command invocation");
150
+ output.push(...body.parts.filter((part) => part.type === "text").map((part) => part.text));
151
+ },
152
+ },
153
+ tui: {
154
+ async showToast({ body }) {
155
+ const output = commands.getStore();
156
+ if (output) output.push(body.message);
157
+ },
158
+ },
159
+ },
160
+ });
161
+
162
+ const isManagedModel = (model) => managed && model?.providerID === "anthropic";
163
+ transport = await (dependencies.setupTransport ?? setupV2Transport)(context, {
164
+ isManagedModel,
165
+ onError(error, summary) {
166
+ // Never log the Error itself: its preserved cause may contain secrets.
167
+ const message = redactString(error.message);
168
+ const interrupted = summary?.interrupted === true;
169
+ const reportInterruption = interrupted && !reportedInterruptedFailure;
170
+ if (interrupted) reportedInterruptedFailure = true;
171
+ if (!interrupted) console.error(`[anthropic] ${message}`);
172
+ let sinkFailure;
173
+ try {
174
+ const directory = dependencies.transportErrorDir ?? getConfigDir();
175
+ mkdirSync(directory, { recursive: true });
176
+ const file = join(directory, "transport-errors.log");
177
+ const rotatedFile = `${file}.1`;
178
+ if (existsSync(file) && statSync(file).size > 2 * 1024 * 1024) {
179
+ if (existsSync(rotatedFile)) unlinkSync(rotatedFile);
180
+ renameSync(file, rotatedFile);
181
+ }
182
+ const entry = {
183
+ timestamp: new Date().toISOString(),
184
+ ...(summary ?? { name: redactString(error.name), message }),
185
+ };
186
+ writeFileSync(file, `${JSON.stringify(entry)}\n`, { flag: "a", mode: 0o600 });
187
+ } catch (sinkError) {
188
+ const detail = sinkError instanceof Error ? sinkError.message : sinkError;
189
+ sinkFailure = `Failed to write transport-errors.log: ${redactString(detail)}`;
190
+ if (!interrupted) console.error(`[anthropic] ${sinkFailure}`);
191
+ }
192
+ if (reportInterruption) {
193
+ console.error(`[anthropic] ${message}${sinkFailure ? ` ${sinkFailure}` : ""}`);
194
+ }
195
+ },
196
+ async fetch(input, init) {
197
+ if (disposed) throw new Error("Anthropic plugin is disposed");
198
+ if (!managed) throw new Error("The Anthropic OAuth connection changed. Retry with the selected connection.");
199
+ fetchPromise ??= hooks.auth.loader(
200
+ async () => (await runtime.getCurrentAuth({ allowExpired: true })) ?? { type: "none" },
201
+ { models: {} },
202
+ );
203
+ const executor = await fetchPromise;
204
+ if (typeof executor.fetch !== "function") {
205
+ fetchPromise = undefined;
206
+ throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
207
+ }
208
+ return executor.fetch(input, init);
209
+ },
210
+ });
211
+
212
+ registrations.push(
213
+ await context.integration.transform((editor) => {
214
+ editor.method.update({
215
+ integrationID: "anthropic",
216
+ method: { id: OAUTH_METHOD_ID, type: "oauth", label: "Claude Pro/Max (multi-account)" },
217
+ async authorize() {
218
+ const flow = await runtime.authorizeOAuth();
219
+ return {
220
+ url: flow.url,
221
+ instructions: flow.instructions,
222
+ mode: "code",
223
+ async callback(code) {
224
+ const result = await flow.callback(code);
225
+ if (result.type !== "success") throw new Error("Anthropic authorization failed. Start login again.");
226
+ fetchPromise = undefined;
227
+ await refreshState();
228
+ return credential(result);
229
+ },
230
+ };
231
+ },
232
+ async refresh() {
233
+ // Disk stays authoritative after logout/removal and cross-process refresh.
234
+ return credential(await runtime.getCurrentAuth({ refresh: true }));
235
+ },
236
+ label: () => "Claude Pro/Max account pool",
237
+ });
238
+ }),
239
+ );
240
+
241
+ registrations.push(
242
+ await context.provider.transform((editor) => {
243
+ if ((!managed && !ownedConnection) || !editor.get("anthropic")) return;
244
+ editor.update("anthropic", (provider) => {
245
+ if (!poolAvailable) {
246
+ // A stale host credential must not resurrect an account removed
247
+ // from the authoritative pool by silently reverting to native.
248
+ provider.activation = "disabled";
249
+ return;
250
+ }
251
+ provider.activation = "enabled";
252
+ provider.package = V2_MODEL_PACKAGE;
253
+ provider.settings = { ...provider.settings, apiKey: "opencode-managed-oauth" };
254
+ // Core 2.0.22+ defaults to five-minute header/chunk limits. The
255
+ // executor has attempt budgets, not a total deadline (transient 429s
256
+ // and Retry-After waits can exceed five minutes). Do not race it with
257
+ // host timers. Auxiliary work has its own deadline in the bridge.
258
+ provider.settings.timeout = false;
259
+ provider.settings.headerTimeout = false;
260
+ // 2.0.21's public schema only accepts a number here; its fetch wrapper
261
+ // enables the timer only for values > 0. 2.0.22 requires false instead.
262
+ provider.settings.chunkTimeout = context.app?.version === "2.0.21" ? 0 : false;
263
+ // The compatibility route uses the host's summary compaction instead.
264
+ provider.settings.compaction = { type: "summary" };
265
+ });
266
+ }),
267
+ );
268
+ registrations.push(
269
+ await context.model.transform((editor) => {
270
+ if (!managed) return;
271
+ for (const candidate of editor.list("anthropic")) {
272
+ editor.update(candidate.providerID, candidate.id, (model) => {
273
+ model.package = V2_MODEL_PACKAGE;
274
+ model.settings = { ...model.settings };
275
+ model.settings.compaction = { type: "summary" };
276
+ // The host merges a selected variant after the base model settings.
277
+ for (const variant of model.variants ?? []) {
278
+ variant.settings = { ...variant.settings, compaction: { type: "summary" } };
279
+ }
280
+ model.cost = model.cost.map((tier) => ({ ...tier, input: 0, output: 0, cache: { read: 0, write: 0 } }));
281
+ if (
282
+ config.override_model_limits.enabled &&
283
+ !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
284
+ (hasOneMillionContext(model.id) ||
285
+ isOpus46Model(model.id) ||
286
+ isOpus47Model(model.id) ||
287
+ isOpus48Model(model.id))
288
+ ) {
289
+ model.limit.context = config.override_model_limits.context;
290
+ if (config.override_model_limits.output > 0) model.limit.output = config.override_model_limits.output;
291
+ }
292
+ });
293
+ }
294
+ }),
295
+ );
296
+
297
+ for (const name of ["context", "compaction", "generate", "title"]) {
298
+ registrations.push(
299
+ await context.session.hook(
300
+ name,
301
+ async (event) => {
302
+ await refreshState();
303
+ if (!isManagedModel(event.model)) return;
304
+ assertPortableV2History(event.messages);
305
+ applyV2MessagePolicies(event.messages, runtime.getConfig().token_economy_strategies);
306
+ await hooks["experimental.chat.system.transform"]({ model: event.model }, { system: event.system });
307
+ for (let i = 0; i < event.system.length; i++) {
308
+ if (typeof event.system[i] === "string") event.system[i] = { type: "text", text: event.system[i] };
309
+ }
310
+ if (name === "compaction") {
311
+ const output = { context: [] };
312
+ await hooks["experimental.session.compacting"]({ sessionID: event.sessionID }, output);
313
+ event.system.push(...output.context.map((text) => ({ type: "text", text })));
314
+ }
315
+ },
316
+ { providerID: "anthropic" },
317
+ ),
318
+ );
319
+ }
320
+
321
+ registrations.push(await registerAnthropicServerCommand(context));
322
+ registrations.push(
323
+ await registerAnthropicCommandRpc(context, async (input) => {
324
+ const output = [];
325
+ await commands.run(output, () => runtime.executeCommand(input));
326
+ fetchPromise = undefined;
327
+ await refreshState();
328
+ return output.join("\n\n");
329
+ }),
330
+ );
331
+ await refreshState();
332
+ return dispose;
333
+ } catch (error) {
334
+ try {
335
+ await dispose();
336
+ } catch (cleanupError) {
337
+ throw new AggregateError([error, cleanupError], "Anthropic v2 initialization and cleanup failed", {
338
+ cause: cleanupError,
339
+ });
340
+ }
341
+ throw error;
342
+ }
343
+ }
@@ -34,7 +34,9 @@ import {
34
34
  repairOrphanedToolUseBlocks,
35
35
  } from "./request-helpers.mjs";
36
36
  import { isFalsyEnv, isTruthyEnv } from "../env.mjs";
37
- import { sessionMetrics } from "../session-metrics.mjs";
37
+ import { sessionMetrics as legacySessionMetrics } from "../session-metrics.mjs";
38
+
39
+ const THINKING_DISPLAYS = new Set(["summarized", "omitted"]);
38
40
 
39
41
  /**
40
42
  * Coerce an incoming `thinking` block to the shape the model actually accepts.
@@ -65,7 +67,12 @@ export function normalizeThinkingBlock(thinking, model) {
65
67
  const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
66
68
  return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
67
69
  }
68
- return { type: "adaptive" };
70
+ // Keep the caller's display: dropping it makes the wire builder fall back to "updates",
71
+ // which returns empty thinking text on models that default to "omitted" (Opus 5.5, Fable 5.1).
72
+ // Only the values the wire builder honors are kept.
73
+ return THINKING_DISPLAYS.has(thinking.display)
74
+ ? { type: "adaptive", display: thinking.display }
75
+ : { type: "adaptive" };
69
76
  }
70
77
 
71
78
  // Non-adaptive models: pass through unchanged
@@ -87,6 +94,62 @@ export const CORE_TOOL_NAMES = new Set([
87
94
  "Compress",
88
95
  ]);
89
96
 
97
+ /**
98
+ * True for a text block that holds only an opencode-model-router turn-state tail
99
+ * (`<router-state>…</router-state>`): per-request state the host never persists.
100
+ *
101
+ * @param {unknown} block
102
+ * @returns {boolean}
103
+ */
104
+ function isRouterStateBlock(block) {
105
+ if (!block || typeof block !== "object" || block.type !== "text" || typeof block.text !== "string") return false;
106
+ const text = block.text.trim();
107
+ return text.startsWith("<router-state>") && text.endsWith("</router-state>");
108
+ }
109
+
110
+ /**
111
+ * Put the one message cache breakpoint on the last block of the last user
112
+ * message (real CC behaviour). When that block is a trailing `<router-state>`
113
+ * tail, the breakpoint goes on the nearest earlier block instead, so the cache
114
+ * entry ends on content the next request repeats; thinking, redacted thinking
115
+ * and blank text blocks are never marked. Without a tail, behaviour is unchanged.
116
+ *
117
+ * @param {Array<{role?: string, content?: unknown}>} messages
118
+ * @param {{type: string, ttl?: string}} marker
119
+ */
120
+ function placeMessageBreakpoint(messages, marker) {
121
+ for (let i = messages.length - 1; i >= 0; i--) {
122
+ const msg = messages[i];
123
+ if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
124
+ const lastBlock = msg.content[msg.content.length - 1];
125
+ if (!isRouterStateBlock(lastBlock)) {
126
+ if (lastBlock && typeof lastBlock === "object") lastBlock.cache_control = marker;
127
+ return;
128
+ }
129
+ for (let m = i; m >= 0; m--) {
130
+ const content = messages[m].content;
131
+ if (typeof content === "string") {
132
+ // Anthropic's string shorthand is one text block; normalize it so the
133
+ // breakpoint can sit on it (same tokens, so the cached prefix is unchanged).
134
+ if (content.trim() === "" || isRouterStateBlock({ type: "text", text: content })) continue;
135
+ messages[m].content = [{ type: "text", text: content, cache_control: marker }];
136
+ return;
137
+ }
138
+ if (!Array.isArray(content)) continue;
139
+ const start = m === i ? content.length - 2 : content.length - 1;
140
+ for (let b = start; b >= 0; b--) {
141
+ const block = content[b];
142
+ if (!block || typeof block !== "object" || isRouterStateBlock(block)) continue;
143
+ if (block.type === "thinking" || block.type === "redacted_thinking") continue;
144
+ if (block.type === "text" && (typeof block.text !== "string" || block.text.trim() === "")) continue;
145
+ block.cache_control = marker;
146
+ return;
147
+ }
148
+ }
149
+ return;
150
+ }
151
+ }
152
+
90
153
  /**
91
154
  * Transform the request body: system prompt sanitization and tool prefixing.
92
155
  * Preserves behaviors E1-E7.
@@ -103,6 +166,7 @@ export const CORE_TOOL_NAMES = new Set([
103
166
  */
104
167
  export function transformRequestBody(body, signature, runtime, taskBudgetsActive, config) {
105
168
  if (!body || typeof body !== "string") return body;
169
+ const sessionMetrics = runtime?.sessionMetrics ?? legacySessionMetrics;
106
170
 
107
171
  try {
108
172
  const parsed = JSON.parse(body);
@@ -123,7 +187,7 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
123
187
  }
124
188
  // Output cap: resolve max_tokens before any other body transforms
125
189
  if (config?.output_cap?.enabled) {
126
- parsed.max_tokens = resolveMaxTokens(parsed, config);
190
+ parsed.max_tokens = resolveMaxTokens(parsed, config, sessionMetrics);
127
191
  }
128
192
  // The body-level `anthropic_beta` array existed only for Bedrock, which does
129
193
  // not forward custom HTTP headers. Multi-provider support was removed, and
@@ -500,18 +564,18 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
500
564
  }
501
565
  }
502
566
  }
503
- // Add cache_control to last user message (real CC does this).
504
- // (User messages never contain thinking blocks, so the guard above
505
- // does not affect this breakpoint.)
506
- for (let i = parsed.messages.length - 1; i >= 0; i--) {
507
- const msg = parsed.messages[i];
508
- if (msg.role !== "user" || !Array.isArray(msg.content) || msg.content.length === 0) continue;
509
- const lastBlock = msg.content[msg.content.length - 1];
510
- if (lastBlock && typeof lastBlock === "object") {
511
- lastBlock.cache_control = { type: "ephemeral", ttl: ccTtl };
512
- }
513
- break;
514
- }
567
+ // At most one message breakpoint (see placeMessageBreakpoint). Normally
568
+ // it goes on the last block of the last user message with array
569
+ // content (real CC does this). When that block is a per-request
570
+ // `<router-state>` tail (opencode-model-router's turn state, never
571
+ // persisted, so a breakpoint on it writes an entry the next request
572
+ // cannot read), it walks back to the nearest earlier eligible block:
573
+ // the rest of that message, then earlier messages of any role, newest
574
+ // first, including assistant messages. The walk-back skips thinking,
575
+ // redacted_thinking, blank text and other router-state blocks, so the
576
+ // guard above still holds; earlier string content is normalized to one
577
+ // text block. With no eligible block, no message breakpoint is placed.
578
+ placeMessageBreakpoint(parsed.messages, { type: "ephemeral", ttl: ccTtl });
515
579
  }
516
580
  }
517
581
 
@@ -519,23 +583,25 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
519
583
  // opencode uses lowercase names while CC uses PascalCase. While only "todowrite"
520
584
  // is currently confirmed blocklisted, we rename ALL core opencode tools to match
521
585
  // CC's naming convention as a preventive measure against future blocklist additions.
522
- const OC_TO_CC_TOOL_NAMES = {
523
- bash: "Bash",
524
- read: "Read",
525
- glob: "Glob",
526
- grep: "Grep",
527
- edit: "Edit",
528
- write: "Write",
529
- webfetch: "WebFetch",
530
- todowrite: "TodoWrite",
531
- skill: "Skill",
532
- task: "Task",
533
- compress: "Compress",
534
- };
586
+ const OC_TO_CC_TOOL_NAMES = new Map(
587
+ Object.entries({
588
+ bash: "Bash",
589
+ read: "Read",
590
+ glob: "Glob",
591
+ grep: "Grep",
592
+ edit: "Edit",
593
+ write: "Write",
594
+ webfetch: "WebFetch",
595
+ todowrite: "TodoWrite",
596
+ skill: "Skill",
597
+ task: "Task",
598
+ compress: "Compress",
599
+ }),
600
+ );
535
601
  if (Array.isArray(parsed.tools)) {
536
602
  for (const tool of parsed.tools) {
537
- if (tool.name && OC_TO_CC_TOOL_NAMES[tool.name]) {
538
- tool.name = OC_TO_CC_TOOL_NAMES[tool.name];
603
+ if (tool.name && OC_TO_CC_TOOL_NAMES.has(tool.name)) {
604
+ tool.name = OC_TO_CC_TOOL_NAMES.get(tool.name);
539
605
  }
540
606
  }
541
607
  }
@@ -544,8 +610,8 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
544
610
  for (const msg of parsed.messages) {
545
611
  if (!Array.isArray(msg.content)) continue;
546
612
  for (const block of msg.content) {
547
- if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES[block.name]) {
548
- block.name = OC_TO_CC_TOOL_NAMES[block.name];
613
+ if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES.has(block.name)) {
614
+ block.name = OC_TO_CC_TOOL_NAMES.get(block.name);
549
615
  }
550
616
  }
551
617
  }
@@ -605,7 +671,7 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
605
671
  parsed.model &&
606
672
  !/claude-3-|haiku/i.test(parsed.model)
607
673
  ) {
608
- const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
674
+ const coreToolNames = new Set(OC_TO_CC_TOOL_NAMES.values());
609
675
  for (const tool of parsed.tools) {
610
676
  if (tool.name && !coreToolNames.has(tool.name)) {
611
677
  tool.defer_loading = true;
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Leaf helpers used by transformRequestBody, extracted from index.mjs so the
6
6
  // orchestrator can become a thin importer. All are pure except resolveMaxTokens,
7
- // which reads/writes the shared sessionMetrics.lastStopReason singleton.
7
+ // which reads/writes the caller's session metrics (or the legacy default).
8
8
 
9
9
  import { sessionMetrics } from "../session-metrics.mjs";
10
10
 
@@ -219,25 +219,26 @@ export function buildRequestMetadata(input) {
219
219
  /**
220
220
  * Resolve the outgoing max_tokens honoring the output_cap config and a
221
221
  * one-turn escalation when the previous stream stopped on max_tokens.
222
- * Reads/writes the shared sessionMetrics.lastStopReason singleton.
222
+ * Reads/writes the injected metrics; standalone callers retain the legacy default.
223
223
  * @param {{max_tokens?: number}} body
224
224
  * @param {any} config
225
+ * @param {typeof sessionMetrics} [metrics]
225
226
  * @returns {number|undefined}
226
227
  */
227
- export function resolveMaxTokens(body, config) {
228
+ export function resolveMaxTokens(body, config, metrics = sessionMetrics) {
228
229
  if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
229
230
  if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
230
231
  // QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
231
232
  // completes. The timing works correctly for "escalate for one turn" because this function runs
232
233
  // BEFORE the next request's stream starts. If the response pipeline changes to update stop
233
234
  // reason mid-stream or before response completion, this ordering assumption would break.
234
- const escalated = sessionMetrics.lastStopReason === "max_tokens";
235
+ const escalated = metrics.lastStopReason === "max_tokens";
235
236
  const result = escalated
236
237
  ? (config.output_cap.escalated_max_tokens ?? 64_000)
237
238
  : (config.output_cap.default_max_tokens ?? 8_000);
238
239
  // Reset after escalation is consumed (sticky for exactly one turn)
239
240
  if (escalated) {
240
- sessionMetrics.lastStopReason = null;
241
+ metrics.lastStopReason = null;
241
242
  }
242
243
  return result;
243
244
  }