@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.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- 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();
|