@foldrun/core 0.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
// The runtime's own translator: Anthropic Messages in, Chat Completions or
|
|
2
|
+
// Responses out.
|
|
3
|
+
//
|
|
4
|
+
// The Agent SDK speaks one wire format. Most of the providers people bring
|
|
5
|
+
// keys for speak another — OpenAI's Chat Completions, which OpenAI, Gemini,
|
|
6
|
+
// xAI, Groq, Mistral, Hugging Face, Cloudflare and every local runtime all
|
|
7
|
+
// implement. Rather than teach the runtime a second format, or install a
|
|
8
|
+
// gateway beside it, this listens on localhost inside the run sandbox,
|
|
9
|
+
// presents an Anthropic endpoint to the SDK, and rewrites each request and
|
|
10
|
+
// each streamed reply between the two shapes. It exists only for the
|
|
11
|
+
// duration of a step, holds only that step's key, and dies with it.
|
|
12
|
+
//
|
|
13
|
+
// `format: responses` is the third shape: OpenAI's newer Responses API,
|
|
14
|
+
// which OpenAI recommends for new work and almost nobody else implements.
|
|
15
|
+
// Same translator, same server, a second pair of mappings — chosen per
|
|
16
|
+
// provider block, never guessed. It carries one thing Chat Completions
|
|
17
|
+
// cannot: a document block crosses as an input_file, so a PDF an agent
|
|
18
|
+
// reads reaches the model instead of being dropped.
|
|
19
|
+
//
|
|
20
|
+
// It is deliberately small. It translates what the SDK actually sends — a
|
|
21
|
+
// system prompt, text and image blocks, tools with JSON schemas, tool calls
|
|
22
|
+
// and their results, streaming — and drops what a Chat-Completions endpoint
|
|
23
|
+
// has no word for (cache_control, top_k, server-side tools, thinking on
|
|
24
|
+
// endpoints that lack reasoning_effort). Anything dropped is said once in
|
|
25
|
+
// the run trace. The translation is pure functions over JSON so the tests
|
|
26
|
+
// can drive every mapping without a network; the server around them is a
|
|
27
|
+
// few dozen lines.
|
|
28
|
+
import http from "node:http";
|
|
29
|
+
import crypto from "node:crypto";
|
|
30
|
+
import { PROTECTED_PARAMS } from "./providers.js";
|
|
31
|
+
import { viaEgress } from "./egress.js";
|
|
32
|
+
function dropped() {
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
return {
|
|
35
|
+
add: (w) => void seen.add(w),
|
|
36
|
+
lines: () => [...seen].map((w) => `translator: dropped ${w} — the endpoint has no equivalent`),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ------------------------------------------------------- request mapping
|
|
40
|
+
/**
|
|
41
|
+
* The SDK's `metadata.user_id` is a long composite string; OpenAI's own
|
|
42
|
+
* endpoint caps `safety_identifier` (Responses) at 64 characters and
|
|
43
|
+
* rejects the request outright — a gateway in front of it did not, which is
|
|
44
|
+
* how this shipped untested. A stable digest keeps the identity property
|
|
45
|
+
* (same user, same id) inside the limit. Applied to Chat Completions' `user`
|
|
46
|
+
* too, for the same reason.
|
|
47
|
+
*/
|
|
48
|
+
function stableId(id) {
|
|
49
|
+
return id.length <= 64 ? id : crypto.createHash("sha256").update(id).digest("hex").slice(0, 64);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether a model is one OpenAI's own endpoint refuses a reasoning-effort
|
|
53
|
+
* parameter for ("Unsupported parameter: 'reasoning.effort'" on gpt-4o-mini).
|
|
54
|
+
* A gateway in front of it ignored the field, which is how the preset's
|
|
55
|
+
* reasoningEffort shipped untested against the origin. The families that do
|
|
56
|
+
* not reason are the ones named — anything else (o-series, gpt-5, grok,
|
|
57
|
+
* gemini, deepseek-r, qwen thinking) keeps getting the knob, because on a
|
|
58
|
+
* gateway the id alone cannot say and an ignored field costs nothing.
|
|
59
|
+
*/
|
|
60
|
+
export function modelRejectsReasoning(model) {
|
|
61
|
+
const id = String(model).split("/").pop()?.toLowerCase() ?? "";
|
|
62
|
+
return /^(gpt-3|gpt-4|chatgpt|gpt-5-chat|davinci|babbage)/.test(id);
|
|
63
|
+
}
|
|
64
|
+
function textOf(content) {
|
|
65
|
+
if (typeof content === "string")
|
|
66
|
+
return content;
|
|
67
|
+
if (!Array.isArray(content))
|
|
68
|
+
return "";
|
|
69
|
+
return content
|
|
70
|
+
.filter((b) => b && typeof b === "object" && b.type === "text")
|
|
71
|
+
.map((b) => String(b.text ?? ""))
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
function imagePart(block) {
|
|
75
|
+
const src = block.source;
|
|
76
|
+
if (!src)
|
|
77
|
+
return null;
|
|
78
|
+
if (src.type === "base64" && typeof src.data === "string") {
|
|
79
|
+
return { type: "image_url", image_url: { url: `data:${String(src.media_type ?? "image/png")};base64,${src.data}` } };
|
|
80
|
+
}
|
|
81
|
+
if (src.type === "url" && typeof src.url === "string")
|
|
82
|
+
return { type: "image_url", image_url: { url: src.url } };
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* An Anthropic Messages request as a Chat Completions request. Pure.
|
|
87
|
+
*
|
|
88
|
+
* Message order matters to the other side: a tool result must directly
|
|
89
|
+
* follow the assistant turn that called the tool. Anthropic puts results
|
|
90
|
+
* inside the next user message, so those blocks become `tool` messages
|
|
91
|
+
* first and whatever else the user said follows as a user message.
|
|
92
|
+
*/
|
|
93
|
+
export function toChatCompletions(req, opts, drop = dropped()) {
|
|
94
|
+
const out = { model: req.model, stream: opts.stream };
|
|
95
|
+
const messages = [];
|
|
96
|
+
const system = textOf(req.system);
|
|
97
|
+
if (system)
|
|
98
|
+
messages.push({ role: "system", content: system });
|
|
99
|
+
for (const m of (Array.isArray(req.messages) ? req.messages : [])) {
|
|
100
|
+
const content = m.content;
|
|
101
|
+
if (m.role === "assistant") {
|
|
102
|
+
const text = textOf(content);
|
|
103
|
+
const toolCalls = [];
|
|
104
|
+
for (const b of Array.isArray(content) ? content : []) {
|
|
105
|
+
if (b.type === "tool_use") {
|
|
106
|
+
toolCalls.push({
|
|
107
|
+
id: String(b.id),
|
|
108
|
+
type: "function",
|
|
109
|
+
function: { name: String(b.name), arguments: JSON.stringify(b.input ?? {}) },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (b.type === "thinking" || b.type === "redacted_thinking") {
|
|
113
|
+
drop.add("thinking blocks in the transcript");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const msg = { role: "assistant" };
|
|
117
|
+
if (text)
|
|
118
|
+
msg.content = text;
|
|
119
|
+
else if (!toolCalls.length)
|
|
120
|
+
msg.content = "";
|
|
121
|
+
if (toolCalls.length)
|
|
122
|
+
msg.tool_calls = toolCalls;
|
|
123
|
+
messages.push(msg);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// user
|
|
127
|
+
if (typeof content === "string") {
|
|
128
|
+
messages.push({ role: "user", content });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const b of Array.isArray(content) ? content : []) {
|
|
133
|
+
if (b.type === "tool_result") {
|
|
134
|
+
const inner = b.content;
|
|
135
|
+
const text = typeof inner === "string" ? inner : textOf(inner);
|
|
136
|
+
messages.push({
|
|
137
|
+
role: "tool",
|
|
138
|
+
tool_call_id: String(b.tool_use_id),
|
|
139
|
+
content: text || (b.is_error ? "error" : "ok"),
|
|
140
|
+
});
|
|
141
|
+
if (Array.isArray(inner) && inner.some((x) => x.type === "image"))
|
|
142
|
+
drop.add("images inside tool results");
|
|
143
|
+
}
|
|
144
|
+
else if (b.type === "text") {
|
|
145
|
+
parts.push({ type: "text", text: String(b.text ?? "") });
|
|
146
|
+
}
|
|
147
|
+
else if (b.type === "image") {
|
|
148
|
+
const p = imagePart(b);
|
|
149
|
+
if (p)
|
|
150
|
+
parts.push(p);
|
|
151
|
+
}
|
|
152
|
+
else if (b.type === "document") {
|
|
153
|
+
drop.add("document blocks");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (parts.length) {
|
|
157
|
+
// A single text part reads as a plain string on every endpoint;
|
|
158
|
+
// a list is only needed when an image is among them.
|
|
159
|
+
messages.push({
|
|
160
|
+
role: "user",
|
|
161
|
+
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out.messages = messages;
|
|
166
|
+
const tools = [];
|
|
167
|
+
for (const t of (Array.isArray(req.tools) ? req.tools : [])) {
|
|
168
|
+
if (t.type && t.type !== "custom") {
|
|
169
|
+
drop.add(`server-side tool ${String(t.type)}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
tools.push({
|
|
173
|
+
type: "function",
|
|
174
|
+
function: {
|
|
175
|
+
name: String(t.name),
|
|
176
|
+
...(t.description ? { description: String(t.description) } : {}),
|
|
177
|
+
parameters: t.input_schema ?? { type: "object", properties: {} },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (tools.length)
|
|
182
|
+
out.tools = tools;
|
|
183
|
+
const tc = req.tool_choice;
|
|
184
|
+
if (tc && tools.length) {
|
|
185
|
+
if (tc.type === "auto")
|
|
186
|
+
out.tool_choice = "auto";
|
|
187
|
+
else if (tc.type === "any")
|
|
188
|
+
out.tool_choice = "required";
|
|
189
|
+
else if (tc.type === "none")
|
|
190
|
+
out.tool_choice = "none";
|
|
191
|
+
else if (tc.type === "tool" && tc.name)
|
|
192
|
+
out.tool_choice = { type: "function", function: { name: String(tc.name) } };
|
|
193
|
+
if (tc.disable_parallel_tool_use === true)
|
|
194
|
+
out.parallel_tool_calls = false;
|
|
195
|
+
}
|
|
196
|
+
if (typeof req.max_tokens === "number")
|
|
197
|
+
out[opts.maxTokensParam ?? "max_tokens"] = req.max_tokens;
|
|
198
|
+
if (typeof req.temperature === "number")
|
|
199
|
+
out.temperature = req.temperature;
|
|
200
|
+
if (typeof req.top_p === "number")
|
|
201
|
+
out.top_p = req.top_p;
|
|
202
|
+
if (req.top_k !== undefined)
|
|
203
|
+
drop.add("top_k");
|
|
204
|
+
if (Array.isArray(req.stop_sequences) && req.stop_sequences.length)
|
|
205
|
+
out.stop = req.stop_sequences;
|
|
206
|
+
const meta = req.metadata;
|
|
207
|
+
if (meta && typeof meta.user_id === "string")
|
|
208
|
+
out.user = stableId(meta.user_id);
|
|
209
|
+
if (opts.stream)
|
|
210
|
+
out.stream_options = { include_usage: true };
|
|
211
|
+
// Thinking: budget → effort where the endpoint has the word, dropped
|
|
212
|
+
// where it does not. `effort` spelled directly is honoured the same way.
|
|
213
|
+
const thinking = req.thinking;
|
|
214
|
+
const effortWord = typeof req.effort === "string" ? req.effort : req.output_config?.effort;
|
|
215
|
+
let effort = null;
|
|
216
|
+
if (typeof effortWord === "string")
|
|
217
|
+
effort = { low: "low", medium: "medium", high: "high", xhigh: "high", max: "high" }[effortWord] ?? null;
|
|
218
|
+
else if (thinking && thinking.type === "enabled") {
|
|
219
|
+
const b = Number(thinking.budget_tokens) || 0;
|
|
220
|
+
effort = b <= 2048 ? "low" : b <= 8192 ? "medium" : "high";
|
|
221
|
+
}
|
|
222
|
+
else if (thinking && thinking.type === "adaptive")
|
|
223
|
+
effort = "medium";
|
|
224
|
+
if (effort) {
|
|
225
|
+
if (opts.reasoningEffort && !modelRejectsReasoning(String(req.model)))
|
|
226
|
+
out.reasoning_effort = effort;
|
|
227
|
+
else
|
|
228
|
+
drop.add(opts.reasoningEffort ? "thinking / effort (this model does not reason)" : "thinking / effort");
|
|
229
|
+
}
|
|
230
|
+
// The author's own fields, last, so the file wins over anything decided
|
|
231
|
+
// above — including a `reasoning_effort` they would rather set by hand.
|
|
232
|
+
// `null` removes a field the translation would otherwise have sent, which
|
|
233
|
+
// is the only way to say "do not send temperature at all" to an endpoint
|
|
234
|
+
// that rejects it. Structural keys were dropped at parse time.
|
|
235
|
+
for (const [key, value] of Object.entries(opts.params ?? {})) {
|
|
236
|
+
if (PROTECTED_PARAMS.includes(key))
|
|
237
|
+
continue;
|
|
238
|
+
if (value === null)
|
|
239
|
+
delete out[key];
|
|
240
|
+
else
|
|
241
|
+
out[key] = value;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* An Anthropic Messages request as a Responses request. Pure.
|
|
247
|
+
*
|
|
248
|
+
* The Responses shape is flatter than Chat Completions: the system prompt
|
|
249
|
+
* is `instructions`, a tool call and its result are top-level items rather
|
|
250
|
+
* than an assistant field and a `tool` role, and a document is an input
|
|
251
|
+
* file the model reads itself. Order is preserved as written: a
|
|
252
|
+
* function_call item, then its function_call_output, then whatever else the
|
|
253
|
+
* user said.
|
|
254
|
+
*/
|
|
255
|
+
export function toResponses(req, opts, drop = dropped()) {
|
|
256
|
+
const out = { model: req.model, stream: opts.stream, store: false };
|
|
257
|
+
const system = textOf(req.system);
|
|
258
|
+
if (system)
|
|
259
|
+
out.instructions = system;
|
|
260
|
+
const input = [];
|
|
261
|
+
for (const m of (Array.isArray(req.messages) ? req.messages : [])) {
|
|
262
|
+
const content = m.content;
|
|
263
|
+
if (m.role === "assistant") {
|
|
264
|
+
const text = textOf(content);
|
|
265
|
+
if (text)
|
|
266
|
+
input.push({ role: "assistant", content: [{ type: "output_text", text }] });
|
|
267
|
+
for (const b of Array.isArray(content) ? content : []) {
|
|
268
|
+
if (b.type === "tool_use") {
|
|
269
|
+
input.push({ type: "function_call", call_id: String(b.id), name: String(b.name), arguments: JSON.stringify(b.input ?? {}) });
|
|
270
|
+
}
|
|
271
|
+
else if (b.type === "thinking" || b.type === "redacted_thinking") {
|
|
272
|
+
drop.add("thinking blocks in the transcript");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (typeof content === "string") {
|
|
278
|
+
input.push({ role: "user", content });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const parts = [];
|
|
282
|
+
for (const b of Array.isArray(content) ? content : []) {
|
|
283
|
+
if (b.type === "tool_result") {
|
|
284
|
+
const inner = b.content;
|
|
285
|
+
const text = typeof inner === "string" ? inner : textOf(inner);
|
|
286
|
+
input.push({ type: "function_call_output", call_id: String(b.tool_use_id), output: text || (b.is_error ? "error" : "ok") });
|
|
287
|
+
if (Array.isArray(inner) && inner.some((x) => x.type === "image"))
|
|
288
|
+
drop.add("images inside tool results");
|
|
289
|
+
}
|
|
290
|
+
else if (b.type === "text") {
|
|
291
|
+
parts.push({ type: "input_text", text: String(b.text ?? "") });
|
|
292
|
+
}
|
|
293
|
+
else if (b.type === "image") {
|
|
294
|
+
const src = b.source;
|
|
295
|
+
if (src?.type === "base64" && typeof src.data === "string") {
|
|
296
|
+
parts.push({ type: "input_image", image_url: `data:${String(src.media_type ?? "image/png")};base64,${src.data}`, detail: "auto" });
|
|
297
|
+
}
|
|
298
|
+
else if (src?.type === "url" && typeof src.url === "string") {
|
|
299
|
+
parts.push({ type: "input_image", image_url: src.url, detail: "auto" });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (b.type === "document") {
|
|
303
|
+
// The one thing this shape can say that Chat Completions cannot.
|
|
304
|
+
const src = b.source;
|
|
305
|
+
const title = typeof b.title === "string" ? b.title : "document.pdf";
|
|
306
|
+
if (src?.type === "base64" && typeof src.data === "string") {
|
|
307
|
+
parts.push({ type: "input_file", filename: title, file_data: `data:${String(src.media_type ?? "application/pdf")};base64,${src.data}` });
|
|
308
|
+
}
|
|
309
|
+
else if (src?.type === "url" && typeof src.url === "string") {
|
|
310
|
+
parts.push({ type: "input_file", file_url: src.url });
|
|
311
|
+
}
|
|
312
|
+
else if (src?.type === "text" && typeof src.data === "string") {
|
|
313
|
+
parts.push({ type: "input_text", text: src.data });
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
drop.add("document blocks of that source type");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (parts.length)
|
|
321
|
+
input.push({ role: "user", content: parts });
|
|
322
|
+
}
|
|
323
|
+
out.input = input;
|
|
324
|
+
const tools = [];
|
|
325
|
+
for (const t of (Array.isArray(req.tools) ? req.tools : [])) {
|
|
326
|
+
if (t.type && t.type !== "custom") {
|
|
327
|
+
drop.add(`server-side tool ${String(t.type)}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
tools.push({
|
|
331
|
+
type: "function",
|
|
332
|
+
name: String(t.name),
|
|
333
|
+
...(t.description ? { description: String(t.description) } : {}),
|
|
334
|
+
parameters: t.input_schema ?? { type: "object", properties: {} },
|
|
335
|
+
strict: false,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (tools.length)
|
|
339
|
+
out.tools = tools;
|
|
340
|
+
const tc = req.tool_choice;
|
|
341
|
+
if (tc && tools.length) {
|
|
342
|
+
if (tc.type === "auto")
|
|
343
|
+
out.tool_choice = "auto";
|
|
344
|
+
else if (tc.type === "any")
|
|
345
|
+
out.tool_choice = "required";
|
|
346
|
+
else if (tc.type === "none")
|
|
347
|
+
out.tool_choice = "none";
|
|
348
|
+
else if (tc.type === "tool" && tc.name)
|
|
349
|
+
out.tool_choice = { type: "function", name: String(tc.name) };
|
|
350
|
+
if (tc.disable_parallel_tool_use === true)
|
|
351
|
+
out.parallel_tool_calls = false;
|
|
352
|
+
}
|
|
353
|
+
if (typeof req.max_tokens === "number")
|
|
354
|
+
out.max_output_tokens = req.max_tokens;
|
|
355
|
+
if (typeof req.temperature === "number")
|
|
356
|
+
out.temperature = req.temperature;
|
|
357
|
+
if (typeof req.top_p === "number")
|
|
358
|
+
out.top_p = req.top_p;
|
|
359
|
+
if (req.top_k !== undefined)
|
|
360
|
+
drop.add("top_k");
|
|
361
|
+
if (Array.isArray(req.stop_sequences) && req.stop_sequences.length)
|
|
362
|
+
drop.add("stop_sequences");
|
|
363
|
+
const meta = req.metadata;
|
|
364
|
+
if (meta && typeof meta.user_id === "string")
|
|
365
|
+
out.safety_identifier = stableId(meta.user_id);
|
|
366
|
+
const thinking = req.thinking;
|
|
367
|
+
const effortWord = typeof req.effort === "string" ? req.effort : req.output_config?.effort;
|
|
368
|
+
let effort = null;
|
|
369
|
+
if (typeof effortWord === "string")
|
|
370
|
+
effort = { low: "low", medium: "medium", high: "high", xhigh: "high", max: "high" }[effortWord] ?? null;
|
|
371
|
+
else if (thinking && thinking.type === "enabled") {
|
|
372
|
+
const b = Number(thinking.budget_tokens) || 0;
|
|
373
|
+
effort = b <= 2048 ? "low" : b <= 8192 ? "medium" : "high";
|
|
374
|
+
}
|
|
375
|
+
else if (thinking && thinking.type === "adaptive")
|
|
376
|
+
effort = "medium";
|
|
377
|
+
if (effort) {
|
|
378
|
+
if (opts.reasoningEffort !== false && !modelRejectsReasoning(String(req.model)))
|
|
379
|
+
out.reasoning = { effort };
|
|
380
|
+
else
|
|
381
|
+
drop.add(opts.reasoningEffort !== false ? "thinking / effort (this model does not reason)" : "thinking / effort");
|
|
382
|
+
}
|
|
383
|
+
for (const [key, value] of Object.entries(opts.params ?? {})) {
|
|
384
|
+
if (PROTECTED_PARAMS.includes(key))
|
|
385
|
+
continue;
|
|
386
|
+
if (value === null)
|
|
387
|
+
delete out[key];
|
|
388
|
+
else
|
|
389
|
+
out[key] = value;
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
// ------------------------------------------------------- response mapping
|
|
394
|
+
function stopReason(finish) {
|
|
395
|
+
switch (finish) {
|
|
396
|
+
case "tool_calls":
|
|
397
|
+
case "function_call":
|
|
398
|
+
return "tool_use";
|
|
399
|
+
case "length":
|
|
400
|
+
return "max_tokens";
|
|
401
|
+
default:
|
|
402
|
+
return "end_turn";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function parseArgs(raw) {
|
|
406
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
407
|
+
return {};
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(raw);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return { _unparsed: raw };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
416
|
+
export function fromChatCompletion(res, requestedModel) {
|
|
417
|
+
const choice = (res.choices ?? [])[0] ?? {};
|
|
418
|
+
const msg = choice.message ?? {};
|
|
419
|
+
const content = [];
|
|
420
|
+
const text = typeof msg.content === "string" ? msg.content : textOf(msg.content);
|
|
421
|
+
if (text)
|
|
422
|
+
content.push({ type: "text", text });
|
|
423
|
+
for (const tc of (Array.isArray(msg.tool_calls) ? msg.tool_calls : [])) {
|
|
424
|
+
const fn = tc.function ?? {};
|
|
425
|
+
content.push({
|
|
426
|
+
type: "tool_use",
|
|
427
|
+
id: String(tc.id ?? `call_${crypto.randomBytes(6).toString("hex")}`),
|
|
428
|
+
name: String(fn.name ?? ""),
|
|
429
|
+
input: parseArgs(fn.arguments),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const usage = res.usage ?? {};
|
|
433
|
+
return {
|
|
434
|
+
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
435
|
+
type: "message",
|
|
436
|
+
role: "assistant",
|
|
437
|
+
model: String(res.model ?? requestedModel),
|
|
438
|
+
content,
|
|
439
|
+
stop_reason: stopReason(choice.finish_reason),
|
|
440
|
+
stop_sequence: null,
|
|
441
|
+
usage: {
|
|
442
|
+
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
443
|
+
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The streaming half: Chat-Completions chunks in, Anthropic SSE events out.
|
|
449
|
+
* A state machine because the two streams disagree about structure — one
|
|
450
|
+
* is a flat sequence of deltas, the other is blocks with explicit starts and
|
|
451
|
+
* stops — and a tool call's arguments arrive as fragments that the other
|
|
452
|
+
* side wants as `input_json_delta`s inside one open block.
|
|
453
|
+
*/
|
|
454
|
+
export class StreamTranslator {
|
|
455
|
+
started = false;
|
|
456
|
+
nextIndex = 0;
|
|
457
|
+
textIndex = null;
|
|
458
|
+
/** Chat-Completions tool index → Anthropic block index. */
|
|
459
|
+
tools = new Map();
|
|
460
|
+
finish = null;
|
|
461
|
+
usage = null;
|
|
462
|
+
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
463
|
+
model;
|
|
464
|
+
constructor(model) {
|
|
465
|
+
this.model = model;
|
|
466
|
+
}
|
|
467
|
+
event(name, data) {
|
|
468
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
469
|
+
}
|
|
470
|
+
start(chunk) {
|
|
471
|
+
if (this.started)
|
|
472
|
+
return [];
|
|
473
|
+
this.started = true;
|
|
474
|
+
if (typeof chunk.id === "string")
|
|
475
|
+
this.id = chunk.id;
|
|
476
|
+
return [
|
|
477
|
+
this.event("message_start", {
|
|
478
|
+
type: "message_start",
|
|
479
|
+
message: {
|
|
480
|
+
id: this.id,
|
|
481
|
+
type: "message",
|
|
482
|
+
role: "assistant",
|
|
483
|
+
model: String(chunk.model ?? this.model),
|
|
484
|
+
content: [],
|
|
485
|
+
stop_reason: null,
|
|
486
|
+
stop_sequence: null,
|
|
487
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
closeText() {
|
|
493
|
+
if (this.textIndex === null)
|
|
494
|
+
return [];
|
|
495
|
+
const i = this.textIndex;
|
|
496
|
+
this.textIndex = null;
|
|
497
|
+
return [this.event("content_block_stop", { type: "content_block_stop", index: i })];
|
|
498
|
+
}
|
|
499
|
+
/** One parsed chunk in, zero or more SSE events out. */
|
|
500
|
+
feed(chunk) {
|
|
501
|
+
const out = this.start(chunk);
|
|
502
|
+
const u = chunk.usage;
|
|
503
|
+
if (u && (u.prompt_tokens !== undefined || u.completion_tokens !== undefined)) {
|
|
504
|
+
this.usage = { input: Number(u.prompt_tokens ?? 0), output: Number(u.completion_tokens ?? 0) };
|
|
505
|
+
}
|
|
506
|
+
const choice = (chunk.choices ?? [])[0];
|
|
507
|
+
if (!choice)
|
|
508
|
+
return out;
|
|
509
|
+
if (choice.finish_reason)
|
|
510
|
+
this.finish = choice.finish_reason;
|
|
511
|
+
const delta = choice.delta ?? {};
|
|
512
|
+
const text = typeof delta.content === "string" ? delta.content : "";
|
|
513
|
+
if (text) {
|
|
514
|
+
if (this.textIndex === null) {
|
|
515
|
+
this.textIndex = this.nextIndex++;
|
|
516
|
+
out.push(this.event("content_block_start", {
|
|
517
|
+
type: "content_block_start",
|
|
518
|
+
index: this.textIndex,
|
|
519
|
+
content_block: { type: "text", text: "" },
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
out.push(this.event("content_block_delta", {
|
|
523
|
+
type: "content_block_delta",
|
|
524
|
+
index: this.textIndex,
|
|
525
|
+
delta: { type: "text_delta", text },
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
for (const [n, tc] of (Array.isArray(delta.tool_calls) ? delta.tool_calls : []).entries()) {
|
|
529
|
+
const idx = typeof tc.index === "number" ? tc.index : this.tools.size + n;
|
|
530
|
+
const fn = tc.function ?? {};
|
|
531
|
+
let entry = this.tools.get(idx);
|
|
532
|
+
if (!entry) {
|
|
533
|
+
// A new call: text stops here (a block is one thing), the tool
|
|
534
|
+
// block opens with the id and name the first fragment carries.
|
|
535
|
+
out.push(...this.closeText());
|
|
536
|
+
entry = { block: this.nextIndex++, argsSeen: false };
|
|
537
|
+
this.tools.set(idx, entry);
|
|
538
|
+
out.push(this.event("content_block_start", {
|
|
539
|
+
type: "content_block_start",
|
|
540
|
+
index: entry.block,
|
|
541
|
+
content_block: {
|
|
542
|
+
type: "tool_use",
|
|
543
|
+
id: String(tc.id ?? `call_${crypto.randomBytes(6).toString("hex")}`),
|
|
544
|
+
name: String(fn.name ?? ""),
|
|
545
|
+
input: {},
|
|
546
|
+
},
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
const args = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
550
|
+
if (args) {
|
|
551
|
+
entry.argsSeen = true;
|
|
552
|
+
out.push(this.event("content_block_delta", {
|
|
553
|
+
type: "content_block_delta",
|
|
554
|
+
index: entry.block,
|
|
555
|
+
delta: { type: "input_json_delta", partial_json: args },
|
|
556
|
+
}));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
/** The stream ended: close what is open, say why, and stop. */
|
|
562
|
+
finishStream() {
|
|
563
|
+
const out = this.start({});
|
|
564
|
+
out.push(...this.closeText());
|
|
565
|
+
for (const entry of this.tools.values()) {
|
|
566
|
+
// Anthropic expects the concatenated partial_json to parse; a call
|
|
567
|
+
// that streamed no arguments at all needs an explicit empty object.
|
|
568
|
+
if (!entry.argsSeen) {
|
|
569
|
+
out.push(this.event("content_block_delta", {
|
|
570
|
+
type: "content_block_delta",
|
|
571
|
+
index: entry.block,
|
|
572
|
+
delta: { type: "input_json_delta", partial_json: "{}" },
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
576
|
+
}
|
|
577
|
+
const reason = this.tools.size && this.finish !== "length" ? "tool_use" : stopReason(this.finish);
|
|
578
|
+
out.push(this.event("message_delta", {
|
|
579
|
+
type: "message_delta",
|
|
580
|
+
delta: { stop_reason: reason, stop_sequence: null },
|
|
581
|
+
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
582
|
+
}));
|
|
583
|
+
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** A finished Response as an Anthropic message. Pure. */
|
|
588
|
+
export function fromResponses(res, requestedModel) {
|
|
589
|
+
const content = [];
|
|
590
|
+
for (const item of (Array.isArray(res.output) ? res.output : [])) {
|
|
591
|
+
if (item.type === "message") {
|
|
592
|
+
const text = (Array.isArray(item.content) ? item.content : [])
|
|
593
|
+
.map((c) => (c.type === "output_text" ? String(c.text ?? "") : c.type === "refusal" ? String(c.refusal ?? "") : ""))
|
|
594
|
+
.join("");
|
|
595
|
+
if (text)
|
|
596
|
+
content.push({ type: "text", text });
|
|
597
|
+
}
|
|
598
|
+
else if (item.type === "function_call") {
|
|
599
|
+
content.push({
|
|
600
|
+
type: "tool_use",
|
|
601
|
+
id: String(item.call_id ?? item.id ?? `call_${crypto.randomBytes(6).toString("hex")}`),
|
|
602
|
+
name: String(item.name ?? ""),
|
|
603
|
+
input: parseArgs(item.arguments),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
// reasoning items and server-tool calls have no Anthropic block here
|
|
607
|
+
}
|
|
608
|
+
const usage = res.usage ?? {};
|
|
609
|
+
const incomplete = res.incomplete_details?.reason;
|
|
610
|
+
const stop = content.some((c) => c.type === "tool_use") ? "tool_use" : incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
611
|
+
return {
|
|
612
|
+
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
613
|
+
type: "message",
|
|
614
|
+
role: "assistant",
|
|
615
|
+
model: String(res.model ?? requestedModel),
|
|
616
|
+
content,
|
|
617
|
+
stop_reason: stop,
|
|
618
|
+
stop_sequence: null,
|
|
619
|
+
usage: {
|
|
620
|
+
input_tokens: Number(usage.input_tokens ?? 0),
|
|
621
|
+
output_tokens: Number(usage.output_tokens ?? 0),
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The Responses stream as Anthropic SSE. Simpler than the Chat-Completions
|
|
627
|
+
* machine because the other side already speaks in items: an output item
|
|
628
|
+
* is added, its text or arguments arrive as deltas, it is done. Each item
|
|
629
|
+
* becomes one Anthropic block; `response.completed` carries the usage and
|
|
630
|
+
* the reason.
|
|
631
|
+
*/
|
|
632
|
+
export class ResponsesStreamTranslator {
|
|
633
|
+
started = false;
|
|
634
|
+
nextIndex = 0;
|
|
635
|
+
/** Responses output_index → Anthropic block index and kind. */
|
|
636
|
+
blocks = new Map();
|
|
637
|
+
usage = null;
|
|
638
|
+
incomplete = null;
|
|
639
|
+
sawTool = false;
|
|
640
|
+
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
641
|
+
model;
|
|
642
|
+
constructor(model) {
|
|
643
|
+
this.model = model;
|
|
644
|
+
}
|
|
645
|
+
event(name, data) {
|
|
646
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
647
|
+
}
|
|
648
|
+
start(response) {
|
|
649
|
+
if (this.started)
|
|
650
|
+
return [];
|
|
651
|
+
this.started = true;
|
|
652
|
+
if (response && typeof response.id === "string")
|
|
653
|
+
this.id = response.id;
|
|
654
|
+
return [
|
|
655
|
+
this.event("message_start", {
|
|
656
|
+
type: "message_start",
|
|
657
|
+
message: {
|
|
658
|
+
id: this.id,
|
|
659
|
+
type: "message",
|
|
660
|
+
role: "assistant",
|
|
661
|
+
model: String(response?.model ?? this.model),
|
|
662
|
+
content: [],
|
|
663
|
+
stop_reason: null,
|
|
664
|
+
stop_sequence: null,
|
|
665
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
666
|
+
},
|
|
667
|
+
}),
|
|
668
|
+
];
|
|
669
|
+
}
|
|
670
|
+
/** One parsed event in, zero or more SSE events out. */
|
|
671
|
+
feed(ev) {
|
|
672
|
+
const type = String(ev.type ?? "");
|
|
673
|
+
const out = this.start(ev.response);
|
|
674
|
+
if (type === "response.output_item.added") {
|
|
675
|
+
const item = ev.item ?? {};
|
|
676
|
+
const idx = Number(ev.output_index ?? this.blocks.size);
|
|
677
|
+
if (item.type === "message") {
|
|
678
|
+
const block = this.nextIndex++;
|
|
679
|
+
this.blocks.set(idx, { block, kind: "text", argsSeen: false });
|
|
680
|
+
out.push(this.event("content_block_start", { type: "content_block_start", index: block, content_block: { type: "text", text: "" } }));
|
|
681
|
+
}
|
|
682
|
+
else if (item.type === "function_call") {
|
|
683
|
+
const block = this.nextIndex++;
|
|
684
|
+
this.blocks.set(idx, { block, kind: "tool", argsSeen: false });
|
|
685
|
+
this.sawTool = true;
|
|
686
|
+
out.push(this.event("content_block_start", {
|
|
687
|
+
type: "content_block_start",
|
|
688
|
+
index: block,
|
|
689
|
+
content_block: {
|
|
690
|
+
type: "tool_use",
|
|
691
|
+
id: String(item.call_id ?? item.id ?? `call_${crypto.randomBytes(6).toString("hex")}`),
|
|
692
|
+
name: String(item.name ?? ""),
|
|
693
|
+
input: {},
|
|
694
|
+
},
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
700
|
+
const entry = this.blocks.get(Number(ev.output_index));
|
|
701
|
+
const text = String(ev.delta ?? "");
|
|
702
|
+
if (entry && entry.kind === "text" && text) {
|
|
703
|
+
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
|
|
704
|
+
}
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
if (type === "response.function_call_arguments.delta") {
|
|
708
|
+
const entry = this.blocks.get(Number(ev.output_index));
|
|
709
|
+
const args = String(ev.delta ?? "");
|
|
710
|
+
if (entry && entry.kind === "tool" && args) {
|
|
711
|
+
entry.argsSeen = true;
|
|
712
|
+
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "input_json_delta", partial_json: args } }));
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
if (type === "response.output_item.done") {
|
|
717
|
+
const entry = this.blocks.get(Number(ev.output_index));
|
|
718
|
+
if (entry) {
|
|
719
|
+
if (entry.kind === "tool" && !entry.argsSeen) {
|
|
720
|
+
// The arguments may have come whole on the done item rather than
|
|
721
|
+
// as deltas; Anthropic wants them as json deltas inside the block.
|
|
722
|
+
const item = ev.item ?? {};
|
|
723
|
+
const args = typeof item.arguments === "string" && item.arguments.trim() ? item.arguments : "{}";
|
|
724
|
+
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "input_json_delta", partial_json: args } }));
|
|
725
|
+
entry.argsSeen = true;
|
|
726
|
+
}
|
|
727
|
+
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
728
|
+
this.blocks.delete(Number(ev.output_index));
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
|
|
733
|
+
const r = ev.response ?? {};
|
|
734
|
+
const u = r.usage;
|
|
735
|
+
if (u)
|
|
736
|
+
this.usage = { input: Number(u.input_tokens ?? 0), output: Number(u.output_tokens ?? 0) };
|
|
737
|
+
const reason = r.incomplete_details?.reason;
|
|
738
|
+
if (typeof reason === "string")
|
|
739
|
+
this.incomplete = reason;
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
}
|
|
744
|
+
/** The stream ended: close what is still open, say why, and stop. */
|
|
745
|
+
finishStream() {
|
|
746
|
+
const out = this.start();
|
|
747
|
+
for (const entry of this.blocks.values()) {
|
|
748
|
+
if (entry.kind === "tool") {
|
|
749
|
+
if (!entry.argsSeen)
|
|
750
|
+
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "input_json_delta", partial_json: "{}" } }));
|
|
751
|
+
}
|
|
752
|
+
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
753
|
+
}
|
|
754
|
+
const reason = this.sawTool && this.incomplete !== "max_output_tokens" ? "tool_use" : this.incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
755
|
+
out.push(this.event("message_delta", {
|
|
756
|
+
type: "message_delta",
|
|
757
|
+
delta: { stop_reason: reason, stop_sequence: null },
|
|
758
|
+
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
759
|
+
}));
|
|
760
|
+
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
761
|
+
return out;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// ---------------------------------------------------------------- errors
|
|
765
|
+
function errorType(status) {
|
|
766
|
+
if (status === 400)
|
|
767
|
+
return "invalid_request_error";
|
|
768
|
+
if (status === 401)
|
|
769
|
+
return "authentication_error";
|
|
770
|
+
if (status === 403)
|
|
771
|
+
return "permission_error";
|
|
772
|
+
if (status === 404)
|
|
773
|
+
return "not_found_error";
|
|
774
|
+
if (status === 429)
|
|
775
|
+
return "rate_limit_error";
|
|
776
|
+
if (status === 529)
|
|
777
|
+
return "overloaded_error";
|
|
778
|
+
return "api_error";
|
|
779
|
+
}
|
|
780
|
+
function errorBody(status, message) {
|
|
781
|
+
return JSON.stringify({ type: "error", error: { type: errorType(status), message } });
|
|
782
|
+
}
|
|
783
|
+
/** The provider's own words for what went wrong, whatever shape it used. */
|
|
784
|
+
function upstreamMessage(text, status) {
|
|
785
|
+
try {
|
|
786
|
+
const parsed = JSON.parse(text);
|
|
787
|
+
const err = parsed.error;
|
|
788
|
+
if (typeof err === "string")
|
|
789
|
+
return err;
|
|
790
|
+
if (err && typeof err.message === "string")
|
|
791
|
+
return err.message;
|
|
792
|
+
if (typeof parsed.message === "string")
|
|
793
|
+
return parsed.message;
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// not JSON — the text is the message
|
|
797
|
+
}
|
|
798
|
+
return text.slice(0, 500) || `upstream returned HTTP ${status}`;
|
|
799
|
+
}
|
|
800
|
+
// ---------------------------------------------------------------- server
|
|
801
|
+
/**
|
|
802
|
+
* Start the translator for one step. Binds an ephemeral port on loopback,
|
|
803
|
+
* mints the key the SDK will present, and returns the env that points the
|
|
804
|
+
* SDK at it. Close it when the step is over.
|
|
805
|
+
*/
|
|
806
|
+
export async function startTranslator(spec) {
|
|
807
|
+
const key = `fr-${crypto.randomBytes(16).toString("hex")}`;
|
|
808
|
+
const log = [];
|
|
809
|
+
const base = spec.upstreamBase.replace(/\/+$/, "");
|
|
810
|
+
const label = spec.label ?? base;
|
|
811
|
+
const server = http.createServer(async (req, res) => {
|
|
812
|
+
const started = Date.now();
|
|
813
|
+
const send = (status, body, type = "application/json") => {
|
|
814
|
+
res.writeHead(status, { "content-type": type });
|
|
815
|
+
res.end(body);
|
|
816
|
+
};
|
|
817
|
+
try {
|
|
818
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
819
|
+
const presented = req.headers["x-api-key"] ?? (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
|
|
820
|
+
const ok = typeof presented === "string" && presented.length === key.length && crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(key));
|
|
821
|
+
if (!ok)
|
|
822
|
+
return send(401, errorBody(401, "translator: wrong key for this step"));
|
|
823
|
+
const raw = await new Promise((resolve, reject) => {
|
|
824
|
+
let body = "";
|
|
825
|
+
req.on("data", (c) => (body += c.toString()));
|
|
826
|
+
req.on("end", () => resolve(body));
|
|
827
|
+
req.on("error", reject);
|
|
828
|
+
});
|
|
829
|
+
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
830
|
+
// No endpoint on the other side counts for us; four characters a
|
|
831
|
+
// token is the estimate every tokenizer lands near for English.
|
|
832
|
+
const chars = raw.length;
|
|
833
|
+
return send(200, JSON.stringify({ input_tokens: Math.ceil(chars / 4) }));
|
|
834
|
+
}
|
|
835
|
+
if (req.method !== "POST" || url.pathname !== "/v1/messages") {
|
|
836
|
+
return send(404, errorBody(404, `translator: no ${req.method} ${url.pathname} — only POST /v1/messages`));
|
|
837
|
+
}
|
|
838
|
+
let body;
|
|
839
|
+
try {
|
|
840
|
+
body = JSON.parse(raw);
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return send(400, errorBody(400, "translator: request body is not JSON"));
|
|
844
|
+
}
|
|
845
|
+
const stream = body.stream === true;
|
|
846
|
+
const drop = dropped();
|
|
847
|
+
const responses = spec.wire === "responses";
|
|
848
|
+
const outbound = responses
|
|
849
|
+
? toResponses(body, { reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop)
|
|
850
|
+
: toChatCompletions(body, { maxTokensParam: spec.maxTokensParam, reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop);
|
|
851
|
+
for (const line of drop.lines())
|
|
852
|
+
if (!log.includes(line))
|
|
853
|
+
log.push(line);
|
|
854
|
+
let upstream;
|
|
855
|
+
try {
|
|
856
|
+
upstream = await fetch(viaEgress(spec.egress, `${base}${responses ? "/responses" : "/chat/completions"}`), {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: {
|
|
859
|
+
"content-type": "application/json",
|
|
860
|
+
authorization: `Bearer ${spec.upstreamKey}`,
|
|
861
|
+
...(spec.headers ?? {}),
|
|
862
|
+
},
|
|
863
|
+
body: JSON.stringify(outbound),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
868
|
+
log.push(`POST ${label} → unreachable: ${message}`);
|
|
869
|
+
return send(502, errorBody(502, `translator: could not reach ${label}: ${message}`));
|
|
870
|
+
}
|
|
871
|
+
if (!upstream.ok) {
|
|
872
|
+
const text = await upstream.text();
|
|
873
|
+
const message = upstreamMessage(text, upstream.status);
|
|
874
|
+
log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
|
|
875
|
+
// The status crosses unchanged, so a 401 or 429 from the provider
|
|
876
|
+
// reads as one to the SDK — and to the fallback decision upstream.
|
|
877
|
+
return send(upstream.status, errorBody(upstream.status, message));
|
|
878
|
+
}
|
|
879
|
+
const model = String(body.model ?? "");
|
|
880
|
+
if (!stream) {
|
|
881
|
+
const text = await upstream.text();
|
|
882
|
+
let parsed;
|
|
883
|
+
try {
|
|
884
|
+
parsed = JSON.parse(text);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
|
|
888
|
+
}
|
|
889
|
+
log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
|
|
890
|
+
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
|
|
891
|
+
}
|
|
892
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
893
|
+
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
|
|
894
|
+
const reader = upstream.body?.getReader();
|
|
895
|
+
if (!reader) {
|
|
896
|
+
for (const e of machine.finishStream())
|
|
897
|
+
res.write(e);
|
|
898
|
+
return res.end();
|
|
899
|
+
}
|
|
900
|
+
const decoder = new TextDecoder();
|
|
901
|
+
let buffer = "";
|
|
902
|
+
let chunks = 0;
|
|
903
|
+
for (;;) {
|
|
904
|
+
const { value, done } = await reader.read();
|
|
905
|
+
if (done)
|
|
906
|
+
break;
|
|
907
|
+
buffer += decoder.decode(value, { stream: true });
|
|
908
|
+
let nl;
|
|
909
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
910
|
+
const line = buffer.slice(0, nl).trim();
|
|
911
|
+
buffer = buffer.slice(nl + 1);
|
|
912
|
+
if (!line.startsWith("data:"))
|
|
913
|
+
continue;
|
|
914
|
+
const data = line.slice(5).trim();
|
|
915
|
+
if (data === "[DONE]")
|
|
916
|
+
continue;
|
|
917
|
+
try {
|
|
918
|
+
chunks++;
|
|
919
|
+
for (const e of machine.feed(JSON.parse(data)))
|
|
920
|
+
res.write(e);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
// a malformed chunk is skipped, not fatal — the stream goes on
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
for (const e of machine.finishStream())
|
|
928
|
+
res.write(e);
|
|
929
|
+
res.end();
|
|
930
|
+
log.push(`POST ${label} → 200 (stream, ${chunks} chunks, ${Date.now() - started}ms, ${model})`);
|
|
931
|
+
}
|
|
932
|
+
catch (err) {
|
|
933
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
934
|
+
log.push(`translator error: ${message}`);
|
|
935
|
+
if (!res.headersSent)
|
|
936
|
+
send(500, errorBody(500, `translator: ${message}`));
|
|
937
|
+
else
|
|
938
|
+
res.end();
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
await new Promise((resolve, reject) => {
|
|
942
|
+
server.once("error", reject);
|
|
943
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
944
|
+
});
|
|
945
|
+
const address = server.address();
|
|
946
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
947
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
948
|
+
return {
|
|
949
|
+
port,
|
|
950
|
+
key,
|
|
951
|
+
baseUrl,
|
|
952
|
+
env: {
|
|
953
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
954
|
+
// The SDK sends ANTHROPIC_API_KEY as x-api-key, which is what the
|
|
955
|
+
// server above checks; the bearer slot is blanked so the SDK cannot
|
|
956
|
+
// fall back to Anthropic's own endpoint with a stale credential.
|
|
957
|
+
ANTHROPIC_API_KEY: key,
|
|
958
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
959
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
960
|
+
},
|
|
961
|
+
drainLog: () => log.splice(0, log.length),
|
|
962
|
+
close: () => new Promise((resolve) => {
|
|
963
|
+
server.closeAllConnections?.();
|
|
964
|
+
server.close(() => resolve());
|
|
965
|
+
}),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* What a provider block needs from the translator, or null when the block
|
|
970
|
+
* speaks Anthropic and needs none. Values only — this crosses into the run
|
|
971
|
+
* container as JSON.
|
|
972
|
+
*/
|
|
973
|
+
export function translatorSpecFor(spec) {
|
|
974
|
+
if (spec.format !== "openai" && spec.format !== "responses")
|
|
975
|
+
return null;
|
|
976
|
+
return {
|
|
977
|
+
wire: spec.format === "responses" ? "responses" : "chat",
|
|
978
|
+
upstreamBase: spec.baseUrl,
|
|
979
|
+
upstreamKey: spec.token,
|
|
980
|
+
headers: spec.headers,
|
|
981
|
+
params: spec.params,
|
|
982
|
+
maxTokensParam: spec.maxTokensParam,
|
|
983
|
+
reasoningEffort: spec.reasoningEffort,
|
|
984
|
+
label: spec.name ? `${spec.name} (${spec.baseUrl})` : spec.baseUrl,
|
|
985
|
+
};
|
|
986
|
+
}
|