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