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.
- 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 -6578
- package/lib/accounts.mjs +46 -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 +415 -0
- package/lib/host/v2.mjs +343 -0
- package/lib/mimicry/request-body.mjs +99 -33
- 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,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
|
-
|
|
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
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
538
|
-
tool.name = OC_TO_CC_TOOL_NAMES
|
|
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
|
|
548
|
-
block.name = OC_TO_CC_TOOL_NAMES
|
|
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(
|
|
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
|
|
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
|
}
|