opencode-anthropic-fix 2.1.1 → 2.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.
@@ -0,0 +1,305 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createAnthropicRuntime } from "./runtime.mjs";
3
+ import { registerAnthropicCommandRpc, registerAnthropicServerCommand } from "./command-rpc.mjs";
4
+ import { setupV2Transport } from "./v2-transport.mjs";
5
+ import { V2_SDK_PACKAGE } from "./v2-language.mjs";
6
+ import { prepareV2Tools } from "./tool-names.mjs";
7
+ import { applyV2MessagePolicies, assertPortableV2History } from "./v2-context.mjs";
8
+ import { isTruthyEnv } from "../env.mjs";
9
+ import { hasOneMillionContext, isOpus46Model, isOpus47Model, isOpus48Model } from "../mimicry/wire-compat.mjs";
10
+
11
+ export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
12
+ // Keep the AI SDK route while resolving the shipped factory locally. The host's
13
+ // built-in dynamic hook executes before ours, so a registry spec adds an install.
14
+ export const V2_MODEL_PACKAGE = `aisdk:${V2_SDK_PACKAGE}`;
15
+
16
+ /** @param {object} auth */
17
+ function credential(auth) {
18
+ if (!auth?.access || !auth?.refresh) throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
19
+ return {
20
+ type: "oauth",
21
+ methodID: OAUTH_METHOD_ID,
22
+ access: auth.access,
23
+ refresh: auth.refresh,
24
+ expires: auth.expires,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * v2 owns registration and presentation; the shared runtime owns credentials,
30
+ * request construction and retries. No v1 hooks are returned to the v2 host.
31
+ * @param {import('@opencode/plugin').Plugin.Context} context
32
+ * @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport}} [dependencies]
33
+ */
34
+ export async function setupV2(context, dependencies = {}) {
35
+ const runtime = (dependencies.createRuntime ?? createAnthropicRuntime)({
36
+ interactiveOAuth: false,
37
+ hostCompatShim: true, // @ai-sdk/anthropic 3.0.111 is pinned, even when the host omits its UA.
38
+ prepareRequest: prepareV2Tools,
39
+ });
40
+ const commands = new AsyncLocalStorage();
41
+ const registrations = [];
42
+ let disposed = false;
43
+ let managed = false;
44
+ let poolAvailable = false;
45
+ let ownedConnection = false;
46
+ const knownOwnedConnections = new Set();
47
+ let fetchPromise;
48
+ let connection;
49
+ let config;
50
+ let transport;
51
+ let refreshStatePromise;
52
+ let stateSignature;
53
+ let warnedAboutHaikuSummary = false;
54
+
55
+ function assertActive() {
56
+ if (disposed) throw new Error("Anthropic plugin is disposed");
57
+ }
58
+
59
+ async function dispose() {
60
+ if (disposed) return;
61
+ disposed = true;
62
+ const failures = [];
63
+ for (const resource of [transport, ...registrations.reverse(), runtime]) {
64
+ try {
65
+ await resource?.dispose();
66
+ } catch (error) {
67
+ failures.push(error);
68
+ }
69
+ }
70
+ if (failures.length) throw new AggregateError(failures, "Failed to dispose Anthropic plugin resources");
71
+ }
72
+
73
+ async function refreshState() {
74
+ assertActive();
75
+ if (refreshStatePromise) return refreshStatePromise;
76
+ refreshStatePromise = (async () => {
77
+ runtime.reloadConfig();
78
+ poolAvailable = Boolean(await runtime.getCurrentAuth({ allowExpired: true }));
79
+ assertActive();
80
+ connection = await context.integration.connection.active("anthropic");
81
+ assertActive();
82
+ // An explicitly selected API key/environment connection remains native.
83
+ // Existing pools can be used before connecting a v2 credential.
84
+ const connectionKey = JSON.stringify(connection);
85
+ ownedConnection = knownOwnedConnections.has(connectionKey);
86
+ if (connection?.type === "credential" && connection.method === "oauth") {
87
+ try {
88
+ const activeCredential = await context.integration.connection.resolve(connection);
89
+ assertActive();
90
+ ownedConnection = activeCredential?.methodID === OAUTH_METHOD_ID;
91
+ if (ownedConnection) knownOwnedConnections.add(connectionKey);
92
+ else knownOwnedConnections.delete(connectionKey);
93
+ } catch (error) {
94
+ if (disposed) throw error;
95
+ // The host may attempt to refresh an expired credential after its
96
+ // account was removed. Keep administration available and preserve a
97
+ // known owned connection's disabled state; never print token errors.
98
+ console.warn(
99
+ "[anthropic] The selected OAuth connection could not be resolved. Reconnect through /anthropic login.",
100
+ );
101
+ }
102
+ }
103
+ managed = poolAvailable && (!connection || ownedConnection);
104
+ config = structuredClone(runtime.getConfig());
105
+ if (managed && config.token_economy_strategies.haiku_rolling_summary && !warnedAboutHaikuSummary) {
106
+ warnedAboutHaikuSummary = true;
107
+ console.warn(
108
+ "[anthropic] haiku_rolling_summary requires a v1 fork hook; OpenCode v2 uses summary compaction instead.",
109
+ );
110
+ }
111
+ const nextSignature = JSON.stringify({
112
+ managed,
113
+ ownedConnection,
114
+ connection,
115
+ limits: config.override_model_limits,
116
+ });
117
+ if (nextSignature !== stateSignature) {
118
+ stateSignature = nextSignature;
119
+ fetchPromise = undefined;
120
+ await context.provider.reload();
121
+ }
122
+ })().finally(() => {
123
+ refreshStatePromise = undefined;
124
+ });
125
+ return refreshStatePromise;
126
+ }
127
+
128
+ try {
129
+ const hooks = await runtime.initialize({
130
+ client: {
131
+ // The runtime persists the rotated token under its cross-process lock.
132
+ // v2 persists the returned credential only through its OAuth callbacks;
133
+ // there is deliberately no invented credential-update API here.
134
+ auth: {
135
+ async set() {
136
+ return undefined;
137
+ },
138
+ },
139
+ session: {
140
+ async prompt({ body }) {
141
+ const output = commands.getStore();
142
+ if (!output) throw new Error("Administrative output requires an Anthropic command invocation");
143
+ output.push(...body.parts.filter((part) => part.type === "text").map((part) => part.text));
144
+ },
145
+ },
146
+ tui: {
147
+ async showToast({ body }) {
148
+ const output = commands.getStore();
149
+ if (output) output.push(body.message);
150
+ },
151
+ },
152
+ },
153
+ });
154
+
155
+ const isManagedModel = (model) => managed && model?.providerID === "anthropic";
156
+ transport = await (dependencies.setupTransport ?? setupV2Transport)(context, {
157
+ isManagedModel,
158
+ async fetch(input, init) {
159
+ if (disposed) throw new Error("Anthropic plugin is disposed");
160
+ if (!managed) throw new Error("The Anthropic OAuth connection changed. Retry with the selected connection.");
161
+ fetchPromise ??= hooks.auth.loader(
162
+ async () => (await runtime.getCurrentAuth({ allowExpired: true })) ?? { type: "none" },
163
+ { models: {} },
164
+ );
165
+ const executor = await fetchPromise;
166
+ if (typeof executor.fetch !== "function") {
167
+ fetchPromise = undefined;
168
+ throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
169
+ }
170
+ return executor.fetch(input, init);
171
+ },
172
+ });
173
+
174
+ registrations.push(
175
+ await context.integration.transform((editor) => {
176
+ editor.method.update({
177
+ integrationID: "anthropic",
178
+ method: { id: OAUTH_METHOD_ID, type: "oauth", label: "Claude Pro/Max (multi-account)" },
179
+ async authorize() {
180
+ const flow = await runtime.authorizeOAuth();
181
+ return {
182
+ url: flow.url,
183
+ instructions: flow.instructions,
184
+ mode: "code",
185
+ async callback(code) {
186
+ const result = await flow.callback(code);
187
+ if (result.type !== "success") throw new Error("Anthropic authorization failed. Start login again.");
188
+ fetchPromise = undefined;
189
+ await refreshState();
190
+ return credential(result);
191
+ },
192
+ };
193
+ },
194
+ async refresh() {
195
+ // Disk stays authoritative after logout/removal and cross-process refresh.
196
+ return credential(await runtime.getCurrentAuth({ refresh: true }));
197
+ },
198
+ label: () => "Claude Pro/Max account pool",
199
+ });
200
+ }),
201
+ );
202
+
203
+ registrations.push(
204
+ await context.provider.transform((editor) => {
205
+ if ((!managed && !ownedConnection) || !editor.get("anthropic")) return;
206
+ editor.update("anthropic", (provider) => {
207
+ if (!poolAvailable) {
208
+ // A stale host credential must not resurrect an account removed
209
+ // from the authoritative pool by silently reverting to native.
210
+ provider.activation = "disabled";
211
+ return;
212
+ }
213
+ provider.activation = "enabled";
214
+ provider.package = V2_MODEL_PACKAGE;
215
+ provider.settings = { ...provider.settings, apiKey: "opencode-managed-oauth" };
216
+ // Core 2.0.22+ defaults to five-minute header/chunk limits. The
217
+ // executor has attempt budgets, not a total deadline (transient 429s
218
+ // and Retry-After waits can exceed five minutes). Do not race it with
219
+ // host timers. Auxiliary work has its own deadline in the bridge.
220
+ provider.settings.timeout = false;
221
+ provider.settings.headerTimeout = false;
222
+ // 2.0.21's public schema only accepts a number here; its fetch wrapper
223
+ // enables the timer only for values > 0. 2.0.22 requires false instead.
224
+ provider.settings.chunkTimeout = context.app?.version === "2.0.21" ? 0 : false;
225
+ // The compatibility route uses the host's summary compaction instead.
226
+ provider.settings.compaction = { type: "summary" };
227
+ });
228
+ }),
229
+ );
230
+ registrations.push(
231
+ await context.model.transform((editor) => {
232
+ if (!managed) return;
233
+ for (const candidate of editor.list("anthropic")) {
234
+ editor.update(candidate.providerID, candidate.id, (model) => {
235
+ model.package = V2_MODEL_PACKAGE;
236
+ model.settings = { ...model.settings };
237
+ model.settings.compaction = { type: "summary" };
238
+ // The host merges a selected variant after the base model settings.
239
+ for (const variant of model.variants ?? []) {
240
+ variant.settings = { ...variant.settings, compaction: { type: "summary" } };
241
+ }
242
+ model.cost = model.cost.map((tier) => ({ ...tier, input: 0, output: 0, cache: { read: 0, write: 0 } }));
243
+ if (
244
+ config.override_model_limits.enabled &&
245
+ !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
246
+ (hasOneMillionContext(model.id) ||
247
+ isOpus46Model(model.id) ||
248
+ isOpus47Model(model.id) ||
249
+ isOpus48Model(model.id))
250
+ ) {
251
+ model.limit.context = config.override_model_limits.context;
252
+ if (config.override_model_limits.output > 0) model.limit.output = config.override_model_limits.output;
253
+ }
254
+ });
255
+ }
256
+ }),
257
+ );
258
+
259
+ for (const name of ["context", "compaction", "generate", "title"]) {
260
+ registrations.push(
261
+ await context.session.hook(
262
+ name,
263
+ async (event) => {
264
+ await refreshState();
265
+ if (!isManagedModel(event.model)) return;
266
+ assertPortableV2History(event.messages);
267
+ applyV2MessagePolicies(event.messages, runtime.getConfig().token_economy_strategies);
268
+ await hooks["experimental.chat.system.transform"]({ model: event.model }, { system: event.system });
269
+ for (let i = 0; i < event.system.length; i++) {
270
+ if (typeof event.system[i] === "string") event.system[i] = { type: "text", text: event.system[i] };
271
+ }
272
+ if (name === "compaction") {
273
+ const output = { context: [] };
274
+ await hooks["experimental.session.compacting"]({ sessionID: event.sessionID }, output);
275
+ event.system.push(...output.context.map((text) => ({ type: "text", text })));
276
+ }
277
+ },
278
+ { providerID: "anthropic" },
279
+ ),
280
+ );
281
+ }
282
+
283
+ registrations.push(await registerAnthropicServerCommand(context));
284
+ registrations.push(
285
+ await registerAnthropicCommandRpc(context, async (input) => {
286
+ const output = [];
287
+ await commands.run(output, () => runtime.executeCommand(input));
288
+ fetchPromise = undefined;
289
+ await refreshState();
290
+ return output.join("\n\n");
291
+ }),
292
+ );
293
+ await refreshState();
294
+ return dispose;
295
+ } catch (error) {
296
+ try {
297
+ await dispose();
298
+ } catch (cleanupError) {
299
+ throw new AggregateError([error, cleanupError], "Anthropic v2 initialization and cleanup failed", {
300
+ cause: cleanupError,
301
+ });
302
+ }
303
+ throw error;
304
+ }
305
+ }
@@ -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
@@ -103,6 +110,7 @@ export const CORE_TOOL_NAMES = new Set([
103
110
  */
104
111
  export function transformRequestBody(body, signature, runtime, taskBudgetsActive, config) {
105
112
  if (!body || typeof body !== "string") return body;
113
+ const sessionMetrics = runtime?.sessionMetrics ?? legacySessionMetrics;
106
114
 
107
115
  try {
108
116
  const parsed = JSON.parse(body);
@@ -123,7 +131,7 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
123
131
  }
124
132
  // Output cap: resolve max_tokens before any other body transforms
125
133
  if (config?.output_cap?.enabled) {
126
- parsed.max_tokens = resolveMaxTokens(parsed, config);
134
+ parsed.max_tokens = resolveMaxTokens(parsed, config, sessionMetrics);
127
135
  }
128
136
  // The body-level `anthropic_beta` array existed only for Bedrock, which does
129
137
  // not forward custom HTTP headers. Multi-provider support was removed, and
@@ -519,23 +527,25 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
519
527
  // opencode uses lowercase names while CC uses PascalCase. While only "todowrite"
520
528
  // is currently confirmed blocklisted, we rename ALL core opencode tools to match
521
529
  // 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
- };
530
+ const OC_TO_CC_TOOL_NAMES = new Map(
531
+ Object.entries({
532
+ bash: "Bash",
533
+ read: "Read",
534
+ glob: "Glob",
535
+ grep: "Grep",
536
+ edit: "Edit",
537
+ write: "Write",
538
+ webfetch: "WebFetch",
539
+ todowrite: "TodoWrite",
540
+ skill: "Skill",
541
+ task: "Task",
542
+ compress: "Compress",
543
+ }),
544
+ );
535
545
  if (Array.isArray(parsed.tools)) {
536
546
  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];
547
+ if (tool.name && OC_TO_CC_TOOL_NAMES.has(tool.name)) {
548
+ tool.name = OC_TO_CC_TOOL_NAMES.get(tool.name);
539
549
  }
540
550
  }
541
551
  }
@@ -544,8 +554,8 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
544
554
  for (const msg of parsed.messages) {
545
555
  if (!Array.isArray(msg.content)) continue;
546
556
  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];
557
+ if (block.type === "tool_use" && block.name && OC_TO_CC_TOOL_NAMES.has(block.name)) {
558
+ block.name = OC_TO_CC_TOOL_NAMES.get(block.name);
549
559
  }
550
560
  }
551
561
  }
@@ -605,7 +615,7 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
605
615
  parsed.model &&
606
616
  !/claude-3-|haiku/i.test(parsed.model)
607
617
  ) {
608
- const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
618
+ const coreToolNames = new Set(OC_TO_CC_TOOL_NAMES.values());
609
619
  for (const tool of parsed.tools) {
610
620
  if (tool.name && !coreToolNames.has(tool.name)) {
611
621
  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
  }
@@ -143,30 +143,32 @@ export function stripMcpPrefixFromSSE(text) {
143
143
  * Built from the outgoing OC_TO_CC_TOOL_NAMES map (defined in transformRequestBody).
144
144
  * Must stay in sync with that map.
145
145
  */
146
- export const CC_TO_OC_TOOL_NAMES = {
147
- Bash: "bash",
148
- Read: "read",
149
- Glob: "glob",
150
- Grep: "grep",
151
- Edit: "edit",
152
- Write: "write",
153
- WebFetch: "webfetch",
154
- TodoWrite: "todowrite",
155
- Skill: "skill",
156
- Task: "task",
157
- Compress: "compress",
158
- };
146
+ export const CC_TO_OC_TOOL_NAMES = new Map(
147
+ Object.entries({
148
+ Bash: "bash",
149
+ Read: "read",
150
+ Glob: "glob",
151
+ Grep: "grep",
152
+ Edit: "edit",
153
+ Write: "write",
154
+ WebFetch: "webfetch",
155
+ TodoWrite: "todowrite",
156
+ Skill: "skill",
157
+ Task: "task",
158
+ Compress: "compress",
159
+ }),
160
+ );
159
161
 
160
162
  /**
161
163
  * Reverse-map a tool name from CC PascalCase back to opencode lowercase,
162
164
  * and strip `mcp_` prefix if present. Returns the original name if no mapping exists.
163
165
  * @param {string} name
166
+ * @param {Map<string, string>} [toolNameMap] - Per-request restoration of host tool names.
164
167
  * @returns {string}
165
168
  */
166
- export function reverseMapToolName(name) {
167
- if (CC_TO_OC_TOOL_NAMES[name]) return CC_TO_OC_TOOL_NAMES[name];
168
- if (name.startsWith("mcp_")) return name.slice(4);
169
- return name;
169
+ export function reverseMapToolName(name, toolNameMap) {
170
+ const mapped = CC_TO_OC_TOOL_NAMES.get(name) ?? (name.startsWith("mcp_") ? name.slice(4) : name);
171
+ return toolNameMap?.get(mapped) ?? mapped;
170
172
  }
171
173
 
172
174
  /**
@@ -175,9 +177,10 @@ export function reverseMapToolName(name) {
175
177
  * Returns true if any modification was made.
176
178
  *
177
179
  * @param {any} parsed
180
+ * @param {Map<string, string>} [toolNameMap]
178
181
  * @returns {boolean}
179
182
  */
180
- export function stripMcpPrefixFromParsedEvent(parsed) {
183
+ export function stripMcpPrefixFromParsedEvent(parsed, toolNameMap) {
181
184
  if (!parsed || typeof parsed !== "object") return false;
182
185
 
183
186
  let modified = false;
@@ -188,7 +191,7 @@ export function stripMcpPrefixFromParsedEvent(parsed) {
188
191
  (parsed.content_block.type === "tool_use" || parsed.content_block.type === "tool_reference") &&
189
192
  typeof parsed.content_block.name === "string"
190
193
  ) {
191
- const mapped = reverseMapToolName(parsed.content_block.name);
194
+ const mapped = reverseMapToolName(parsed.content_block.name, toolNameMap);
192
195
  if (mapped !== parsed.content_block.name) {
193
196
  parsed.content_block.name = mapped;
194
197
  modified = true;
@@ -199,7 +202,7 @@ export function stripMcpPrefixFromParsedEvent(parsed) {
199
202
  if (parsed.message && Array.isArray(parsed.message.content)) {
200
203
  for (const block of parsed.message.content) {
201
204
  if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
202
- const mapped = reverseMapToolName(block.name);
205
+ const mapped = reverseMapToolName(block.name, toolNameMap);
203
206
  if (mapped !== block.name) {
204
207
  block.name = mapped;
205
208
  modified = true;
@@ -212,7 +215,7 @@ export function stripMcpPrefixFromParsedEvent(parsed) {
212
215
  if (Array.isArray(parsed.content)) {
213
216
  for (const block of parsed.content) {
214
217
  if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
215
- const mapped = reverseMapToolName(block.name);
218
+ const mapped = reverseMapToolName(block.name, toolNameMap);
216
219
  if (mapped !== block.name) {
217
220
  block.name = mapped;
218
221
  modified = true;
@@ -803,6 +806,7 @@ function createPassthroughStream(reader, idleTimeoutMs) {
803
806
  *
804
807
  * @param {Response} response
805
808
  * @param {object} options
809
+ * @param {Map<string, string>} [options.toolNameMap] - Host aliases specific to this request.
806
810
  * @param {((stats: UsageStats) => void) | null} [options.onUsage]
807
811
  * @param {((details: {reason: import('../backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [options.onAccountError]
808
812
  * @param {((stats: {blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}) => void) | null} [options.onHostCompatShim]
@@ -839,6 +843,7 @@ export function createTransformedSSEStream(
839
843
  onAccountError,
840
844
  onHostCompatShim,
841
845
  hostCompatShim = true,
846
+ toolNameMap,
842
847
  correlationId,
843
848
  idleTimeoutMs,
844
849
  captureEnabled,
@@ -939,7 +944,7 @@ export function createTransformedSSEStream(
939
944
  }
940
945
  if (shimAction === "drop") return [];
941
946
 
942
- const mcpModified = stripMcpPrefixFromParsedEvent(parsed);
947
+ const mcpModified = stripMcpPrefixFromParsedEvent(parsed, toolNameMap);
943
948
  if (shimAction === "rewrite" || mcpModified) {
944
949
  // Re-serialize only the `data:` line(s) this event actually changed;
945
950
  // every other line in the block (event:, id:, comments) is untouched.
package/lib/oauth.mjs CHANGED
@@ -179,9 +179,21 @@ function buildTokenHeaders() {
179
179
  * @param {number} ms
180
180
  * @returns {Promise<void>}
181
181
  */
182
- function sleep(ms) {
182
+ function sleep(ms, signal) {
183
+ signal?.throwIfAborted();
183
184
  if (ms <= 0) return Promise.resolve();
184
- return new Promise((resolve) => setTimeout(resolve, ms));
185
+ return new Promise((resolve, reject) => {
186
+ const abort = () => {
187
+ clearTimeout(timer);
188
+ signal.removeEventListener("abort", abort);
189
+ reject(signal.reason);
190
+ };
191
+ const timer = setTimeout(() => {
192
+ signal?.removeEventListener("abort", abort);
193
+ resolve();
194
+ }, ms);
195
+ signal?.addEventListener("abort", abort, { once: true });
196
+ });
185
197
  }
186
198
 
187
199
  /**
@@ -442,7 +454,7 @@ export function parseOAuthCallback(input) {
442
454
  * Exchange an authorization code for tokens.
443
455
  * @param {string} code
444
456
  * @param {string} verifier
445
- * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent` is
457
+ * @param {{ sdkTokenUserAgent?: boolean, signal?: AbortSignal }} [options] - `sdkTokenUserAgent` is
446
458
  * accepted and ignored. It is retained so existing call sites keep type-
447
459
  * checking; there is only one token fingerprint. See §7 divergence D2.
448
460
  * @returns {Promise<
@@ -458,7 +470,7 @@ export function parseOAuthCallback(input) {
458
470
  * }
459
471
  * >}
460
472
  */
461
- export async function exchange(code, verifier, _options = {}) {
473
+ export async function exchange(code, verifier, options = {}) {
462
474
  const tokenHeaders = buildTokenHeaders();
463
475
  const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
464
476
  const { errorCode, reason } = parseOAuthErrorBody(rawText);
@@ -486,6 +498,7 @@ export async function exchange(code, verifier, _options = {}) {
486
498
  const { code: authCode, state: authState } = parseOAuthCallback(code);
487
499
  let result;
488
500
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
501
+ options.signal?.throwIfAborted();
489
502
  try {
490
503
  // SHAPE IS INPUT-DEPENDENT (D8). `state` is present only when the pasted
491
504
  // callback carried one, so this body is five keys for a bare code and six
@@ -504,11 +517,16 @@ export async function exchange(code, verifier, _options = {}) {
504
517
  method: "POST",
505
518
  headers: tokenHeaders,
506
519
  body: JSON.stringify(exchangeBody),
507
- signal: AbortSignal.timeout(15_000),
520
+ // Match the runtime's AbortSignal.any baseline; a caller signal must
521
+ // not replace the independent per-attempt network deadline.
522
+ signal: options.signal
523
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
524
+ : AbortSignal.timeout(15_000),
508
525
  });
509
526
  } catch (err) {
527
+ options.signal?.throwIfAborted();
510
528
  if (attempt < OAUTH_MAX_RETRIES) {
511
- await sleep(Math.min(500 * 2 ** attempt, 2000));
529
+ await sleep(Math.min(500 * 2 ** attempt, 2000), options.signal);
512
530
  continue;
513
531
  }
514
532
  return fail(undefined, err instanceof Error ? err.message : String(err));
@@ -524,7 +542,7 @@ export async function exchange(code, verifier, _options = {}) {
524
542
  : "";
525
543
 
526
544
  if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(result.status)) {
527
- await sleep(getRetryDelayMs(result, attempt));
545
+ await sleep(getRetryDelayMs(result, attempt), options.signal);
528
546
  continue;
529
547
  }
530
548
 
@@ -638,6 +656,7 @@ export async function refreshToken(refreshTokenValue, options = {}) {
638
656
  const tokenHeaders = buildTokenHeaders();
639
657
 
640
658
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
659
+ options.signal?.throwIfAborted();
641
660
  let resp;
642
661
  // A single AbortSignal.timeout() created outside the loop starts counting at
643
662
  // creation, so a slow first attempt would leave the retries with no budget
@@ -658,8 +677,9 @@ export async function refreshToken(refreshTokenValue, options = {}) {
658
677
  signal,
659
678
  });
660
679
  } catch (err) {
680
+ options.signal?.throwIfAborted();
661
681
  if (attempt < OAUTH_MAX_RETRIES) {
662
- await sleep(Math.min(500 * 2 ** attempt, 2000));
682
+ await sleep(Math.min(500 * 2 ** attempt, 2000), options.signal);
663
683
  continue;
664
684
  }
665
685
  throw err;
@@ -670,7 +690,7 @@ export async function refreshToken(refreshTokenValue, options = {}) {
670
690
  const retryAfterMs = getRetryDelayMs(resp, attempt);
671
691
 
672
692
  if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(resp.status)) {
673
- await sleep(retryAfterMs);
693
+ await sleep(retryAfterMs, options.signal);
674
694
  continue;
675
695
  }
676
696