@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,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
+ }