opencode-anthropic-fix 2.2.0 → 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/lib/host/v2-transport.mjs +53 -12
- package/lib/host/v2.mjs +39 -1
- package/lib/mimicry/request-body.mjs +68 -12
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
3
4
|
import { Readable } from "node:stream";
|
|
4
5
|
import { pipeline } from "node:stream/promises";
|
|
5
6
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
7
|
+
import { redactString } from "../redact.mjs";
|
|
6
8
|
import { adaptV2Language, V2_SDK_PACKAGE } from "./v2-language.mjs";
|
|
7
9
|
|
|
8
10
|
const TICKET_HEADER = "x-opencode-anthropic-fix-request";
|
|
@@ -14,6 +16,14 @@ function abortError(message) {
|
|
|
14
16
|
return error;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
// Only scalar diagnostic fields may reach logs, never arbitrary error objects
|
|
20
|
+
// (which can carry request bodies, headers or credentials).
|
|
21
|
+
function diagnosticValue(value) {
|
|
22
|
+
if (typeof value === "string") return redactString(value).slice(0, 2000);
|
|
23
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/**
|
|
18
28
|
* OpenCode 2.0.21 does not pass an abortSignal to LanguageModelV3.doStream.
|
|
19
29
|
* Associate the host's HTTP hook with the custom fetch using a single-use
|
|
@@ -29,7 +39,7 @@ function abortError(message) {
|
|
|
29
39
|
* middleware; never put the returned internal fetch in provider/model settings.
|
|
30
40
|
*
|
|
31
41
|
* @param {{session: {hook: Function}, aisdk: {hook: Function}, event: {subscribe: Function}} context
|
|
32
|
-
* @param {{fetch: typeof fetch, isManagedModel?: (model: object) => boolean, onError?: (error: Error) => void, auxiliaryDeadlineMs?: number}} options
|
|
42
|
+
* @param {{fetch: typeof fetch, isManagedModel?: (model: object) => boolean, onError?: (error: Error, summary?: object) => void, auxiliaryDeadlineMs?: number}} options
|
|
33
43
|
* @returns {Promise<{fetch: typeof fetch, dispose: () => Promise<void>}>}
|
|
34
44
|
*/
|
|
35
45
|
export async function setupV2Transport(context, options) {
|
|
@@ -70,8 +80,10 @@ export async function setupV2Transport(context, options) {
|
|
|
70
80
|
} else if (["session.execution.succeeded", "session.execution.failed"].includes(event.type)) {
|
|
71
81
|
for (const ticket of tickets.values()) {
|
|
72
82
|
// A request that never reached fetch may have failed in another
|
|
73
|
-
// HTTP hook. Keep active auxiliary streams until their own EOF
|
|
74
|
-
|
|
83
|
+
// HTTP hook. Keep active auxiliary streams until their own EOF, and
|
|
84
|
+
// claimed tickets whose body is still uploading: the bridge releases
|
|
85
|
+
// those on its own finish or cancel path.
|
|
86
|
+
if (!ticket.started && !ticket.claimed && ticket.sessionID === event.data.sessionID) release(ticket);
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
89
|
}
|
|
@@ -79,7 +91,7 @@ export async function setupV2Transport(context, options) {
|
|
|
79
91
|
} catch (error) {
|
|
80
92
|
if (disposed) return;
|
|
81
93
|
eventFailure = error instanceof Error ? error : new Error(String(error));
|
|
82
|
-
cancelAll(eventFailure);
|
|
94
|
+
cancelAll(abortError(`OpenCode session event subscription failed. ${eventFailure.message}`));
|
|
83
95
|
onError(eventFailure);
|
|
84
96
|
}
|
|
85
97
|
})();
|
|
@@ -170,7 +182,7 @@ export async function setupV2Transport(context, options) {
|
|
|
170
182
|
// Remove our listener before aborting: cancellation already owns the
|
|
171
183
|
// reader, and must not error a stream that the consumer just closed.
|
|
172
184
|
signal.removeEventListener("abort", abort);
|
|
173
|
-
ticket.controller.abort(reason ?? abortError("Anthropic response cancelled."));
|
|
185
|
+
ticket.controller.abort(reason ?? abortError("Anthropic response consumer cancelled."));
|
|
174
186
|
try {
|
|
175
187
|
await reader.cancel(reason);
|
|
176
188
|
} finally {
|
|
@@ -203,9 +215,10 @@ export async function setupV2Transport(context, options) {
|
|
|
203
215
|
};
|
|
204
216
|
response.once("close", disconnect);
|
|
205
217
|
void (async () => {
|
|
218
|
+
const startedAt = performance.now();
|
|
219
|
+
let size = 0;
|
|
206
220
|
try {
|
|
207
221
|
const chunks = [];
|
|
208
|
-
let size = 0;
|
|
209
222
|
for await (const chunk of request) {
|
|
210
223
|
size += chunk.length;
|
|
211
224
|
if (size > 32 * 1024 * 1024) throw new Error("Anthropic request exceeds 32 MiB.");
|
|
@@ -226,7 +239,9 @@ export async function setupV2Transport(context, options) {
|
|
|
226
239
|
else response.end();
|
|
227
240
|
} catch (error) {
|
|
228
241
|
const interrupted = ticket.controller.signal.aborted;
|
|
229
|
-
|
|
242
|
+
const reason = interrupted ? ticket.controller.signal.reason : undefined;
|
|
243
|
+
const headersSent = response.headersSent;
|
|
244
|
+
cancel(ticket, abortError("Anthropic bridge request failed."));
|
|
230
245
|
if (!response.headersSent && !response.destroyed) {
|
|
231
246
|
response.writeHead(502, { "content-type": "application/json" });
|
|
232
247
|
response.end(
|
|
@@ -236,7 +251,32 @@ export async function setupV2Transport(context, options) {
|
|
|
236
251
|
}),
|
|
237
252
|
);
|
|
238
253
|
} else response.destroy();
|
|
239
|
-
|
|
254
|
+
const summary = {
|
|
255
|
+
name: diagnosticValue(error?.name),
|
|
256
|
+
message: diagnosticValue(error?.message ?? error),
|
|
257
|
+
code: diagnosticValue(error?.code),
|
|
258
|
+
status: diagnosticValue(error?.status),
|
|
259
|
+
cause: {
|
|
260
|
+
code: diagnosticValue(error?.cause?.code),
|
|
261
|
+
message: diagnosticValue(error?.cause?.message),
|
|
262
|
+
},
|
|
263
|
+
interrupted,
|
|
264
|
+
...(interrupted && {
|
|
265
|
+
reason: {
|
|
266
|
+
name: diagnosticValue(reason?.name),
|
|
267
|
+
message: diagnosticValue(reason?.message ?? reason),
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
headersSent,
|
|
271
|
+
started: ticket.started,
|
|
272
|
+
kind: diagnosticValue(ticket.kind),
|
|
273
|
+
bodyBytes: size,
|
|
274
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
275
|
+
};
|
|
276
|
+
onError(
|
|
277
|
+
new Error(`Anthropic compatibility transport failed. ${JSON.stringify(summary)}`, { cause: error }),
|
|
278
|
+
summary,
|
|
279
|
+
);
|
|
240
280
|
} finally {
|
|
241
281
|
response.removeListener("close", disconnect);
|
|
242
282
|
}
|
|
@@ -270,7 +310,7 @@ export async function setupV2Transport(context, options) {
|
|
|
270
310
|
async function releaseResources(reason) {
|
|
271
311
|
disposed = true;
|
|
272
312
|
cancelAll(reason);
|
|
273
|
-
subscription.abort();
|
|
313
|
+
subscription.abort(abortError("Anthropic transport event subscription stopped."));
|
|
274
314
|
const results = await Promise.allSettled([
|
|
275
315
|
...registrations.map(async (registration) => registration.dispose()),
|
|
276
316
|
closeBridge(),
|
|
@@ -294,6 +334,7 @@ export async function setupV2Transport(context, options) {
|
|
|
294
334
|
id: randomUUID(),
|
|
295
335
|
url,
|
|
296
336
|
sessionID: event.sessionID,
|
|
337
|
+
kind: event.kind,
|
|
297
338
|
controller: new AbortController(),
|
|
298
339
|
started: false,
|
|
299
340
|
};
|
|
@@ -320,7 +361,7 @@ export async function setupV2Transport(context, options) {
|
|
|
320
361
|
headers,
|
|
321
362
|
});
|
|
322
363
|
} catch (error) {
|
|
323
|
-
cancel(ticket,
|
|
364
|
+
cancel(ticket, abortError("Anthropic HTTP request hook failed."));
|
|
324
365
|
throw error;
|
|
325
366
|
}
|
|
326
367
|
},
|
|
@@ -355,7 +396,7 @@ export async function setupV2Transport(context, options) {
|
|
|
355
396
|
);
|
|
356
397
|
} catch (error) {
|
|
357
398
|
try {
|
|
358
|
-
await releaseResources(
|
|
399
|
+
await releaseResources(abortError("Anthropic transport initialization failed."));
|
|
359
400
|
} catch (cleanupError) {
|
|
360
401
|
throw new AggregateError([error, cleanupError], "Anthropic transport initialization and cleanup failed.", {
|
|
361
402
|
cause: cleanupError,
|
|
@@ -368,7 +409,7 @@ export async function setupV2Transport(context, options) {
|
|
|
368
409
|
fetch: managedFetch,
|
|
369
410
|
async dispose() {
|
|
370
411
|
if (disposed) return;
|
|
371
|
-
await releaseResources(abortError("Anthropic
|
|
412
|
+
await releaseResources(abortError("Anthropic transport disposed."));
|
|
372
413
|
},
|
|
373
414
|
};
|
|
374
415
|
}
|
package/lib/host/v2.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { createAnthropicRuntime } from "./runtime.mjs";
|
|
3
5
|
import { registerAnthropicCommandRpc, registerAnthropicServerCommand } from "./command-rpc.mjs";
|
|
4
6
|
import { setupV2Transport } from "./v2-transport.mjs";
|
|
@@ -6,6 +8,8 @@ import { V2_SDK_PACKAGE } from "./v2-language.mjs";
|
|
|
6
8
|
import { prepareV2Tools } from "./tool-names.mjs";
|
|
7
9
|
import { applyV2MessagePolicies, assertPortableV2History } from "./v2-context.mjs";
|
|
8
10
|
import { isTruthyEnv } from "../env.mjs";
|
|
11
|
+
import { getConfigDir } from "../config.mjs";
|
|
12
|
+
import { redactString } from "../redact.mjs";
|
|
9
13
|
import { hasOneMillionContext, isOpus46Model, isOpus47Model, isOpus48Model } from "../mimicry/wire-compat.mjs";
|
|
10
14
|
|
|
11
15
|
export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
|
|
@@ -13,6 +17,9 @@ export const OAUTH_METHOD_ID = "opencode-anthropic-fix";
|
|
|
13
17
|
// built-in dynamic hook executes before ours, so a registry spec adds an install.
|
|
14
18
|
export const V2_MODEL_PACKAGE = `aisdk:${V2_SDK_PACKAGE}`;
|
|
15
19
|
|
|
20
|
+
// Shared by plugin instances so reload/disposal and user cancels do not spam stderr.
|
|
21
|
+
let reportedInterruptedFailure = false;
|
|
22
|
+
|
|
16
23
|
/** @param {object} auth */
|
|
17
24
|
function credential(auth) {
|
|
18
25
|
if (!auth?.access || !auth?.refresh) throw new Error("No enabled Anthropic OAuth account. Use /anthropic login.");
|
|
@@ -29,7 +36,7 @@ function credential(auth) {
|
|
|
29
36
|
* v2 owns registration and presentation; the shared runtime owns credentials,
|
|
30
37
|
* request construction and retries. No v1 hooks are returned to the v2 host.
|
|
31
38
|
* @param {import('@opencode/plugin').Plugin.Context} context
|
|
32
|
-
* @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport}} [dependencies]
|
|
39
|
+
* @param {{createRuntime?: typeof createAnthropicRuntime, setupTransport?: typeof setupV2Transport, transportErrorDir?: string}} [dependencies]
|
|
33
40
|
*/
|
|
34
41
|
export async function setupV2(context, dependencies = {}) {
|
|
35
42
|
const runtime = (dependencies.createRuntime ?? createAnthropicRuntime)({
|
|
@@ -155,6 +162,37 @@ export async function setupV2(context, dependencies = {}) {
|
|
|
155
162
|
const isManagedModel = (model) => managed && model?.providerID === "anthropic";
|
|
156
163
|
transport = await (dependencies.setupTransport ?? setupV2Transport)(context, {
|
|
157
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
|
+
},
|
|
158
196
|
async fetch(input, init) {
|
|
159
197
|
if (disposed) throw new Error("Anthropic plugin is disposed");
|
|
160
198
|
if (!managed) throw new Error("The Anthropic OAuth connection changed. Retry with the selected connection.");
|
|
@@ -94,6 +94,62 @@ export const CORE_TOOL_NAMES = new Set([
|
|
|
94
94
|
"Compress",
|
|
95
95
|
]);
|
|
96
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
|
+
|
|
97
153
|
/**
|
|
98
154
|
* Transform the request body: system prompt sanitization and tool prefixing.
|
|
99
155
|
* Preserves behaviors E1-E7.
|
|
@@ -508,18 +564,18 @@ export function transformRequestBody(body, signature, runtime, taskBudgetsActive
|
|
|
508
564
|
}
|
|
509
565
|
}
|
|
510
566
|
}
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
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 });
|
|
523
579
|
}
|
|
524
580
|
}
|
|
525
581
|
|