opencode-anthropic-fix 2.1.0 → 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.
- package/NOTICE +53 -0
- package/README.md +72 -4
- package/docs/opencode-v2-architecture.md +148 -0
- package/docs/opencode-v2.md +198 -0
- package/index.mjs +13 -6608
- package/lib/accounts.mjs +46 -0
- package/lib/debug-dump.mjs +87 -0
- package/lib/host/command-args.mjs +18 -0
- package/lib/host/command-rpc.mjs +192 -0
- package/lib/host/runtime.mjs +6902 -0
- package/lib/host/tool-names.mjs +62 -0
- package/lib/host/v2-context.mjs +45 -0
- package/lib/host/v2-language.mjs +58 -0
- package/lib/host/v2-sdk.mjs +4 -0
- package/lib/host/v2-transport.mjs +374 -0
- package/lib/host/v2.mjs +305 -0
- package/lib/mimicry/request-body.mjs +31 -21
- package/lib/mimicry/request-helpers.mjs +6 -5
- package/lib/mimicry/response-stream.mjs +27 -22
- package/lib/oauth.mjs +29 -9
- package/lib/refresh-lock.mjs +18 -4
- package/lib/session-metrics.mjs +5 -8
- package/lib/unicode-text.mjs +9 -13
- package/licenses/Apache-2.0.txt +201 -0
- package/package.json +21 -1
- package/server.mjs +15 -0
- package/tui.mjs +93 -0
package/lib/host/v2.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
538
|
-
tool.name = OC_TO_CC_TOOL_NAMES
|
|
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
|
|
548
|
-
block.name = OC_TO_CC_TOOL_NAMES
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
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
|
|