@amenophis1er/foreman 0.1.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 (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,1411 @@
1
+ /**
2
+ * LLM gateway — lets the Claude Agent SDK (which speaks the Anthropic Messages
3
+ * API) talk to an OpenAI-compatible Chat Completions endpoint.
4
+ *
5
+ * The SDK only knows how to POST /v1/messages in Anthropic wire format. OpenAI
6
+ * (and any OpenAI-compatible server) speaks /v1/chat/completions in a different
7
+ * shape. This gateway runs inside the agent container on 127.0.0.1 and
8
+ * translates request + response (including SSE streaming and tool calls) in
9
+ * both directions.
10
+ *
11
+ * Modes (LLM_GATEWAY_MODE):
12
+ * - "openai" translate Anthropic Messages <-> OpenAI Chat Completions
13
+ * - "codex" translate Anthropic Messages <-> OpenAI RESPONSES API,
14
+ * against the ChatGPT Codex backend (subscription OAuth)
15
+ * - "passthrough" forward verbatim, only rewriting x-api-key -> Bearer
16
+ * (the legacy ollama-proxy behavior; kept for completeness)
17
+ *
18
+ * Env:
19
+ * LLM_GATEWAY_MODE "openai" | "codex" | "passthrough" (default "openai")
20
+ * LLM_GATEWAY_TARGET_URL upstream base URL (default https://api.openai.com;
21
+ * codex default https://chatgpt.com/backend-api)
22
+ * LLM_GATEWAY_PORT local listen port (default 11434)
23
+ * CODEX_ACCOUNT_ID ChatGPT account id header (codex mode)
24
+ * CODEX_ORIGINATOR originator header (codex mode, default "vonzio")
25
+ *
26
+ * The pure translation functions are exported for unit testing; the HTTP server
27
+ * only starts when this file is run directly. Delete this file to remove
28
+ * OpenAI-compatible support.
29
+ */
30
+ const http = require("http");
31
+ const https = require("https");
32
+ const net = require("net");
33
+ const tls = require("tls");
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Request translation: Anthropic Messages -> OpenAI Chat Completions
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Flatten an Anthropic `system` field (string | block[]) into one string. */
40
+ function systemToText(system) {
41
+ if (!system) return "";
42
+ if (typeof system === "string") return system;
43
+ if (Array.isArray(system)) {
44
+ return system
45
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
46
+ .map((b) => b.text)
47
+ .join("\n\n");
48
+ }
49
+ return "";
50
+ }
51
+
52
+ /** Anthropic content (string | block[]) -> OpenAI message(s). */
53
+ function translateMessage(msg, out) {
54
+ const role = msg.role;
55
+ const content = msg.content;
56
+
57
+ if (typeof content === "string") {
58
+ out.push({ role, content });
59
+ return;
60
+ }
61
+ if (!Array.isArray(content)) return;
62
+
63
+ // tool_result blocks (user turn) become standalone OpenAI {role:"tool"} msgs.
64
+ const toolResults = content.filter((b) => b && b.type === "tool_result");
65
+ const toolUses = content.filter((b) => b && b.type === "tool_use");
66
+ const textParts = content.filter((b) => b && b.type === "text");
67
+ const imageParts = content.filter((b) => b && b.type === "image");
68
+
69
+ if (role === "assistant") {
70
+ const assistant = { role: "assistant" };
71
+ const text = textParts.map((b) => b.text).join("");
72
+ if (text) assistant.content = text;
73
+ if (toolUses.length > 0) {
74
+ assistant.tool_calls = toolUses.map((b) => ({
75
+ id: b.id,
76
+ type: "function",
77
+ function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
78
+ }));
79
+ if (!assistant.content) assistant.content = null;
80
+ }
81
+ out.push(assistant);
82
+ return;
83
+ }
84
+
85
+ // user turn: emit any tool_results first (OpenAI requires them right after the
86
+ // assistant tool_calls turn), then the user text/images.
87
+ for (const tr of toolResults) {
88
+ out.push({
89
+ role: "tool",
90
+ tool_call_id: tr.tool_use_id,
91
+ content: toolResultText(tr.content),
92
+ });
93
+ }
94
+
95
+ if (imageParts.length > 0) {
96
+ const parts = [];
97
+ for (const t of textParts) parts.push({ type: "text", text: t.text });
98
+ for (const img of imageParts) {
99
+ const src = img.source || {};
100
+ const url =
101
+ src.type === "url"
102
+ ? src.url
103
+ : `data:${src.media_type};base64,${src.data}`;
104
+ parts.push({ type: "image_url", image_url: { url } });
105
+ }
106
+ out.push({ role: "user", content: parts });
107
+ } else if (textParts.length > 0) {
108
+ out.push({ role: "user", content: textParts.map((b) => b.text).join("") });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * FOREMAN DIVERGENCE from the upstream copy of this file — see
114
+ * docs/provider-model-tracker.md, Divergences. Re-apply after any re-sync.
115
+ *
116
+ * Folds every system message into a single leading one.
117
+ *
118
+ * OpenAI itself accepts a system message anywhere in the array, so upstream
119
+ * never had to care. Chat templates are stricter: Qwen3's rejects the whole
120
+ * request with `system message must be at the beginning` if one appears at any
121
+ * later index — which is what a real Claude Code director hits partway through
122
+ * a mission, since the harness can put a system-role turn into `messages`.
123
+ *
124
+ * Merging rather than dropping keeps the instruction; putting it first is what
125
+ * every template expects anyway. Mutates `messages` in place.
126
+ */
127
+ function hoistSystemMessages(messages) {
128
+ const later = [];
129
+ for (let i = messages.length - 1; i > 0; i--) {
130
+ if (messages[i] && messages[i].role === "system") {
131
+ later.unshift(String(messages[i].content ?? ""));
132
+ messages.splice(i, 1);
133
+ }
134
+ }
135
+ if (later.length === 0) return messages;
136
+
137
+ const text = later.filter(Boolean).join("\n\n");
138
+ if (messages[0] && messages[0].role === "system") {
139
+ messages[0].content = [messages[0].content, text].filter(Boolean).join("\n\n");
140
+ } else {
141
+ messages.unshift({ role: "system", content: text });
142
+ }
143
+ return messages;
144
+ }
145
+
146
+ /** Anthropic tool_result `content` (string | block[]) -> plain string. */
147
+ function toolResultText(content) {
148
+ if (typeof content === "string") return content;
149
+ if (Array.isArray(content)) {
150
+ return content
151
+ .filter((b) => b && b.type === "text")
152
+ .map((b) => b.text)
153
+ .join("\n");
154
+ }
155
+ return "";
156
+ }
157
+
158
+ /** Anthropic tool_choice -> OpenAI tool_choice. */
159
+ function translateToolChoice(tc) {
160
+ if (!tc) return undefined;
161
+ if (tc.type === "auto") return "auto";
162
+ if (tc.type === "any") return "required";
163
+ if (tc.type === "tool" && tc.name) {
164
+ return { type: "function", function: { name: tc.name } };
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ /** Full Anthropic Messages request body -> OpenAI Chat Completions body. */
170
+ function anthropicToOpenAIRequest(body) {
171
+ const messages = [];
172
+ const system = systemToText(body.system);
173
+ if (system) messages.push({ role: "system", content: system });
174
+ for (const m of body.messages || []) translateMessage(m, messages);
175
+ hoistSystemMessages(messages);
176
+
177
+ const oa = {
178
+ model: body.model,
179
+ messages,
180
+ stream: !!body.stream,
181
+ };
182
+ // Route the token cap to the field the target model accepts. OpenAI's
183
+ // GPT-5 and o-series reasoning models reject the legacy `max_tokens` and
184
+ // require `max_completion_tokens`; gpt-4* and most OpenAI-compatible
185
+ // servers (vLLM, LM Studio, OpenRouter) only understand `max_tokens`.
186
+ // The model-family regex is only a FIRST-GUESS hint — what the provider
187
+ // actually accepts is learned from its 400s (applyCapabilityFix) and
188
+ // cached, so a mis-guess costs one request, once, per daemon lifetime.
189
+ const caps = capsFor(body.model);
190
+ if (body.max_tokens != null) {
191
+ const tokenParam = caps.tokenParam
192
+ || (/^(o\d|gpt-5)/i.test(body.model || "") ? "max_completion_tokens" : "max_tokens");
193
+ oa[tokenParam] = body.max_tokens;
194
+ }
195
+ if (oa.stream) oa.stream_options = { include_usage: true };
196
+
197
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
198
+ oa.tools = body.tools
199
+ // skip Anthropic server-tool shapes that have no OpenAI analogue
200
+ .filter((t) => t && t.name && (t.input_schema || t.parameters))
201
+ .map((t) => ({
202
+ type: "function",
203
+ function: {
204
+ name: t.name,
205
+ description: t.description || "",
206
+ parameters: t.input_schema || t.parameters || { type: "object", properties: {} },
207
+ },
208
+ }));
209
+ const choice = translateToolChoice(body.tool_choice);
210
+ if (choice) oa.tool_choice = choice;
211
+
212
+ // gpt-5.x reasoning models on /v1/chat/completions DEFAULT reasoning_effort
213
+ // to a non-none level and then reject it in combination with function
214
+ // tools ("Function tools with reasoning_effort are not supported … use
215
+ // /v1/responses or set reasoning_effort to 'none'"). The SDK always sends
216
+ // tools, so without this every agent turn 400s. Working tools beat hidden
217
+ // reasoning until this mode speaks the Responses API (issue #349). The
218
+ // regex is a first-guess hint; learned capabilities override it in both
219
+ // directions (a server that rejects the param entirely gets it removed).
220
+ if (!caps.noReasoningEffortParam
221
+ && (caps.reasoningEffortNone || /^gpt-5/i.test(body.model || ""))) {
222
+ oa.reasoning_effort = "none";
223
+ }
224
+ }
225
+
226
+ return oa;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Learned per-model capabilities
231
+ //
232
+ // Model-name regexes only PREDICT provider behavior; the provider's own 400s
233
+ // are the authoritative capability signal (a rename like gpt-6, or the same
234
+ // model behind OpenRouter's "openai/…" alias, silently defeats any regex).
235
+ // So: send the natural request first, and when the provider rejects it with a
236
+ // recognizable capability error, learn the fact, adjust, and retry — cached
237
+ // per model for the daemon's lifetime so each capability costs at most one
238
+ // failed request. Same philosophy as the adaptive tool trim below.
239
+ // ---------------------------------------------------------------------------
240
+
241
+ const modelCaps = new Map(); // model id -> { tokenParam?, reasoningEffortNone?, noReasoningEffortParam? }
242
+ function capsFor(model) {
243
+ let c = modelCaps.get(model || "");
244
+ if (!c) {
245
+ // Defensive bound: model ids come from the client, so pathological unique
246
+ // ids could grow this forever. Losing learned facts just re-learns them.
247
+ if (modelCaps.size >= 200) modelCaps.clear();
248
+ c = {}; modelCaps.set(model || "", c);
249
+ }
250
+ return c;
251
+ }
252
+
253
+ /** Inspect a 4xx error body for a KNOWN capability complaint; if found, learn
254
+ * it (cache) and mutate `oa` accordingly. Returns a log line, or null when
255
+ * the error isn't a capability we understand. Every branch changes `oa` in a
256
+ * way that makes the same complaint impossible on the retry, so the caller's
257
+ * retry loop can't spin. */
258
+ function applyCapabilityFix(oa, errText) {
259
+ const caps = capsFor(oa.model);
260
+
261
+ // NB: every matcher requires rejection keywords or remedy phrasing IN
262
+ // PROXIMITY to the parameter name — a plain substring test would let any
263
+ // 400 whose body merely echoes these words (validation details, content
264
+ // filters, proxies quoting the request) poison the cache permanently.
265
+
266
+ // Tools + reasoning_effort unsupported on chat/completions (gpt-5 family):
267
+ // "Function tools with reasoning_effort are not supported … set
268
+ // reasoning_effort to 'none'".
269
+ if (/(tools[\s\S]{0,60}reasoning_effort|reasoning_effort[\s\S]{0,60}tools|set reasoning_effort to '?none'?)/i.test(errText)
270
+ && oa.reasoning_effort !== "none" && Array.isArray(oa.tools) && oa.tools.length > 0) {
271
+ caps.reasoningEffortNone = true;
272
+ oa.reasoning_effort = "none";
273
+ return `model '${oa.model}' rejects tools+reasoning_effort — pinned reasoning_effort=none`;
274
+ }
275
+
276
+ // The opposite failure: a server that doesn't know the param at all (or an
277
+ // o-series model that rejects the value "none" our hint pinned). Remove it.
278
+ if (/(unsupported|not supported|unrecognized|unknown|unexpected|invalid|extra)[^.]{0,40}reasoning_effort|reasoning_effort[^.]{0,40}(unsupported|not supported|unrecognized|unknown|unexpected|invalid)/i.test(errText)
279
+ && oa.reasoning_effort !== undefined) {
280
+ caps.noReasoningEffortParam = true;
281
+ caps.reasoningEffortNone = false;
282
+ delete oa.reasoning_effort;
283
+ return `model '${oa.model}' rejects the reasoning_effort param — removed`;
284
+ }
285
+
286
+ // Reasoning models: "Unsupported parameter: 'max_tokens' … use
287
+ // 'max_completion_tokens' instead."
288
+ if (/(max_tokens[\s\S]{0,80}max_completion_tokens|use ['"]?max_completion_tokens)/i.test(errText)
289
+ && oa.max_tokens != null) {
290
+ caps.tokenParam = "max_completion_tokens";
291
+ oa.max_completion_tokens = oa.max_tokens;
292
+ delete oa.max_tokens;
293
+ return `model '${oa.model}' wants max_completion_tokens`;
294
+ }
295
+
296
+ // The reverse: an OpenAI-compatible server (vLLM/LM Studio) that only knows
297
+ // the legacy field, serving a model whose NAME matched the reasoning hint.
298
+ if (/(unsupported|not supported|unrecognized|unknown|unexpected|invalid|extra)[^.]{0,40}max_completion_tokens|max_completion_tokens[^.]{0,40}(unsupported|not supported|unrecognized|unknown|unexpected|invalid)/i.test(errText)
299
+ && oa.max_completion_tokens != null) {
300
+ caps.tokenParam = "max_tokens";
301
+ oa.max_tokens = oa.max_completion_tokens;
302
+ delete oa.max_completion_tokens;
303
+ return `model '${oa.model}' wants legacy max_tokens`;
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Adaptive tool trimming
311
+ //
312
+ // The Claude Agent SDK advertises its full tool catalog (built-ins + MCP) on
313
+ // every request — easily ~20k tokens of OpenAI function schema. Small-context
314
+ // OpenAI-compatible models (e.g. an 8k model) reject that before any chat.
315
+ // Rather than uniformly stripping tools (which would needlessly cripple large
316
+ // models like Grok/GPT-5), we send everything first and only trim when a model
317
+ // rejects the request for exceeding its context window — keeping the most
318
+ // useful tools and dropping the long tail to fit, then retrying once.
319
+ // ---------------------------------------------------------------------------
320
+
321
+ // Rough token estimate (≈ 4 chars/token) — good enough for budgeting.
322
+ function estimateTokens(str) {
323
+ return Math.ceil((str || "").length / 4);
324
+ }
325
+
326
+ // Parse a provider "context length exceeded" error and return the model's
327
+ // token limit, or null if this isn't a context-window error. Handles OpenAI's
328
+ // "maximum context length is 8192 tokens" plus the generic
329
+ // context_length_exceeded code; falls back to a conservative 8192 when the
330
+ // error is clearly about context but no number is given.
331
+ function parseContextLimit(errText) {
332
+ const text = String(errText || "");
333
+ const m = text.match(/maximum context length is\s+(\d+)/i) || text.match(/context length of\s+(\d+)/i);
334
+ if (m) return parseInt(m[1], 10);
335
+ if (/context_length_exceeded|maximum context|reduce the length of the (?:messages|prompt)/i.test(text)) {
336
+ return 8192;
337
+ }
338
+ return null;
339
+ }
340
+
341
+ // Core built-in tools — the agent's "hands". Kept first when trimming and
342
+ // ordered by load-bearingness. NOT an exhaustive built-in list: anything not
343
+ // here and not an MCP tool is the droppable long tail (TodoWrite, Task, Web*,
344
+ // NotebookEdit, …). MCP tools (mcp__*) are explicitly configured by the
345
+ // operator, so they rank ABOVE that long tail (see `rank`).
346
+ const TOOL_PRIORITY = ["Bash", "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "LS"];
347
+
348
+ // Cap each tool description to this many chars before dropping whole tools —
349
+ // most of the ~20k is verbose prose, and a capped description keeps the
350
+ // capability while shrinking the schema.
351
+ const MAX_TOOL_DESC = 600;
352
+
353
+ // Make an OpenAI request fit the model's context window, least-destructive
354
+ // first: (1) cap verbose tool descriptions, then (2) drop lowest-priority tools
355
+ // if still over. Mutates oa. Returns { dropped, changed } — `changed` is true
356
+ // whenever anything was modified (so the caller knows a retry is worthwhile,
357
+ // even when no whole tool was dropped).
358
+ function trimOpenAIToolsToFit(oa, contextLimit) {
359
+ if (!Array.isArray(oa.tools) || oa.tools.length === 0) return { dropped: 0, changed: false };
360
+ const msgTokens = estimateTokens(JSON.stringify(oa.messages || []));
361
+ // Reserve headroom for the model's reply + estimation slop.
362
+ const RESERVE = 1024;
363
+ const budget = Math.max(0, contextLimit - msgTokens - RESERVE);
364
+
365
+ // Pass 1 — cap descriptions. Often enough on its own, and keeps every tool.
366
+ let capped = false;
367
+ for (const t of oa.tools) {
368
+ const d = t.function && t.function.description;
369
+ if (typeof d === "string" && d.length > MAX_TOOL_DESC) {
370
+ t.function.description = d.slice(0, MAX_TOOL_DESC) + "…";
371
+ capped = true;
372
+ }
373
+ }
374
+ if (estimateTokens(JSON.stringify(oa.tools)) <= budget) {
375
+ return { dropped: 0, changed: capped };
376
+ }
377
+
378
+ // Pass 2 — drop lowest-priority tools. Priority tiers (lower = kept first):
379
+ // 0..N-1 core built-ins (TOOL_PRIORITY) — the agent's hands
380
+ // N MCP / operator-configured tools (mcp__*) — deliberate intent
381
+ // N+1 everything else (generic built-in long tail) — dropped first
382
+ const rank = (t) => {
383
+ const name = (t.function && t.function.name) || "";
384
+ const i = TOOL_PRIORITY.indexOf(name);
385
+ if (i !== -1) return i;
386
+ return name.startsWith("mcp__") ? TOOL_PRIORITY.length : TOOL_PRIORITY.length + 1;
387
+ };
388
+ const annotated = oa.tools.map((t, idx) => ({ t, idx, rank: rank(t), tok: estimateTokens(JSON.stringify(t)) }));
389
+ annotated.sort((a, b) => a.rank - b.rank || a.idx - b.idx);
390
+
391
+ const kept = [];
392
+ let used = 0;
393
+ for (const e of annotated) {
394
+ if (used + e.tok <= budget) {
395
+ kept.push(e);
396
+ used += e.tok;
397
+ }
398
+ }
399
+ const dropped = oa.tools.length - kept.length;
400
+ kept.sort((a, b) => a.idx - b.idx); // restore original order among survivors
401
+ if (kept.length === 0) {
402
+ delete oa.tools;
403
+ delete oa.tool_choice;
404
+ } else {
405
+ oa.tools = kept.map((e) => e.t);
406
+ // If tool_choice forces a specific function that we just dropped, the retry
407
+ // would 400 ("tool not found"). Fall back to "auto" in that case.
408
+ const forced = oa.tool_choice && oa.tool_choice.function && oa.tool_choice.function.name;
409
+ if (forced && !oa.tools.some((t) => t.function && t.function.name === forced)) {
410
+ oa.tool_choice = "auto";
411
+ }
412
+ }
413
+ return { dropped, changed: capped || dropped > 0 };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Response translation: OpenAI -> Anthropic (non-streaming)
418
+ // ---------------------------------------------------------------------------
419
+
420
+ function mapFinishReason(reason) {
421
+ switch (reason) {
422
+ case "length":
423
+ return "max_tokens";
424
+ case "tool_calls":
425
+ case "function_call":
426
+ return "tool_use";
427
+ case "stop":
428
+ default:
429
+ return "end_turn";
430
+ }
431
+ }
432
+
433
+ function openAIToAnthropicResponse(oa, fallbackModel) {
434
+ const choice = (oa.choices && oa.choices[0]) || {};
435
+ const message = choice.message || {};
436
+ const content = [];
437
+
438
+ if (message.content) {
439
+ content.push({ type: "text", text: message.content });
440
+ }
441
+ for (const tc of message.tool_calls || []) {
442
+ let input = {};
443
+ try {
444
+ input = JSON.parse(tc.function?.arguments || "{}");
445
+ } catch {
446
+ input = {};
447
+ }
448
+ content.push({ type: "tool_use", id: tc.id, name: tc.function?.name, input });
449
+ }
450
+ if (content.length === 0) content.push({ type: "text", text: "" });
451
+
452
+ const usage = oa.usage || {};
453
+ return {
454
+ id: oa.id || `msg_${Date.now()}`,
455
+ type: "message",
456
+ role: "assistant",
457
+ model: oa.model || fallbackModel,
458
+ content,
459
+ stop_reason: mapFinishReason(choice.finish_reason),
460
+ stop_sequence: null,
461
+ usage: {
462
+ input_tokens: usage.prompt_tokens ?? 0,
463
+ output_tokens: usage.completion_tokens ?? 0,
464
+ },
465
+ };
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Streaming translation: OpenAI SSE chunks -> Anthropic SSE events
470
+ // ---------------------------------------------------------------------------
471
+
472
+ function sse(event, data) {
473
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
474
+ }
475
+
476
+ /**
477
+ * Stateful translator. Feed it parsed OpenAI stream chunk objects via push();
478
+ * call end() when the OpenAI stream finishes ([DONE]). Both return an array of
479
+ * Anthropic SSE strings ready to write to the client.
480
+ */
481
+ function makeStreamTranslator(model) {
482
+ let started = false;
483
+ let nextIndex = 0;
484
+ let textOpen = false;
485
+ let textIndex = -1;
486
+ const toolByOaIndex = new Map(); // openai tool_call index -> {anthIndex}
487
+ let finishReason = null;
488
+ let usage = null;
489
+ const id = `msg_${Date.now()}`;
490
+
491
+ function start(out) {
492
+ if (started) return;
493
+ started = true;
494
+ out.push(
495
+ sse("message_start", {
496
+ type: "message_start",
497
+ message: {
498
+ id,
499
+ type: "message",
500
+ role: "assistant",
501
+ model,
502
+ content: [],
503
+ stop_reason: null,
504
+ stop_sequence: null,
505
+ usage: { input_tokens: 0, output_tokens: 0 },
506
+ },
507
+ }),
508
+ );
509
+ }
510
+
511
+ function closeOpenBlock(out) {
512
+ if (textOpen) {
513
+ out.push(sse("content_block_stop", { type: "content_block_stop", index: textIndex }));
514
+ textOpen = false;
515
+ textIndex = -1;
516
+ }
517
+ }
518
+
519
+ function push(chunk) {
520
+ const out = [];
521
+ start(out);
522
+ const choice = (chunk.choices && chunk.choices[0]) || {};
523
+ const delta = choice.delta || {};
524
+ if (chunk.usage) usage = chunk.usage;
525
+
526
+ if (typeof delta.content === "string" && delta.content.length > 0) {
527
+ if (!textOpen) {
528
+ textIndex = nextIndex++;
529
+ textOpen = true;
530
+ out.push(
531
+ sse("content_block_start", {
532
+ type: "content_block_start",
533
+ index: textIndex,
534
+ content_block: { type: "text", text: "" },
535
+ }),
536
+ );
537
+ }
538
+ out.push(
539
+ sse("content_block_delta", {
540
+ type: "content_block_delta",
541
+ index: textIndex,
542
+ delta: { type: "text_delta", text: delta.content },
543
+ }),
544
+ );
545
+ }
546
+
547
+ for (const tc of delta.tool_calls || []) {
548
+ const oaIndex = tc.index ?? 0;
549
+ let entry = toolByOaIndex.get(oaIndex);
550
+ if (!entry) {
551
+ closeOpenBlock(out);
552
+ const anthIndex = nextIndex++;
553
+ entry = { anthIndex };
554
+ toolByOaIndex.set(oaIndex, entry);
555
+ out.push(
556
+ sse("content_block_start", {
557
+ type: "content_block_start",
558
+ index: anthIndex,
559
+ content_block: {
560
+ type: "tool_use",
561
+ id: tc.id || `toolu_${Date.now()}_${oaIndex}`,
562
+ name: tc.function?.name || "",
563
+ input: {},
564
+ },
565
+ }),
566
+ );
567
+ }
568
+ const args = tc.function?.arguments;
569
+ if (typeof args === "string" && args.length > 0) {
570
+ out.push(
571
+ sse("content_block_delta", {
572
+ type: "content_block_delta",
573
+ index: entry.anthIndex,
574
+ delta: { type: "input_json_delta", partial_json: args },
575
+ }),
576
+ );
577
+ }
578
+ }
579
+
580
+ if (choice.finish_reason) finishReason = choice.finish_reason;
581
+ return out;
582
+ }
583
+
584
+ function end() {
585
+ const out = [];
586
+ start(out);
587
+ closeOpenBlock(out);
588
+ for (const entry of toolByOaIndex.values()) {
589
+ out.push(sse("content_block_stop", { type: "content_block_stop", index: entry.anthIndex }));
590
+ }
591
+ out.push(
592
+ sse("message_delta", {
593
+ type: "message_delta",
594
+ delta: { stop_reason: mapFinishReason(finishReason), stop_sequence: null },
595
+ usage: {
596
+ input_tokens: usage?.prompt_tokens ?? 0,
597
+ output_tokens: usage?.completion_tokens ?? 0,
598
+ },
599
+ }),
600
+ );
601
+ out.push(sse("message_stop", { type: "message_stop" }));
602
+ return out;
603
+ }
604
+
605
+ return { push, end };
606
+ }
607
+
608
+ // ===========================================================================
609
+ // Codex mode: Anthropic Messages <-> OpenAI RESPONSES API (ChatGPT Codex)
610
+ //
611
+ // A ChatGPT subscription reaches models through the Codex backend
612
+ // (https://chatgpt.com/backend-api/codex/responses), which speaks the OpenAI
613
+ // *Responses* API — a different request shape AND a different SSE event
614
+ // vocabulary than Chat Completions. This is a SECOND translator; the token is a
615
+ // ChatGPT OAuth access token (feature 0047), account id + originator ride in as
616
+ // env-provided headers.
617
+ // ===========================================================================
618
+
619
+ /** Resolve the Codex responses endpoint from a base URL, mirroring the Codex
620
+ * CLI: tolerate a base already ending in /codex or /codex/responses. */
621
+ function codexResponsesUrl(base) {
622
+ const b = (base || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
623
+ if (b.endsWith("/codex/responses")) return b;
624
+ if (b.endsWith("/codex")) return `${b}/responses`;
625
+ return `${b}/codex/responses`;
626
+ }
627
+
628
+ /** Anthropic content (string | block[]) -> Responses API input item(s). Tool
629
+ * calls/results become top-level function_call / function_call_output items
630
+ * (NOT nested in a message), which is how the Responses API represents them. */
631
+ function translateMessageToResponses(msg, out) {
632
+ const role = msg.role;
633
+ const content = msg.content;
634
+ const textType = role === "assistant" ? "output_text" : "input_text";
635
+
636
+ if (typeof content === "string") {
637
+ if (content) out.push({ role, content: [{ type: textType, text: content }] });
638
+ return;
639
+ }
640
+ if (!Array.isArray(content)) return;
641
+
642
+ const parts = [];
643
+ for (const b of content) {
644
+ if (!b) continue;
645
+ if (b.type === "text") {
646
+ parts.push({ type: textType, text: b.text || "" });
647
+ } else if (b.type === "image" && role !== "assistant") {
648
+ const src = b.source || {};
649
+ const url = src.type === "url" ? src.url : `data:${src.media_type};base64,${src.data}`;
650
+ parts.push({ type: "input_image", image_url: url });
651
+ } else if (b.type === "tool_use") {
652
+ // Flush any accumulated message parts before the function_call item so
653
+ // ordering (assistant text, then its call) is preserved.
654
+ if (parts.length) { out.push({ role, content: parts.splice(0) }); }
655
+ out.push({ type: "function_call", call_id: b.id, name: b.name, arguments: JSON.stringify(b.input ?? {}) });
656
+ } else if (b.type === "tool_result") {
657
+ if (parts.length) { out.push({ role, content: parts.splice(0) }); }
658
+ out.push({ type: "function_call_output", call_id: b.tool_use_id, output: toolResultText(b.content) });
659
+ }
660
+ }
661
+ if (parts.length) out.push({ role, content: parts });
662
+ }
663
+
664
+ /** Responses API tool_choice from Anthropic tool_choice. */
665
+ function translateToolChoiceResponses(tc) {
666
+ if (!tc) return undefined;
667
+ if (tc.type === "auto") return "auto";
668
+ if (tc.type === "any") return "required";
669
+ if (tc.type === "tool" && tc.name) return { type: "function", name: tc.name };
670
+ return undefined;
671
+ }
672
+
673
+ /** Full Anthropic Messages body -> Codex Responses API body. */
674
+ function anthropicToCodexRequest(body, opts = {}) {
675
+ const input = [];
676
+ for (const m of body.messages || []) translateMessageToResponses(m, input);
677
+
678
+ const cx = {
679
+ model: body.model,
680
+ store: false,
681
+ stream: !!body.stream,
682
+ instructions: systemToText(body.system) || "You are a helpful assistant.",
683
+ input,
684
+ // NB: we deliberately do NOT request `reasoning.encrypted_content`. With
685
+ // store:false the API returns encrypted reasoning items only so the client
686
+ // can echo them back on the next turn; the Anthropic wire format has no slot
687
+ // to carry them, so we'd drop them anyway — and requesting-then-omitting them
688
+ // makes some Responses deployments reject the tool-continuation turn with a
689
+ // "missing reasoning item" error. Full history is resent each turn instead.
690
+ tool_choice: "auto",
691
+ parallel_tool_calls: true,
692
+ };
693
+ // NB: the Codex backend REJECTS `max_output_tokens` ("Unsupported parameter"),
694
+ // so the SDK's max_tokens is intentionally dropped — the subscription enforces
695
+ // its own limits server-side.
696
+
697
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
698
+ const tools = body.tools
699
+ .filter((t) => t && t.name && (t.input_schema || t.parameters))
700
+ .map((t) => ({
701
+ type: "function",
702
+ name: t.name,
703
+ description: t.description || "",
704
+ parameters: t.input_schema || t.parameters || { type: "object", properties: {} },
705
+ strict: false,
706
+ }));
707
+ if (tools.length) cx.tools = tools;
708
+ const choice = translateToolChoiceResponses(body.tool_choice);
709
+ if (choice) cx.tool_choice = choice;
710
+ }
711
+ if (opts.reasoningEffort) cx.reasoning = { effort: opts.reasoningEffort, summary: "auto" };
712
+ return cx;
713
+ }
714
+
715
+ /**
716
+ * Stateful translator: parsed Responses-API SSE event objects (push) -> Anthropic
717
+ * SSE strings. Blocks are keyed by the Responses `item_id` since text and tool
718
+ * calls interleave across output items. Reasoning items are dropped (their
719
+ * encrypted_content is not user-facing). end() is idempotent.
720
+ */
721
+ function makeCodexStreamTranslator(model) {
722
+ let started = false;
723
+ let ended = false;
724
+ let nextIndex = 0;
725
+ const blockByItem = new Map(); // item_id -> { index, type }
726
+ const toolItems = new Set(); // item_ids that are function_calls (→ tool_use stop_reason)
727
+ let usage = null;
728
+ let stopReason = null;
729
+ const id = `msg_${Date.now()}`;
730
+
731
+ function start(out) {
732
+ if (started) return;
733
+ started = true;
734
+ out.push(sse("message_start", {
735
+ type: "message_start",
736
+ message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } },
737
+ }));
738
+ }
739
+
740
+ function openText(out, itemId) {
741
+ if (blockByItem.has(itemId)) return blockByItem.get(itemId);
742
+ const index = nextIndex++;
743
+ const entry = { index, type: "text" };
744
+ blockByItem.set(itemId, entry);
745
+ out.push(sse("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } }));
746
+ return entry;
747
+ }
748
+
749
+ function openTool(out, item) {
750
+ if (blockByItem.has(item.id)) return blockByItem.get(item.id);
751
+ const index = nextIndex++;
752
+ const entry = { index, type: "tool_use", gotArgs: false };
753
+ blockByItem.set(item.id, entry);
754
+ toolItems.add(item.id);
755
+ out.push(sse("content_block_start", {
756
+ type: "content_block_start",
757
+ index,
758
+ content_block: { type: "tool_use", id: item.call_id || item.id, name: item.name || "", input: {} },
759
+ }));
760
+ return entry;
761
+ }
762
+
763
+ function closeItem(out, itemId) {
764
+ const entry = blockByItem.get(itemId);
765
+ if (!entry || entry.closed) return;
766
+ entry.closed = true;
767
+ out.push(sse("content_block_stop", { type: "content_block_stop", index: entry.index }));
768
+ }
769
+
770
+ function push(evt) {
771
+ const out = [];
772
+ start(out);
773
+ const type = evt && evt.type;
774
+ if (!type) return out;
775
+
776
+ if (type === "response.output_item.added") {
777
+ const item = evt.item || {};
778
+ if (item.type === "function_call") openTool(out, item);
779
+ // reasoning + message items: opened lazily on their first delta.
780
+ return out;
781
+ }
782
+ if (type === "response.output_text.delta") {
783
+ const entry = openText(out, evt.item_id);
784
+ if (typeof evt.delta === "string" && evt.delta.length) {
785
+ out.push(sse("content_block_delta", { type: "content_block_delta", index: entry.index, delta: { type: "text_delta", text: evt.delta } }));
786
+ }
787
+ return out;
788
+ }
789
+ if (type === "response.function_call_arguments.delta") {
790
+ const entry = blockByItem.get(evt.item_id);
791
+ if (entry && typeof evt.delta === "string" && evt.delta.length) {
792
+ entry.gotArgs = true;
793
+ out.push(sse("content_block_delta", { type: "content_block_delta", index: entry.index, delta: { type: "input_json_delta", partial_json: evt.delta } }));
794
+ }
795
+ return out;
796
+ }
797
+ if (type === "response.output_item.done" || type === "response.output_text.done") {
798
+ const item = evt.item || {};
799
+ const itemId = evt.item_id || item.id;
800
+ // Fallback: if a tool call streamed no argument deltas but the completed
801
+ // item carries the full `arguments`, emit them so tool_use.input isn't {}.
802
+ const entry = itemId && blockByItem.get(itemId);
803
+ if (entry && entry.type === "tool_use" && !entry.gotArgs && typeof item.arguments === "string" && item.arguments.length) {
804
+ entry.gotArgs = true;
805
+ out.push(sse("content_block_delta", { type: "content_block_delta", index: entry.index, delta: { type: "input_json_delta", partial_json: item.arguments } }));
806
+ }
807
+ if (itemId) closeItem(out, itemId);
808
+ return out;
809
+ }
810
+ if (type === "response.completed" || type === "response.incomplete") {
811
+ const resp = evt.response || {};
812
+ usage = resp.usage || usage;
813
+ if (resp.status === "incomplete") stopReason = "max_tokens";
814
+ return out;
815
+ }
816
+ if (type === "response.failed" || type === "error") {
817
+ stopReason = "end_turn";
818
+ return out;
819
+ }
820
+ return out;
821
+ }
822
+
823
+ function end() {
824
+ const out = [];
825
+ if (ended) return out;
826
+ ended = true;
827
+ start(out);
828
+ for (const itemId of blockByItem.keys()) closeItem(out, itemId);
829
+ const finalStop = stopReason || (toolItems.size > 0 ? "tool_use" : "end_turn");
830
+ out.push(sse("message_delta", {
831
+ type: "message_delta",
832
+ delta: { stop_reason: finalStop, stop_sequence: null },
833
+ usage: { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0 },
834
+ }));
835
+ out.push(sse("message_stop", { type: "message_stop" }));
836
+ return out;
837
+ }
838
+
839
+ return { push, end };
840
+ }
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // HTTP server
844
+ // ---------------------------------------------------------------------------
845
+
846
+ const TARGET = process.env.LLM_GATEWAY_TARGET_URL || "https://api.openai.com";
847
+ const MODE = process.env.LLM_GATEWAY_MODE || "openai";
848
+ const PORT = parseInt(process.env.LLM_GATEWAY_PORT || "11434", 10);
849
+ // The session's actual model (set by the orchestrator). Safety net: the
850
+ // Claude Agent SDK resolves subagent model aliases ("haiku"/"sonnet"/"opus")
851
+ // to claude-* ids; those alias envs are remapped per turn, but any claude-*
852
+ // id that still reaches this gateway would be forwarded verbatim and rejected
853
+ // by every non-Anthropic upstream (e.g. Codex 400 "claude-haiku… is not
854
+ // supported when using Codex with a ChatGPT account"). Substitute the
855
+ // session model instead of failing the whole subagent.
856
+ // Read the CURRENT session model per request: this daemon outlives the exec
857
+ // that started it, so its process env is frozen — a mid-session model switch
858
+ // only reaches us through the per-turn file the orchestrator writes.
859
+ function currentDefaultModel() {
860
+ try {
861
+ const m = require("fs").readFileSync("/tmp/llm-gateway.model", "utf8").trim();
862
+ if (m) return m;
863
+ } catch { /* no file yet — boot env below */ }
864
+ return process.env.LLM_GATEWAY_DEFAULT_MODEL || "";
865
+ }
866
+ function substituteClaudeModel(body) {
867
+ const fallback = currentDefaultModel();
868
+ if (fallback && fallback !== body?.model && /^claude-/i.test(body?.model || "")) {
869
+ console.log(`[llm-gateway] rewrote unsupported model '${body.model}' -> '${fallback}'`);
870
+ body.model = fallback;
871
+ }
872
+ return body;
873
+ }
874
+
875
+ function readBody(req) {
876
+ return new Promise((resolve, reject) => {
877
+ const chunks = [];
878
+ req.on("data", (c) => chunks.push(c));
879
+ req.on("end", () => resolve(Buffer.concat(chunks)));
880
+ req.on("error", reject);
881
+ });
882
+ }
883
+
884
+ // When egress enforcement is on, agents sit on a no-direct-internet network and
885
+ // reach the model only through the egress proxy. Node's http/https do NOT honor
886
+ // HTTP(S)_PROXY env, so the gateway — the single component that talks to the
887
+ // real provider — must route through the proxy itself: CONNECT-tunnel for https,
888
+ // absolute-form for http. The proxy token rides in the proxy URL's userinfo and
889
+ // is replayed as Proxy-Authorization. NO_PROXY isn't consulted: the gateway only
890
+ // ever dials external provider hosts (the SDK→gateway hop is localhost).
891
+ function proxyForUrl(u) {
892
+ const env = u.protocol === "https:"
893
+ ? (process.env.HTTPS_PROXY || process.env.https_proxy)
894
+ : (process.env.HTTP_PROXY || process.env.http_proxy);
895
+ return env ? new URL(env) : null;
896
+ }
897
+
898
+ function proxyAuthHeader(p) {
899
+ if (!p.username) return null;
900
+ // username = signed token, password empty (Basic user:pass form).
901
+ return "Basic " + Buffer.from(`${decodeURIComponent(p.username)}:`).toString("base64");
902
+ }
903
+
904
+ /**
905
+ * Build an outbound ClientRequest to `fullUrl`, transparently routed through the
906
+ * egress proxy when one is configured. Returns the ClientRequest so the caller
907
+ * can either write a buffered body or pipe a stream. Mirrors the dial logic the
908
+ * SDK would need if it honored proxy env.
909
+ */
910
+ function makeUpstreamReq(method, fullUrl, headers, cb) {
911
+ const u = new URL(fullUrl);
912
+ const hdrs = { ...headers, host: u.host };
913
+ const proxy = proxyForUrl(u);
914
+
915
+ if (proxy && u.protocol === "https:") {
916
+ const port = Number(u.port) || 443;
917
+ const proxyPort = Number(proxy.port) || 8080;
918
+ const auth = proxyAuthHeader(proxy);
919
+ return https.request(fullUrl, {
920
+ method,
921
+ headers: hdrs,
922
+ // Defer the dial: open a TCP socket to the proxy, CONNECT, then TLS over
923
+ // the tunnel. We hand the established TLS socket back via `oncreate`.
924
+ createConnection(_opts, oncreate) {
925
+ const proxyTimeout = Number(process.env.LLM_GATEWAY_PROXY_TIMEOUT || 15000);
926
+ const sock = net.connect({ host: proxy.hostname, port: proxyPort });
927
+ let settled = false;
928
+ // oncreate must fire exactly once: a blackholed proxy, a CONNECT error,
929
+ // and a TLS-handshake failure are all funneled here under the guard.
930
+ const fail = (err) => { if (settled) return; settled = true; sock.destroy(); oncreate(err instanceof Error ? err : new Error(String(err))); };
931
+ const onProxyError = (err) => fail(err);
932
+ sock.setTimeout(proxyTimeout, () => fail(new Error("proxy CONNECT timeout")));
933
+ sock.once("error", onProxyError);
934
+ const chunks = [];
935
+ const onData = (chunk) => {
936
+ chunks.push(chunk);
937
+ const buf = Buffer.concat(chunks);
938
+ const sep = buf.indexOf("\r\n\r\n");
939
+ if (sep === -1) return; // CONNECT reply headers not complete yet
940
+ sock.removeListener("data", onData);
941
+ sock.removeListener("error", onProxyError);
942
+ sock.setTimeout(0);
943
+ const status = buf.slice(0, buf.indexOf("\r\n")).toString("latin1");
944
+ if (!/^HTTP\/1\.[01] 200\b/.test(status)) {
945
+ return fail(new Error("proxy CONNECT failed: " + status.trim()));
946
+ }
947
+ // Bytes after the header terminator belong to the TLS stream — push
948
+ // them back so the TLS socket reads them (avoids a desync if the proxy
949
+ // coalesces the 200 reply with the first TLS bytes).
950
+ const remainder = buf.slice(sep + 4);
951
+ if (remainder.length) sock.unshift(remainder);
952
+ const tlsSock = tls.connect({ socket: sock, servername: u.hostname });
953
+ const onTlsErr = (err) => fail(err); // handshake failure → single oncreate
954
+ tlsSock.once("error", onTlsErr);
955
+ tlsSock.once("secureConnect", () => {
956
+ if (settled) { tlsSock.destroy(); return; }
957
+ settled = true;
958
+ tlsSock.removeListener("error", onTlsErr); // post-handshake errors go to http
959
+ oncreate(null, tlsSock);
960
+ });
961
+ };
962
+ sock.on("data", onData);
963
+ sock.once("connect", () => {
964
+ let h = `CONNECT ${u.hostname}:${port} HTTP/1.1\r\nHost: ${u.hostname}:${port}\r\n`;
965
+ if (auth) h += `Proxy-Authorization: ${auth}\r\n`;
966
+ sock.write(h + "\r\n");
967
+ });
968
+ return undefined; // socket delivered via oncreate
969
+ },
970
+ }, cb);
971
+ }
972
+
973
+ if (proxy && u.protocol === "http:") {
974
+ const auth = proxyAuthHeader(proxy);
975
+ if (auth) hdrs["proxy-authorization"] = auth;
976
+ return http.request({
977
+ host: proxy.hostname,
978
+ port: Number(proxy.port) || 8080,
979
+ method,
980
+ path: fullUrl, // absolute-form for a forward proxy
981
+ headers: hdrs,
982
+ }, cb);
983
+ }
984
+
985
+ const lib = u.protocol === "https:" ? https : http;
986
+ return lib.request(fullUrl, { method, headers: hdrs }, cb);
987
+ }
988
+
989
+ function upstreamRequest(method, url, headers, bodyBuf) {
990
+ return new Promise((resolve, reject) => {
991
+ const r = makeUpstreamReq(method, url, headers, (res) => resolve(res));
992
+ r.on("error", reject);
993
+ if (bodyBuf) r.write(bodyBuf);
994
+ r.end();
995
+ });
996
+ }
997
+
998
+ async function handleOpenAI(req, res) {
999
+ const apiKey = req.headers["x-api-key"] || "";
1000
+ const auth = apiKey ? `Bearer ${apiKey}` : req.headers["authorization"] || "";
1001
+
1002
+ // /v1/messages -> /v1/chat/completions (the translated path)
1003
+ if (req.method === "POST" && req.url.startsWith("/v1/messages") && !req.url.includes("count_tokens")) {
1004
+ const raw = await readBody(req);
1005
+ let anthropicBody;
1006
+ try {
1007
+ anthropicBody = JSON.parse(raw.toString("utf8"));
1008
+ } catch (e) {
1009
+ res.writeHead(400, { "content-type": "application/json" });
1010
+ res.end(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "bad json" } }));
1011
+ return;
1012
+ }
1013
+ const wantStream = !!anthropicBody.stream;
1014
+ substituteClaudeModel(anthropicBody);
1015
+ const oa = anthropicToOpenAIRequest(anthropicBody);
1016
+ const send = (body) => {
1017
+ const buf = Buffer.from(JSON.stringify(body));
1018
+ return upstreamRequest(
1019
+ "POST",
1020
+ `${TARGET}/v1/chat/completions`,
1021
+ { authorization: auth, "content-type": "application/json", "content-length": Buffer.byteLength(buf) },
1022
+ buf,
1023
+ );
1024
+ };
1025
+ let upstream = await send(oa);
1026
+
1027
+ // Adaptive error-driven retries. Two learners share the loop:
1028
+ // - capability fixes (applyCapabilityFix): the provider says a param
1029
+ // combination is unsupported → learn it for this model, adjust, retry;
1030
+ // - tool trim: context-window overflow → drop lowest-priority tools ONCE.
1031
+ // Each fix provably changes the request, and attempts are bounded, so the
1032
+ // loop can't spin. Unrecognized errors surface to the caller unchanged.
1033
+ let trimmed = false;
1034
+ let attempts = 0;
1035
+ while (
1036
+ (upstream.statusCode === 400 || upstream.statusCode === 413 || upstream.statusCode === 422) &&
1037
+ attempts < 3
1038
+ ) {
1039
+ const errText = (await readAll(upstream)).toString("utf8");
1040
+
1041
+ const fix = applyCapabilityFix(oa, errText);
1042
+ if (fix) {
1043
+ attempts++;
1044
+ console.error(`[llm-gateway] ${fix}, retrying`);
1045
+ upstream = await send(oa);
1046
+ continue;
1047
+ }
1048
+
1049
+ if (
1050
+ !trimmed &&
1051
+ process.env.LLM_GATEWAY_NO_TOOL_TRIM !== "1" &&
1052
+ Array.isArray(oa.tools) && oa.tools.length > 0
1053
+ ) {
1054
+ const limit = parseContextLimit(errText);
1055
+ const trim = limit ? trimOpenAIToolsToFit(oa, limit) : { dropped: 0, changed: false };
1056
+ if (trim.changed) {
1057
+ trimmed = true;
1058
+ attempts++;
1059
+ console.error(`[llm-gateway] context limit ${limit}: capped descriptions${trim.dropped ? ` + dropped ${trim.dropped} tool(s)` : ""} to fit, retrying`);
1060
+ upstream = await send(oa);
1061
+ continue;
1062
+ }
1063
+ }
1064
+
1065
+ // Not an error we know how to fix — surface as-is, preserving the
1066
+ // upstream status + content-type.
1067
+ res.writeHead(upstream.statusCode, {
1068
+ "content-type": upstream.headers["content-type"] || "application/json",
1069
+ });
1070
+ res.end(errText);
1071
+ return;
1072
+ }
1073
+
1074
+ if (upstream.statusCode >= 400) {
1075
+ // surface the upstream error body as-is (helps debugging in the UI)
1076
+ res.writeHead(upstream.statusCode, { "content-type": "application/json" });
1077
+ upstream.pipe(res);
1078
+ return;
1079
+ }
1080
+
1081
+ if (wantStream) {
1082
+ res.writeHead(200, {
1083
+ "content-type": "text/event-stream",
1084
+ "cache-control": "no-cache",
1085
+ connection: "keep-alive",
1086
+ });
1087
+ const tr = makeStreamTranslator(anthropicBody.model);
1088
+ let buf = "";
1089
+ // [DONE] arrives mid-stream, but the upstream socket still fires "end"
1090
+ // afterward. Without this guard both paths flush tr.end() + res.end(),
1091
+ // re-writing message_delta/message_stop to an already-ended response
1092
+ // (ERR_STREAM_WRITE_AFTER_END). finish() makes teardown happen once.
1093
+ let finished = false;
1094
+ const finish = () => {
1095
+ if (finished) return;
1096
+ finished = true;
1097
+ for (const ev of tr.end()) res.write(ev);
1098
+ res.end();
1099
+ };
1100
+ upstream.on("data", (chunk) => {
1101
+ if (finished) return;
1102
+ buf += chunk.toString("utf8");
1103
+ const lines = buf.split("\n");
1104
+ buf = lines.pop() || "";
1105
+ for (const line of lines) {
1106
+ const t = line.trim();
1107
+ if (!t.startsWith("data:")) continue;
1108
+ const payload = t.slice(5).trim();
1109
+ if (payload === "[DONE]") {
1110
+ finish();
1111
+ upstream.destroy();
1112
+ return;
1113
+ }
1114
+ try {
1115
+ const obj = JSON.parse(payload);
1116
+ for (const ev of tr.push(obj)) res.write(ev);
1117
+ } catch {
1118
+ /* ignore keep-alive / partial lines */
1119
+ }
1120
+ }
1121
+ });
1122
+ upstream.on("end", finish);
1123
+ upstream.on("error", () => { if (!finished) { finished = true; res.end(); } });
1124
+ return;
1125
+ }
1126
+
1127
+ // non-streaming
1128
+ const respBuf = await readAll(upstream);
1129
+ let oaResp;
1130
+ try {
1131
+ oaResp = JSON.parse(respBuf.toString("utf8"));
1132
+ } catch {
1133
+ res.writeHead(502, { "content-type": "application/json" });
1134
+ res.end(JSON.stringify({ type: "error", error: { type: "api_error", message: "bad upstream json" } }));
1135
+ return;
1136
+ }
1137
+ const anthropicResp = JSON.stringify(openAIToAnthropicResponse(oaResp, anthropicBody.model));
1138
+ res.writeHead(200, { "content-type": "application/json" });
1139
+ res.end(anthropicResp);
1140
+ return;
1141
+ }
1142
+
1143
+ // count_tokens has no OpenAI equivalent — return a cheap char/4 estimate so
1144
+ // the SDK's context bookkeeping doesn't break.
1145
+ if (req.method === "POST" && req.url.includes("count_tokens")) {
1146
+ const raw = await readBody(req);
1147
+ let estimate = 0;
1148
+ try {
1149
+ estimate = Math.ceil(raw.toString("utf8").length / 4);
1150
+ } catch {
1151
+ estimate = 0;
1152
+ }
1153
+ res.writeHead(200, { "content-type": "application/json" });
1154
+ res.end(JSON.stringify({ input_tokens: estimate }));
1155
+ return;
1156
+ }
1157
+
1158
+ // Everything else (e.g. GET /v1/models): forward with auth rewrite.
1159
+ const raw = req.method === "POST" ? await readBody(req) : undefined;
1160
+ const upstream = await upstreamRequest(
1161
+ req.method,
1162
+ `${TARGET}${req.url}`,
1163
+ { authorization: auth, "content-type": req.headers["content-type"] || "application/json" },
1164
+ raw,
1165
+ );
1166
+ res.writeHead(upstream.statusCode, upstream.headers);
1167
+ upstream.pipe(res);
1168
+ }
1169
+
1170
+ function readAll(stream) {
1171
+ return new Promise((resolve, reject) => {
1172
+ const chunks = [];
1173
+ stream.on("data", (c) => chunks.push(c));
1174
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
1175
+ stream.on("error", reject);
1176
+ });
1177
+ }
1178
+
1179
+ // Raw passthrough preserves the client's own auth (x-api-key, anthropic-version,
1180
+ // ...) instead of rewriting x-api-key -> Bearer. Used when egress enforcement
1181
+ // forces the native-Anthropic model path through the gateway purely to reach the
1182
+ // proxy: the upstream IS api.anthropic.com, which expects x-api-key, not Bearer.
1183
+ const PASSTHROUGH_RAW = process.env.LLM_GATEWAY_PASSTHROUGH_RAW === "1";
1184
+
1185
+ // Hop-by-hop headers (RFC 7230 §6.1) must not be blindly forwarded upstream —
1186
+ // a client-controlled Transfer-Encoding/Connection enables request smuggling,
1187
+ // and a stray proxy-authorization would leak the proxy token to the provider.
1188
+ const HOP_BY_HOP = new Set([
1189
+ "connection", "keep-alive", "proxy-authorization", "proxy-connection",
1190
+ "te", "trailer", "transfer-encoding", "upgrade",
1191
+ ]);
1192
+
1193
+ async function handlePassthrough(req, res) {
1194
+ const headers = { host: new URL(TARGET).hostname };
1195
+ for (const [k, v] of Object.entries(req.headers)) {
1196
+ if (k.toLowerCase() !== "host" && !HOP_BY_HOP.has(k.toLowerCase())) headers[k] = v;
1197
+ }
1198
+ if (!PASSTHROUGH_RAW) {
1199
+ const apiKey = req.headers["x-api-key"] || "";
1200
+ headers.authorization = `Bearer ${apiKey}`;
1201
+ delete headers["x-api-key"];
1202
+ }
1203
+ // Route through the egress proxy when configured (HTTPS_PROXY) so the model
1204
+ // call works on the no-direct-internet network; direct otherwise.
1205
+ const proxyReq = makeUpstreamReq(req.method, `${TARGET}${req.url}`, headers, (proxyRes) => {
1206
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
1207
+ proxyRes.pipe(res);
1208
+ });
1209
+ proxyReq.on("error", (e) => {
1210
+ res.writeHead(502);
1211
+ res.end(JSON.stringify({ error: e.message }));
1212
+ });
1213
+ req.pipe(proxyReq);
1214
+ }
1215
+
1216
+ // Originator is a static, truthful identity. The account id is NOT read from a
1217
+ // module-load env — it's decoded from the per-request token below, so a reused
1218
+ // container that switches between two ChatGPT subscriptions (different account
1219
+ // ids, same gateway process) always sends the id matching the CURRENT token
1220
+ // rather than a stale one baked in at first boot. CODEX_ACCOUNT_ID is only a
1221
+ // fallback for tokens whose account id can't be decoded.
1222
+ const CODEX_ORIGINATOR = process.env.CODEX_ORIGINATOR || "vonzio";
1223
+ const CODEX_ACCOUNT_ID_FALLBACK = process.env.CODEX_ACCOUNT_ID || "";
1224
+
1225
+ /** Decode `chatgpt_account_id` from a Codex JWT (no signature check — it's only
1226
+ * a routing header). Mirrors accountIdFromJwt in codex-oauth-service.ts. */
1227
+ function codexAccountId(jwt) {
1228
+ try {
1229
+ const parts = String(jwt).split(".");
1230
+ if (parts.length < 2) return "";
1231
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
1232
+ const id = payload && payload["https://api.openai.com/auth"] && payload["https://api.openai.com/auth"].chatgpt_account_id;
1233
+ return typeof id === "string" ? id : "";
1234
+ } catch {
1235
+ return "";
1236
+ }
1237
+ }
1238
+
1239
+ async function handleCodex(req, res) {
1240
+ const token = req.headers["x-api-key"] || (req.headers["authorization"] || "").replace(/^Bearer\s+/i, "");
1241
+
1242
+ // count_tokens: no Responses equivalent — cheap char/4 estimate (parity with
1243
+ // handleOpenAI) so the SDK's context bookkeeping keeps working.
1244
+ if (req.method === "POST" && req.url.includes("count_tokens")) {
1245
+ const raw = await readBody(req);
1246
+ res.writeHead(200, { "content-type": "application/json" });
1247
+ res.end(JSON.stringify({ input_tokens: Math.ceil(raw.toString("utf8").length / 4) }));
1248
+ return;
1249
+ }
1250
+
1251
+ if (req.method === "POST" && req.url.startsWith("/v1/messages")) {
1252
+ const raw = await readBody(req);
1253
+ let anthropicBody;
1254
+ try {
1255
+ anthropicBody = JSON.parse(raw.toString("utf8"));
1256
+ } catch {
1257
+ res.writeHead(400, { "content-type": "application/json" });
1258
+ res.end(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "bad json" } }));
1259
+ return;
1260
+ }
1261
+ const wantStream = !!anthropicBody.stream;
1262
+ substituteClaudeModel(anthropicBody);
1263
+ // The Codex backend only streams; force stream upstream and aggregate below
1264
+ // if the SDK asked for a non-streaming reply.
1265
+ const cx = anthropicToCodexRequest({ ...anthropicBody, stream: true });
1266
+ const buf = Buffer.from(JSON.stringify(cx));
1267
+ // Codex has its own backend base; only honor LLM_GATEWAY_TARGET_URL if it
1268
+ // was set for codex (the global TARGET default is api.openai.com, wrong here).
1269
+ const codexBase = process.env.LLM_GATEWAY_TARGET_URL || "https://chatgpt.com/backend-api";
1270
+ const endpoint = codexResponsesUrl(codexBase);
1271
+ const accountId = codexAccountId(token) || CODEX_ACCOUNT_ID_FALLBACK;
1272
+ const upstreamHeaders = {
1273
+ authorization: `Bearer ${token}`,
1274
+ originator: CODEX_ORIGINATOR,
1275
+ "openai-beta": "responses=experimental",
1276
+ "content-type": "application/json",
1277
+ accept: "text/event-stream",
1278
+ "content-length": Buffer.byteLength(buf),
1279
+ };
1280
+ // Omit the header entirely when we can't resolve an id — some backends
1281
+ // reject an empty chatgpt-account-id rather than treating it as absent.
1282
+ if (accountId) upstreamHeaders["chatgpt-account-id"] = accountId;
1283
+ const upstream = await upstreamRequest("POST", endpoint, upstreamHeaders, buf);
1284
+
1285
+ if (upstream.statusCode >= 400) {
1286
+ const errText = (await readAll(upstream)).toString("utf8");
1287
+ res.writeHead(upstream.statusCode, { "content-type": "application/json" });
1288
+ res.end(JSON.stringify({ type: "error", error: { type: "api_error", message: `codex upstream ${upstream.statusCode}: ${errText.slice(0, 500)}` } }));
1289
+ return;
1290
+ }
1291
+
1292
+ const tr = makeCodexStreamTranslator(anthropicBody.model);
1293
+ // Parse the upstream Responses SSE (event:/data: lines); we only need the
1294
+ // data payloads, which carry `type`. response.completed ends the stream.
1295
+ const parseInto = (chunkStr, sink, bufRef) => {
1296
+ bufRef.s += chunkStr;
1297
+ const lines = bufRef.s.split("\n");
1298
+ bufRef.s = lines.pop() || "";
1299
+ for (const line of lines) {
1300
+ const t = line.trim();
1301
+ if (!t.startsWith("data:")) continue;
1302
+ const payload = t.slice(5).trim();
1303
+ if (!payload || payload === "[DONE]") continue;
1304
+ try { sink(JSON.parse(payload)); } catch { /* keep-alive / partial */ }
1305
+ }
1306
+ };
1307
+
1308
+ if (wantStream) {
1309
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1310
+ const bufRef = { s: "" };
1311
+ let finished = false;
1312
+ const finish = () => { if (finished) return; finished = true; for (const ev of tr.end()) res.write(ev); res.end(); };
1313
+ upstream.on("data", (chunk) => {
1314
+ if (finished) return;
1315
+ parseInto(chunk.toString("utf8"), (obj) => { for (const ev of tr.push(obj)) res.write(ev); }, bufRef);
1316
+ });
1317
+ upstream.on("end", finish);
1318
+ upstream.on("error", () => { if (!finished) { finished = true; res.end(); } });
1319
+ return;
1320
+ }
1321
+
1322
+ // Non-streaming: drain the upstream stream, run it through the translator,
1323
+ // and fold the Anthropic SSE events back into a single Messages response.
1324
+ const respBuf = await readAll(upstream);
1325
+ const bufRef = { s: "" };
1326
+ const events = [];
1327
+ parseInto(respBuf.toString("utf8"), (obj) => { for (const ev of tr.push(obj)) events.push(ev); }, bufRef);
1328
+ for (const ev of tr.end()) events.push(ev);
1329
+ res.writeHead(200, { "content-type": "application/json" });
1330
+ res.end(JSON.stringify(foldAnthropicSSE(events, anthropicBody.model)));
1331
+ return;
1332
+ }
1333
+
1334
+ // No other Responses routes are needed (models are enumerated server-side).
1335
+ res.writeHead(404, { "content-type": "application/json" });
1336
+ res.end(JSON.stringify({ type: "error", error: { type: "not_found_error", message: "unsupported route" } }));
1337
+ }
1338
+
1339
+ /** Collapse a sequence of Anthropic SSE strings into one non-streaming Messages
1340
+ * response object (used when the SDK asked for a non-streaming reply). */
1341
+ function foldAnthropicSSE(events, fallbackModel) {
1342
+ const blocks = [];
1343
+ let stopReason = "end_turn";
1344
+ let usage = { input_tokens: 0, output_tokens: 0 };
1345
+ let id = `msg_${Date.now()}`;
1346
+ for (const raw of events) {
1347
+ const m = /^data: (.*)$/m.exec(raw);
1348
+ if (!m) continue;
1349
+ let e;
1350
+ try { e = JSON.parse(m[1]); } catch { continue; }
1351
+ if (e.type === "message_start") { id = e.message?.id || id; }
1352
+ else if (e.type === "content_block_start") { blocks[e.index] = JSON.parse(JSON.stringify(e.content_block)); if (blocks[e.index].type === "tool_use") blocks[e.index]._json = ""; }
1353
+ else if (e.type === "content_block_delta") {
1354
+ const b = blocks[e.index];
1355
+ if (!b) continue;
1356
+ if (e.delta.type === "text_delta") b.text = (b.text || "") + e.delta.text;
1357
+ else if (e.delta.type === "input_json_delta") b._json = (b._json || "") + e.delta.partial_json;
1358
+ } else if (e.type === "message_delta") { if (e.delta?.stop_reason) stopReason = e.delta.stop_reason; if (e.usage) usage = { ...usage, ...e.usage }; }
1359
+ }
1360
+ const content = blocks.filter(Boolean).map((b) => {
1361
+ if (b.type === "tool_use") { let input = {}; try { input = JSON.parse(b._json || "{}"); } catch { /* keep {} */ } return { type: "tool_use", id: b.id, name: b.name, input }; }
1362
+ return b;
1363
+ });
1364
+ if (content.length === 0) content.push({ type: "text", text: "" });
1365
+ return { id, type: "message", role: "assistant", model: fallbackModel, content, stop_reason: stopReason, stop_sequence: null, usage };
1366
+ }
1367
+
1368
+ function startServer() {
1369
+ const server = http.createServer((req, res) => {
1370
+ const handler = MODE === "passthrough" ? handlePassthrough : MODE === "codex" ? handleCodex : handleOpenAI;
1371
+ Promise.resolve(handler(req, res)).catch((e) => {
1372
+ if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
1373
+ res.end(JSON.stringify({ type: "error", error: { type: "api_error", message: String(e && e.message || e) } }));
1374
+ });
1375
+ });
1376
+ server.on("error", (e) => {
1377
+ if (e.code === "EADDRINUSE") process.exit(0); // already running for this container
1378
+ throw e;
1379
+ });
1380
+ server.listen(PORT, "127.0.0.1", () => {
1381
+ process.stdout.write(`llm-gateway (${MODE}) listening on 127.0.0.1:${PORT} -> ${TARGET}\n`);
1382
+ });
1383
+ }
1384
+
1385
+ module.exports = {
1386
+ // DIVERGENCE (Foreman): the three request handlers are exported so
1387
+ // ledger.cjs can bind the socket itself and count tokens per run without
1388
+ // editing anything above this line. Additive only — re-apply after a
1389
+ // re-sync; src/gateway/ledger.test.ts fails loudly if it is lost.
1390
+ handleOpenAI,
1391
+ handleCodex,
1392
+ handlePassthrough,
1393
+ anthropicToOpenAIRequest,
1394
+ hoistSystemMessages,
1395
+ openAIToAnthropicResponse,
1396
+ makeStreamTranslator,
1397
+ systemToText,
1398
+ translateToolChoice,
1399
+ mapFinishReason,
1400
+ estimateTokens,
1401
+ parseContextLimit,
1402
+ trimOpenAIToolsToFit,
1403
+ // codex (Responses API) mode
1404
+ anthropicToCodexRequest,
1405
+ makeCodexStreamTranslator,
1406
+ codexResponsesUrl,
1407
+ translateMessageToResponses,
1408
+ foldAnthropicSSE,
1409
+ };
1410
+
1411
+ if (require.main === module) startServer();