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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { CC_TO_OC_TOOL_NAMES } from "../mimicry/response-stream.mjs";
|
|
2
|
+
|
|
3
|
+
const ALIASES = new Map([
|
|
4
|
+
["shell", "bash"],
|
|
5
|
+
["subagent", "task"],
|
|
6
|
+
]);
|
|
7
|
+
const TO_WIRE = new Map([...CC_TO_OC_TOOL_NAMES].map(([wire, host]) => [host, wire]));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapt renamed v2 tools before the shared v1 wire policy, retaining a reverse
|
|
11
|
+
* map for this request only. Custom tools occupying a legacy name win: in that
|
|
12
|
+
* case the v2 name stays intact rather than being aliased ambiguously.
|
|
13
|
+
* @param {RequestInfo | URL} input
|
|
14
|
+
* @param {RequestInit} [init]
|
|
15
|
+
*/
|
|
16
|
+
export async function prepareV2Tools(input, init = {}) {
|
|
17
|
+
const body = init.body ?? (input instanceof Request && input.body ? await input.clone().text() : undefined);
|
|
18
|
+
if (typeof body !== "string") return { input, init };
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(body);
|
|
22
|
+
} catch {
|
|
23
|
+
// The shared executor owns request validation and its error diagnostics.
|
|
24
|
+
return { input, init };
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(parsed?.tools)) return { input, init };
|
|
27
|
+
const names = new Set(parsed.tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
|
|
28
|
+
const aliases = new Map();
|
|
29
|
+
const toolNameMap = new Map();
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const alias = ALIASES.get(name);
|
|
32
|
+
if (alias && !names.has(alias) && !names.has(TO_WIRE.get(alias))) aliases.set(name, alias);
|
|
33
|
+
const normalized = aliases.get(name) ?? name;
|
|
34
|
+
const wire = TO_WIRE.get(normalized) ?? normalized;
|
|
35
|
+
const restored = CC_TO_OC_TOOL_NAMES.get(wire) ?? (wire.startsWith("mcp_") ? wire.slice(4) : wire);
|
|
36
|
+
if (toolNameMap.has(restored) && toolNameMap.get(restored) !== name) {
|
|
37
|
+
throw new Error(`Anthropic tool names collide after wire normalization: ${name}`);
|
|
38
|
+
}
|
|
39
|
+
toolNameMap.set(restored, name);
|
|
40
|
+
}
|
|
41
|
+
const rename = (object, key) => {
|
|
42
|
+
if (typeof object?.[key] === "string" && aliases.has(object[key])) object[key] = aliases.get(object[key]);
|
|
43
|
+
};
|
|
44
|
+
const renameReference = (object, key) => {
|
|
45
|
+
rename(object, key);
|
|
46
|
+
if (typeof object?.[key] === "string") object[key] = TO_WIRE.get(object[key]) ?? object[key];
|
|
47
|
+
};
|
|
48
|
+
const visit = (block) => {
|
|
49
|
+
if (block?.type === "tool_use") rename(block, "name");
|
|
50
|
+
if (block?.type === "tool_reference") {
|
|
51
|
+
renameReference(block, "tool_name");
|
|
52
|
+
renameReference(block, "name");
|
|
53
|
+
}
|
|
54
|
+
if (block?.type === "tool_result" && Array.isArray(block.content)) block.content.forEach(visit);
|
|
55
|
+
};
|
|
56
|
+
for (const tool of parsed.tools) rename(tool, "name");
|
|
57
|
+
if (parsed.tool_choice?.type === "tool") renameReference(parsed.tool_choice, "name");
|
|
58
|
+
for (const message of parsed.messages ?? []) {
|
|
59
|
+
if (Array.isArray(message.content)) message.content.forEach(visit);
|
|
60
|
+
}
|
|
61
|
+
return { input, init: { ...init, body: JSON.stringify(parsed) }, toolNameMap };
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { perToolClassPrune, staleReadEviction } from "../message-transform.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reuse the existing pruning policies through a lossless view of v2 results.
|
|
5
|
+
* Only a result that a policy changes is replaced; IDs, tool inputs, provider
|
|
6
|
+
* metadata and all unmodified result objects retain their original shape.
|
|
7
|
+
* @param {Array<import('@opencode/ai').Message>} messages
|
|
8
|
+
* @param {object} strategies
|
|
9
|
+
*/
|
|
10
|
+
export function applyV2MessagePolicies(messages, strategies = {}) {
|
|
11
|
+
if (!strategies.stale_read_eviction && !strategies.per_tool_class_prune) return;
|
|
12
|
+
const bindings = [];
|
|
13
|
+
const view = messages.map((message) => ({
|
|
14
|
+
parts: message.content.flatMap((block) => {
|
|
15
|
+
if (block.type !== "tool-result" || block.result.type === "error") return [];
|
|
16
|
+
const result = block.result;
|
|
17
|
+
const output =
|
|
18
|
+
result.type === "content"
|
|
19
|
+
? result.value
|
|
20
|
+
.filter((part) => part.type === "text")
|
|
21
|
+
.map((part) => part.text)
|
|
22
|
+
.join("\n")
|
|
23
|
+
: typeof result.value === "string"
|
|
24
|
+
? result.value
|
|
25
|
+
: JSON.stringify(result.value);
|
|
26
|
+
const part = { type: "tool", tool: block.name, state: { status: "completed", output } };
|
|
27
|
+
bindings.push({ block, part, output });
|
|
28
|
+
return [part];
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
if (strategies.stale_read_eviction) staleReadEviction({ messages: view });
|
|
32
|
+
if (strategies.per_tool_class_prune) perToolClassPrune({ messages: view });
|
|
33
|
+
for (const { block, part, output } of bindings) {
|
|
34
|
+
if (part.state.output !== output) block.result = { type: "text", value: part.state.output };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Fail explicitly rather than sending opaque native checkpoints to an AI SDK. */
|
|
39
|
+
export function assertPortableV2History(messages) {
|
|
40
|
+
if (messages.some((message) => message.content.some((part) => part.type === "compaction"))) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"This session contains native provider compaction state. Start a new session to use the Anthropic OAuth compatibility transport.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TransformStream } from "node:stream/web";
|
|
2
|
+
|
|
3
|
+
// A local factory avoids the host's redundant npm install before our SDK hook.
|
|
4
|
+
// It also avoids the unversioned package's native-route rewrite. Core uses the
|
|
5
|
+
// provider ID as the option/metadata key for file:// factories.
|
|
6
|
+
export const V2_SDK_PACKAGE = new URL("./v2-sdk.mjs", import.meta.url).href;
|
|
7
|
+
const HOST_KEY = "anthropic";
|
|
8
|
+
const LEGACY_KEY = "anthropic@3.0.111";
|
|
9
|
+
|
|
10
|
+
function sdkOptions(options) {
|
|
11
|
+
// Preserve signed reasoning from sessions persisted under the old package key.
|
|
12
|
+
if (!options?.[LEGACY_KEY]) return options;
|
|
13
|
+
const { [LEGACY_KEY]: anthropic, ...rest } = options;
|
|
14
|
+
return { ...rest, anthropic: { ...anthropic, ...rest.anthropic } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function callOptions(options) {
|
|
18
|
+
return {
|
|
19
|
+
...options,
|
|
20
|
+
providerOptions: sdkOptions(options.providerOptions),
|
|
21
|
+
prompt: options.prompt.map((message) => ({
|
|
22
|
+
...message,
|
|
23
|
+
providerOptions: sdkOptions(message.providerOptions),
|
|
24
|
+
...(Array.isArray(message.content)
|
|
25
|
+
? { content: message.content.map((part) => ({ ...part, providerOptions: sdkOptions(part.providerOptions) })) }
|
|
26
|
+
: {}),
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hostMetadata(part) {
|
|
32
|
+
if (!part.providerMetadata?.anthropic) return part;
|
|
33
|
+
const { anthropic, ...rest } = part.providerMetadata;
|
|
34
|
+
return { ...part, providerMetadata: { ...rest, [HOST_KEY]: anthropic } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {import('@ai-sdk/provider').LanguageModelV3} language */
|
|
38
|
+
export function adaptV2Language(language) {
|
|
39
|
+
return {
|
|
40
|
+
specificationVersion: language.specificationVersion,
|
|
41
|
+
provider: language.provider,
|
|
42
|
+
modelId: language.modelId,
|
|
43
|
+
supportedUrls: language.supportedUrls,
|
|
44
|
+
async doGenerate(options) {
|
|
45
|
+
const result = await language.doGenerate(callOptions(options));
|
|
46
|
+
return { ...hostMetadata(result), content: result.content.map(hostMetadata) };
|
|
47
|
+
},
|
|
48
|
+
async doStream(options) {
|
|
49
|
+
const result = await language.doStream(callOptions(options));
|
|
50
|
+
return {
|
|
51
|
+
...result,
|
|
52
|
+
stream: result.stream.pipeThrough(
|
|
53
|
+
new TransformStream({ transform: (part, controller) => controller.enqueue(hostMetadata(part)) }),
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// OpenCode's built-in dynamic SDK hook runs before external plugin hooks.
|
|
2
|
+
// Its file:// loader uses this shipped, pinned factory without an npm install.
|
|
3
|
+
// Keep this a separate entry in the standalone package as well.
|
|
4
|
+
export { createAnthropic } from "@ai-sdk/anthropic";
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
6
|
+
import { adaptV2Language, V2_SDK_PACKAGE } from "./v2-language.mjs";
|
|
7
|
+
|
|
8
|
+
const TICKET_HEADER = "x-opencode-anthropic-fix-request";
|
|
9
|
+
export const V2_AUXILIARY_DEADLINE_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
function abortError(message) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
error.name = "AbortError";
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* OpenCode 2.0.21 does not pass an abortSignal to LanguageModelV3.doStream.
|
|
19
|
+
* Associate the host's HTTP hook with the custom fetch using a single-use
|
|
20
|
+
* ticket. Its private header is stripped before the shared executor can send
|
|
21
|
+
* anything. Public session interruption events cancel every request belonging
|
|
22
|
+
* to that session, including requests waiting for their first response byte.
|
|
23
|
+
* Auxiliary fiber interruption emits no such event on 2.0.21/2.0.22; a total
|
|
24
|
+
* auxiliary deadline bounds orphaned work without limiting primary streams.
|
|
25
|
+
*
|
|
26
|
+
* Core 2.0.21 also JSON-validates provider/model settings, so settings.fetch is
|
|
27
|
+
* rejected before initialization. A loopback bridge receives the host's normal
|
|
28
|
+
* HTTP fetch and delegates to the existing executor. The SDK keeps Core's fetch
|
|
29
|
+
* middleware; never put the returned internal fetch in provider/model settings.
|
|
30
|
+
*
|
|
31
|
+
* @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
|
|
33
|
+
* @returns {Promise<{fetch: typeof fetch, dispose: () => Promise<void>}>}
|
|
34
|
+
*/
|
|
35
|
+
export async function setupV2Transport(context, options) {
|
|
36
|
+
const isManagedModel = options.isManagedModel ?? ((model) => model.providerID === "anthropic");
|
|
37
|
+
const onError = options.onError ?? ((error) => console.error(`[anthropic] ${error.message}`));
|
|
38
|
+
const tickets = new Map();
|
|
39
|
+
const registrations = [];
|
|
40
|
+
const subscription = new AbortController();
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let eventFailure;
|
|
43
|
+
let bridge;
|
|
44
|
+
let bridgePromise;
|
|
45
|
+
const bridgeSockets = new Set();
|
|
46
|
+
|
|
47
|
+
const release = (ticket) => {
|
|
48
|
+
clearTimeout(ticket.deadline);
|
|
49
|
+
return tickets.delete(ticket.id);
|
|
50
|
+
};
|
|
51
|
+
const cancel = (ticket, reason) => {
|
|
52
|
+
ticket.controller.abort(reason);
|
|
53
|
+
release(ticket);
|
|
54
|
+
};
|
|
55
|
+
const cancelAll = (reason) => {
|
|
56
|
+
for (const ticket of tickets.values()) cancel(ticket, reason);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Begin listening before registering HTTP hooks. Losing the event stream must
|
|
60
|
+
// fail closed: otherwise a later stop could silently leave a paid request alive.
|
|
61
|
+
const eventTask = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
for await (const event of context.event.subscribe({ signal: subscription.signal })) {
|
|
64
|
+
if (event.type === "session.execution.interrupted") {
|
|
65
|
+
for (const ticket of tickets.values()) {
|
|
66
|
+
if (ticket.sessionID === event.data.sessionID) {
|
|
67
|
+
cancel(ticket, abortError("OpenCode session interrupted."));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (["session.execution.succeeded", "session.execution.failed"].includes(event.type)) {
|
|
71
|
+
for (const ticket of tickets.values()) {
|
|
72
|
+
// A request that never reached fetch may have failed in another
|
|
73
|
+
// HTTP hook. Keep active auxiliary streams until their own EOF.
|
|
74
|
+
if (!ticket.started && ticket.sessionID === event.data.sessionID) release(ticket);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!disposed) throw new Error("OpenCode session event stream closed unexpectedly.");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (disposed) return;
|
|
81
|
+
eventFailure = error instanceof Error ? error : new Error(String(error));
|
|
82
|
+
cancelAll(eventFailure);
|
|
83
|
+
onError(eventFailure);
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
/** @type {typeof fetch} */
|
|
88
|
+
const managedFetch = async (input, init) => {
|
|
89
|
+
if (disposed) throw abortError("Anthropic plugin has been disposed.");
|
|
90
|
+
if (eventFailure) throw eventFailure;
|
|
91
|
+
const request = input instanceof Request ? input : undefined;
|
|
92
|
+
const headers = new Headers(init?.headers ?? request?.headers);
|
|
93
|
+
const id = headers.get(TICKET_HEADER);
|
|
94
|
+
headers.delete(TICKET_HEADER);
|
|
95
|
+
const ticket = tickets.get(id);
|
|
96
|
+
if (!ticket || ticket.started) {
|
|
97
|
+
throw new Error("Managed Anthropic requests require the OpenCode v2 HTTP hook.");
|
|
98
|
+
}
|
|
99
|
+
ticket.started = true;
|
|
100
|
+
const callerSignal = init?.signal ?? request?.signal;
|
|
101
|
+
const signals = [ticket.controller.signal, callerSignal].filter(Boolean);
|
|
102
|
+
const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
103
|
+
const originalUserAgent = headers.get("user-agent");
|
|
104
|
+
if (!originalUserAgent?.includes("ai-sdk/anthropic/")) {
|
|
105
|
+
headers.set("user-agent", [originalUserAgent, "ai-sdk/anthropic/3.0.111"].filter(Boolean).join(" "));
|
|
106
|
+
}
|
|
107
|
+
// Normalize Request inputs too: no copy of the ticket-bearing Request is
|
|
108
|
+
// handed to the executor, even when it inspects input.headers directly.
|
|
109
|
+
for (const name of ["host", "connection", "content-length", "transfer-encoding", "keep-alive"])
|
|
110
|
+
headers.delete(name);
|
|
111
|
+
const cleanInput = request
|
|
112
|
+
? new Request(ticket.url, { method: request.method, body: request.body, headers, signal, duplex: "half" })
|
|
113
|
+
: ticket.url;
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
response = await options.fetch(cleanInput, { ...init, headers, signal });
|
|
118
|
+
signal.throwIfAborted();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
release(ticket);
|
|
121
|
+
if (response?.body) await response.body.cancel(error);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (!response.body) {
|
|
125
|
+
release(ticket);
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
const reader = response.body.getReader();
|
|
129
|
+
let settled = false;
|
|
130
|
+
let abort;
|
|
131
|
+
const finish = () => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
signal.removeEventListener("abort", abort);
|
|
135
|
+
release(ticket);
|
|
136
|
+
reader.releaseLock();
|
|
137
|
+
};
|
|
138
|
+
const body = new ReadableStream(
|
|
139
|
+
{
|
|
140
|
+
start(controller) {
|
|
141
|
+
abort = () => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
controller.error(signal.reason);
|
|
144
|
+
// Abort also reaches the executor. Explicit reader cancellation
|
|
145
|
+
// covers its in-memory response transforms and releases resources.
|
|
146
|
+
reader.cancel(signal.reason).then(finish, (error) => {
|
|
147
|
+
finish();
|
|
148
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
152
|
+
if (signal.aborted) abort();
|
|
153
|
+
},
|
|
154
|
+
async pull(controller) {
|
|
155
|
+
try {
|
|
156
|
+
const part = await reader.read();
|
|
157
|
+
if (settled || signal.aborted) return;
|
|
158
|
+
if (part.done) {
|
|
159
|
+
controller.close();
|
|
160
|
+
finish();
|
|
161
|
+
} else controller.enqueue(part.value);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!settled) {
|
|
164
|
+
controller.error(error);
|
|
165
|
+
finish();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async cancel(reason) {
|
|
170
|
+
// Remove our listener before aborting: cancellation already owns the
|
|
171
|
+
// reader, and must not error a stream that the consumer just closed.
|
|
172
|
+
signal.removeEventListener("abort", abort);
|
|
173
|
+
ticket.controller.abort(reason ?? abortError("Anthropic response cancelled."));
|
|
174
|
+
try {
|
|
175
|
+
await reader.cancel(reason);
|
|
176
|
+
} finally {
|
|
177
|
+
finish();
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{ highWaterMark: 0 },
|
|
182
|
+
);
|
|
183
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
async function ensureBridge() {
|
|
187
|
+
if (bridgePromise) return bridgePromise;
|
|
188
|
+
bridge = createServer((request, response) => {
|
|
189
|
+
const ticket = tickets.get(request.headers[TICKET_HEADER]);
|
|
190
|
+
if (request.method !== "POST" || request.url !== "/" || !ticket || ticket.started) {
|
|
191
|
+
response.writeHead(404).end();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Claim at the HTTP boundary before reading the body. The fetch bridge
|
|
195
|
+
// still checks started, so a second connection cannot replay this ticket.
|
|
196
|
+
if (ticket.claimed) {
|
|
197
|
+
response.writeHead(404).end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
ticket.claimed = true;
|
|
201
|
+
const disconnect = () => {
|
|
202
|
+
if (!response.writableFinished) cancel(ticket, abortError("OpenCode HTTP request disconnected."));
|
|
203
|
+
};
|
|
204
|
+
response.once("close", disconnect);
|
|
205
|
+
void (async () => {
|
|
206
|
+
try {
|
|
207
|
+
const chunks = [];
|
|
208
|
+
let size = 0;
|
|
209
|
+
for await (const chunk of request) {
|
|
210
|
+
size += chunk.length;
|
|
211
|
+
if (size > 32 * 1024 * 1024) throw new Error("Anthropic request exceeds 32 MiB.");
|
|
212
|
+
chunks.push(chunk);
|
|
213
|
+
}
|
|
214
|
+
const headers = new Headers();
|
|
215
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
216
|
+
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
217
|
+
}
|
|
218
|
+
const body = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
219
|
+
const result = await managedFetch(ticket.url, { method: "POST", headers, body });
|
|
220
|
+
const resultHeaders = Object.fromEntries(result.headers);
|
|
221
|
+
for (const name of ["connection", "content-length", "transfer-encoding", "content-encoding"]) {
|
|
222
|
+
delete resultHeaders[name];
|
|
223
|
+
}
|
|
224
|
+
response.writeHead(result.status, resultHeaders);
|
|
225
|
+
if (result.body) await pipeline(Readable.fromWeb(result.body), response);
|
|
226
|
+
else response.end();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const interrupted = ticket.controller.signal.aborted;
|
|
229
|
+
cancel(ticket, error);
|
|
230
|
+
if (!response.headersSent && !response.destroyed) {
|
|
231
|
+
response.writeHead(502, { "content-type": "application/json" });
|
|
232
|
+
response.end(
|
|
233
|
+
JSON.stringify({
|
|
234
|
+
type: "error",
|
|
235
|
+
error: { type: "api_error", message: "Anthropic compatibility request failed." },
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
} else response.destroy();
|
|
239
|
+
if (!interrupted) onError(new Error("Anthropic compatibility transport failed."));
|
|
240
|
+
} finally {
|
|
241
|
+
response.removeListener("close", disconnect);
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
});
|
|
245
|
+
bridge.on("clientError", (_error, socket) => socket.destroy());
|
|
246
|
+
bridge.on("connection", (socket) => {
|
|
247
|
+
bridgeSockets.add(socket);
|
|
248
|
+
socket.once("close", () => bridgeSockets.delete(socket));
|
|
249
|
+
});
|
|
250
|
+
bridgePromise = new Promise((resolve, reject) => {
|
|
251
|
+
bridge.once("error", reject);
|
|
252
|
+
bridge.listen(0, "127.0.0.1", () => {
|
|
253
|
+
bridge.removeListener("error", reject);
|
|
254
|
+
const address = bridge.address();
|
|
255
|
+
resolve(`http://127.0.0.1:${address.port}/`);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
bridge.unref();
|
|
259
|
+
return bridgePromise;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function closeBridge() {
|
|
263
|
+
if (!bridge) return;
|
|
264
|
+
await bridgePromise;
|
|
265
|
+
if (!bridge.listening) return;
|
|
266
|
+
for (const socket of bridgeSockets) socket.destroy();
|
|
267
|
+
await new Promise((resolve, reject) => bridge.close((error) => (error ? reject(error) : resolve())));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function releaseResources(reason) {
|
|
271
|
+
disposed = true;
|
|
272
|
+
cancelAll(reason);
|
|
273
|
+
subscription.abort();
|
|
274
|
+
const results = await Promise.allSettled([
|
|
275
|
+
...registrations.map(async (registration) => registration.dispose()),
|
|
276
|
+
closeBridge(),
|
|
277
|
+
eventTask,
|
|
278
|
+
]);
|
|
279
|
+
const failures = results.filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
280
|
+
if (failures.length) throw new AggregateError(failures, "Failed to dispose Anthropic transport resources.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
registrations.push(
|
|
285
|
+
await context.session.hook(
|
|
286
|
+
"http.request",
|
|
287
|
+
async (event) => {
|
|
288
|
+
if (!isManagedModel(event.model)) return;
|
|
289
|
+
if (disposed) throw abortError("Anthropic plugin has been disposed.");
|
|
290
|
+
if (eventFailure) throw eventFailure;
|
|
291
|
+
if (event.request.method !== "POST") throw new Error("Managed Anthropic HTTP requests must use POST.");
|
|
292
|
+
const url = event.request.url;
|
|
293
|
+
const ticket = {
|
|
294
|
+
id: randomUUID(),
|
|
295
|
+
url,
|
|
296
|
+
sessionID: event.sessionID,
|
|
297
|
+
controller: new AbortController(),
|
|
298
|
+
started: false,
|
|
299
|
+
};
|
|
300
|
+
tickets.set(ticket.id, ticket);
|
|
301
|
+
if (event.kind === "generate" || event.kind === "title") {
|
|
302
|
+
ticket.deadline = setTimeout(
|
|
303
|
+
() => cancel(ticket, abortError("Anthropic auxiliary request deadline exceeded.")),
|
|
304
|
+
options.auxiliaryDeadlineMs ?? V2_AUXILIARY_DEADLINE_MS,
|
|
305
|
+
);
|
|
306
|
+
ticket.deadline.unref();
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const bridgeURL = await ensureBridge();
|
|
310
|
+
ticket.controller.signal.throwIfAborted();
|
|
311
|
+
if (disposed) throw abortError("Anthropic plugin has been disposed.");
|
|
312
|
+
if (eventFailure) throw eventFailure;
|
|
313
|
+
const headers = new Headers(event.request.headers);
|
|
314
|
+
headers.set(TICKET_HEADER, ticket.id);
|
|
315
|
+
event.request = new Request(bridgeURL, {
|
|
316
|
+
method: event.request.method,
|
|
317
|
+
body: event.request.body,
|
|
318
|
+
signal: event.request.signal,
|
|
319
|
+
duplex: "half",
|
|
320
|
+
headers,
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
cancel(ticket, error);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
{ providerID: "anthropic" },
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
registrations.push(
|
|
331
|
+
await context.session.hook(
|
|
332
|
+
"retry",
|
|
333
|
+
(event) => {
|
|
334
|
+
if (isManagedModel(event.model)) event.decision = { retry: false };
|
|
335
|
+
},
|
|
336
|
+
{ providerID: "anthropic" },
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
registrations.push(
|
|
340
|
+
await context.aisdk.hook(
|
|
341
|
+
"sdk",
|
|
342
|
+
(event) => {
|
|
343
|
+
if (event.package !== V2_SDK_PACKAGE || !isManagedModel(event.model)) return;
|
|
344
|
+
const sdk = createAnthropic({
|
|
345
|
+
...event.options,
|
|
346
|
+
// The shared executor owns real account credentials and refresh.
|
|
347
|
+
// A stable placeholder avoids caching credentials in SDK instances.
|
|
348
|
+
apiKey: undefined,
|
|
349
|
+
authToken: "opencode-managed-oauth",
|
|
350
|
+
});
|
|
351
|
+
event.sdk = { ...sdk, languageModel: (id) => adaptV2Language(sdk.languageModel(id)) };
|
|
352
|
+
},
|
|
353
|
+
{ providerID: "anthropic" },
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
try {
|
|
358
|
+
await releaseResources(error);
|
|
359
|
+
} catch (cleanupError) {
|
|
360
|
+
throw new AggregateError([error, cleanupError], "Anthropic transport initialization and cleanup failed.", {
|
|
361
|
+
cause: cleanupError,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
fetch: managedFetch,
|
|
369
|
+
async dispose() {
|
|
370
|
+
if (disposed) return;
|
|
371
|
+
await releaseResources(abortError("Anthropic plugin has been disposed."));
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|