@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,478 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* llm-gateway.cjs is a verbatim port of the sibling vonzio-gateway project's
|
|
7
|
+
* docker/llm-gateway.cjs (CommonJS, no build step, runs in-container). These
|
|
8
|
+
* tests are the contract for its pure translation functions — they are what
|
|
9
|
+
* makes it safe to re-sync the file from upstream without silently breaking
|
|
10
|
+
* request/response/stream translation. The HTTP server only starts under
|
|
11
|
+
* `require.main === module`, so requiring it here is side-effect-free.
|
|
12
|
+
*/
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const gw = require("./llm-gateway.cjs") as {
|
|
15
|
+
anthropicToOpenAIRequest: (b: unknown) => any;
|
|
16
|
+
hoistSystemMessages: (m: unknown[]) => unknown[];
|
|
17
|
+
openAIToAnthropicResponse: (b: unknown, m: string) => any;
|
|
18
|
+
makeStreamTranslator: (m: string) => { push: (c: unknown) => string[]; end: () => string[] };
|
|
19
|
+
mapFinishReason: (r: string | null) => string;
|
|
20
|
+
parseContextLimit: (t: string) => number | null;
|
|
21
|
+
trimOpenAIToolsToFit: (oa: any, limit: number) => { dropped: number; changed: boolean };
|
|
22
|
+
estimateTokens: (s: string) => number;
|
|
23
|
+
anthropicToCodexRequest: (b: unknown, o?: unknown) => any;
|
|
24
|
+
makeCodexStreamTranslator: (m: string) => { push: (e: unknown) => string[]; end: () => string[] };
|
|
25
|
+
codexResponsesUrl: (base: string) => string;
|
|
26
|
+
translateMessageToResponses: (m: unknown, out: unknown[]) => void;
|
|
27
|
+
foldAnthropicSSE: (events: string[], model: string) => any;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Parse an Anthropic SSE string array into typed event objects. */
|
|
31
|
+
function parseSSE(events: string[]): any[] {
|
|
32
|
+
return events.map((e) => JSON.parse(/^data: (.*)$/m.exec(e)![1]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse the gateway's Anthropic SSE strings into {event, data} objects. */
|
|
36
|
+
function parseSse(frames: string[]) {
|
|
37
|
+
return frames.map((f) => {
|
|
38
|
+
const ev = /event: (.*)/.exec(f)?.[1];
|
|
39
|
+
const data = /data: (.*)/.exec(f)?.[1];
|
|
40
|
+
return { event: ev, data: data ? JSON.parse(data) : null };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── llm-gateway request translation (Anthropic -> OpenAI) ────────────────
|
|
45
|
+
|
|
46
|
+
test("llm-gateway request translation (Anthropic -> OpenAI) · flattens system and translates a simple user turn", () => {
|
|
47
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
48
|
+
model: "gpt-x",
|
|
49
|
+
system: "You are helpful.",
|
|
50
|
+
max_tokens: 100,
|
|
51
|
+
messages: [{ role: "user", content: "hi" }],
|
|
52
|
+
});
|
|
53
|
+
assert.equal(oa.model, "gpt-x");
|
|
54
|
+
assert.equal(oa.max_tokens, 100);
|
|
55
|
+
assert.deepEqual(oa.messages[0], { role: "system", content: "You are helpful." });
|
|
56
|
+
assert.deepEqual(oa.messages[1], { role: "user", content: "hi" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("llm-gateway request translation (Anthropic -> OpenAI) · routes the token cap to max_completion_tokens for GPT-5 / o-series, max_tokens otherwise", () => {
|
|
60
|
+
// gpt-4* and OpenAI-compatible servers: legacy max_tokens
|
|
61
|
+
const legacy = gw.anthropicToOpenAIRequest({
|
|
62
|
+
model: "gpt-4o",
|
|
63
|
+
max_tokens: 256,
|
|
64
|
+
messages: [{ role: "user", content: "hi" }],
|
|
65
|
+
});
|
|
66
|
+
assert.equal(legacy.max_tokens, 256);
|
|
67
|
+
assert.equal(legacy.max_completion_tokens, undefined);
|
|
68
|
+
|
|
69
|
+
// GPT-5 family rejects max_tokens — must use max_completion_tokens
|
|
70
|
+
const gpt5 = gw.anthropicToOpenAIRequest({
|
|
71
|
+
model: "gpt-5.4",
|
|
72
|
+
max_tokens: 256,
|
|
73
|
+
messages: [{ role: "user", content: "hi" }],
|
|
74
|
+
});
|
|
75
|
+
assert.equal(gpt5.max_completion_tokens, 256);
|
|
76
|
+
assert.equal(gpt5.max_tokens, undefined);
|
|
77
|
+
|
|
78
|
+
// o-series reasoning models likewise
|
|
79
|
+
const o3 = gw.anthropicToOpenAIRequest({
|
|
80
|
+
model: "o3-mini",
|
|
81
|
+
max_tokens: 256,
|
|
82
|
+
messages: [{ role: "user", content: "hi" }],
|
|
83
|
+
});
|
|
84
|
+
assert.equal(o3.max_completion_tokens, 256);
|
|
85
|
+
assert.equal(o3.max_tokens, undefined);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("llm-gateway request translation (Anthropic -> OpenAI) · translates tools and tool_choice", () => {
|
|
89
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
90
|
+
model: "gpt-x",
|
|
91
|
+
max_tokens: 10,
|
|
92
|
+
messages: [{ role: "user", content: "x" }],
|
|
93
|
+
tools: [{ name: "get_weather", description: "w", input_schema: { type: "object", properties: {} } }],
|
|
94
|
+
tool_choice: { type: "any" },
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(oa.tools[0], {
|
|
97
|
+
type: "function",
|
|
98
|
+
function: { name: "get_weather", description: "w", parameters: { type: "object", properties: {} } },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(oa.tool_choice, "required");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("llm-gateway request translation (Anthropic -> OpenAI) · maps assistant tool_use blocks to tool_calls and user tool_result to role:tool", () => {
|
|
104
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
105
|
+
model: "gpt-x",
|
|
106
|
+
max_tokens: 10,
|
|
107
|
+
messages: [
|
|
108
|
+
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } }] },
|
|
109
|
+
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }] },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
const assistant = oa.messages.find((m: any) => m.role === "assistant");
|
|
113
|
+
assert.deepEqual(assistant.tool_calls[0], {
|
|
114
|
+
id: "t1",
|
|
115
|
+
type: "function",
|
|
116
|
+
function: { name: "f", arguments: JSON.stringify({ a: 1 }) },
|
|
117
|
+
});
|
|
118
|
+
const tool = oa.messages.find((m: any) => m.role === "tool");
|
|
119
|
+
assert.deepEqual(tool, { role: "tool", tool_call_id: "t1", content: "result text" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── llm-gateway response translation (OpenAI -> Anthropic) ───────────────
|
|
123
|
+
|
|
124
|
+
test("llm-gateway response translation (OpenAI -> Anthropic) · translates text + tool_calls and maps finish_reason/usage", () => {
|
|
125
|
+
const anth = gw.openAIToAnthropicResponse(
|
|
126
|
+
{
|
|
127
|
+
id: "cmpl_1",
|
|
128
|
+
model: "gpt-x",
|
|
129
|
+
choices: [
|
|
130
|
+
{
|
|
131
|
+
finish_reason: "tool_calls",
|
|
132
|
+
message: {
|
|
133
|
+
content: "sure",
|
|
134
|
+
tool_calls: [{ id: "t1", function: { name: "f", arguments: '{"a":1}' } }],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
usage: { prompt_tokens: 7, completion_tokens: 3 },
|
|
139
|
+
},
|
|
140
|
+
"fallback",
|
|
141
|
+
);
|
|
142
|
+
assert.equal(anth.type, "message");
|
|
143
|
+
assert.equal(anth.stop_reason, "tool_use");
|
|
144
|
+
assert.deepEqual(anth.content[0], { type: "text", text: "sure" });
|
|
145
|
+
assert.deepEqual(anth.content[1], { type: "tool_use", id: "t1", name: "f", input: { a: 1 } });
|
|
146
|
+
assert.deepEqual(anth.usage, { input_tokens: 7, output_tokens: 3 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("llm-gateway response translation (OpenAI -> Anthropic) · maps finish reasons", () => {
|
|
150
|
+
assert.equal(gw.mapFinishReason("stop"), "end_turn");
|
|
151
|
+
assert.equal(gw.mapFinishReason("length"), "max_tokens");
|
|
152
|
+
assert.equal(gw.mapFinishReason("tool_calls"), "tool_use");
|
|
153
|
+
assert.equal(gw.mapFinishReason(null), "end_turn");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── llm-gateway streaming translation (OpenAI SSE -> Anthropic SSE) ──────
|
|
157
|
+
|
|
158
|
+
test("llm-gateway streaming translation (OpenAI SSE -> Anthropic SSE) · emits a well-formed Anthropic event sequence for text", () => {
|
|
159
|
+
const tr = gw.makeStreamTranslator("gpt-x");
|
|
160
|
+
const frames = [
|
|
161
|
+
...tr.push({ choices: [{ delta: { content: "Hel" } }] }),
|
|
162
|
+
...tr.push({ choices: [{ delta: { content: "lo" } }] }),
|
|
163
|
+
...tr.push({ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 5, completion_tokens: 2 } }),
|
|
164
|
+
...tr.end(),
|
|
165
|
+
];
|
|
166
|
+
const events = parseSse(frames);
|
|
167
|
+
const types = events.map((e) => e.event);
|
|
168
|
+
assert.equal(types[0], "message_start");
|
|
169
|
+
assert.ok(types.includes("content_block_start"));
|
|
170
|
+
assert.equal(types.filter((t) => t === "content_block_delta").length, 2);
|
|
171
|
+
assert.ok(types.includes("content_block_stop"));
|
|
172
|
+
const delta = events.find((e) => e.event === "message_delta");
|
|
173
|
+
assert.equal(delta?.data.delta.stop_reason, "end_turn");
|
|
174
|
+
assert.equal(delta?.data.usage.output_tokens, 2);
|
|
175
|
+
assert.equal(types[types.length - 1], "message_stop");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("llm-gateway streaming translation (OpenAI SSE -> Anthropic SSE) · opens a tool_use block and streams input_json_delta for tool calls", () => {
|
|
179
|
+
const tr = gw.makeStreamTranslator("gpt-x");
|
|
180
|
+
const frames = [
|
|
181
|
+
...tr.push({ choices: [{ delta: { tool_calls: [{ index: 0, id: "t1", function: { name: "f", arguments: "" } }] } }] }),
|
|
182
|
+
...tr.push({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"a":1}' } }] } }] }),
|
|
183
|
+
...tr.push({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }),
|
|
184
|
+
...tr.end(),
|
|
185
|
+
];
|
|
186
|
+
const events = parseSse(frames);
|
|
187
|
+
const start = events.find((e) => e.event === "content_block_start");
|
|
188
|
+
assert.equal(start?.data.content_block.type, "tool_use");
|
|
189
|
+
assert.equal(start?.data.content_block.name, "f");
|
|
190
|
+
const jsonDelta = events.find(
|
|
191
|
+
(e) => e.event === "content_block_delta" && e.data.delta.type === "input_json_delta",
|
|
192
|
+
);
|
|
193
|
+
assert.equal(jsonDelta?.data.delta.partial_json, '{"a":1}');
|
|
194
|
+
const msgDelta = events.find((e) => e.event === "message_delta");
|
|
195
|
+
assert.equal(msgDelta?.data.delta.stop_reason, "tool_use");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ─── llm-gateway adaptive tool trimming ────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
test("llm-gateway adaptive tool trimming · parseContextLimit reads the model's limit (or null for non-context errors)", () => {
|
|
201
|
+
assert.equal(gw.parseContextLimit("This model's maximum context length is 8192 tokens. However..."), 8192);
|
|
202
|
+
assert.equal(gw.parseContextLimit('{"code":"context_length_exceeded"}'), 8192); // fallback
|
|
203
|
+
assert.equal(gw.parseContextLimit('{"error":"Incorrect API key provided"}'), null);
|
|
204
|
+
assert.equal(gw.parseContextLimit(""), null);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("llm-gateway adaptive tool trimming · trimOpenAIToolsToFit keeps essential tools first and drops the long tail to fit", () => {
|
|
208
|
+
// 30 fat tools (~ a few hundred tokens each via a long description).
|
|
209
|
+
const desc = "x".repeat(800);
|
|
210
|
+
const mk = (name: string) => ({ type: "function", function: { name, description: desc, parameters: { type: "object", properties: {} } } });
|
|
211
|
+
const oa: any = {
|
|
212
|
+
model: "small",
|
|
213
|
+
messages: [{ role: "user", content: "hi" }],
|
|
214
|
+
tools: [
|
|
215
|
+
...["mcp__a", "mcp__b", "mcp__c"].map(mk), // unknown/MCP — dropped first
|
|
216
|
+
mk("Bash"), mk("Read"), mk("Write"), mk("Edit"), mk("Grep"),
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
const before = oa.tools.length;
|
|
220
|
+
const { dropped } = gw.trimOpenAIToolsToFit(oa, 1200); // tiny budget
|
|
221
|
+
assert.ok(dropped > 0);
|
|
222
|
+
const kept = (oa.tools ?? []).map((t: any) => t.function.name);
|
|
223
|
+
// Core built-ins outrank the MCP tail under a tiny budget, so survivors are core.
|
|
224
|
+
for (const name of kept) assert.ok(!/^mcp__/.test(name));
|
|
225
|
+
assert.equal(before - kept.length, dropped);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("llm-gateway adaptive tool trimming · trimOpenAIToolsToFit drops all tools (and tool_choice) when nothing fits", () => {
|
|
229
|
+
const oa: any = {
|
|
230
|
+
messages: [{ role: "user", content: "x".repeat(40000) }], // huge prompt
|
|
231
|
+
tools: [{ type: "function", function: { name: "Bash", description: "y".repeat(2000), parameters: {} } }],
|
|
232
|
+
tool_choice: "auto",
|
|
233
|
+
};
|
|
234
|
+
const { dropped } = gw.trimOpenAIToolsToFit(oa, 8192);
|
|
235
|
+
assert.equal(dropped, 1);
|
|
236
|
+
assert.equal(oa.tools, undefined);
|
|
237
|
+
assert.equal(oa.tool_choice, undefined);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("llm-gateway adaptive tool trimming · caps long descriptions before dropping tools (keeps all when capping fits)", () => {
|
|
241
|
+
const mk = (name: string) => ({ type: "function", function: { name, description: "d".repeat(5000), parameters: {} } });
|
|
242
|
+
const oa: any = {
|
|
243
|
+
messages: [{ role: "user", content: "hi" }],
|
|
244
|
+
tools: [mk("Bash"), mk("Read"), mk("mcp__x")],
|
|
245
|
+
};
|
|
246
|
+
// Too small for 3×5000-char descs, ample for 3 capped (≤600) ones.
|
|
247
|
+
const r = gw.trimOpenAIToolsToFit(oa, 1024 + 10 + 700);
|
|
248
|
+
assert.equal(r.dropped, 0); // nothing dropped — capping alone fit
|
|
249
|
+
assert.equal(r.changed, true); // but we did change (capped), so retry
|
|
250
|
+
assert.equal(oa.tools.length, 3);
|
|
251
|
+
for (const t of oa.tools) assert.ok(t.function.description.length <= 601);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("llm-gateway adaptive tool trimming · priority: core built-ins > MCP/configured > generic long tail", () => {
|
|
255
|
+
const mk = (name: string) => ({ type: "function", function: { name, description: "d".repeat(200), parameters: {} } });
|
|
256
|
+
const web = mk("WebSearch"); // generic long tail — dropped first
|
|
257
|
+
const mcp = mk("mcp__db__query"); // operator-configured — kept over long tail
|
|
258
|
+
const bash = mk("Bash"); // core — kept first
|
|
259
|
+
const oa: any = { messages: [{ role: "user", content: "hi" }], tools: [web, mcp, bash] };
|
|
260
|
+
// Budget == exactly Bash + mcp, so the long-tail WebSearch can't fit.
|
|
261
|
+
const cBash = gw.estimateTokens(JSON.stringify(bash));
|
|
262
|
+
const cMcp = gw.estimateTokens(JSON.stringify(mcp));
|
|
263
|
+
const msgTok = gw.estimateTokens(JSON.stringify(oa.messages));
|
|
264
|
+
const r = gw.trimOpenAIToolsToFit(oa, cBash + cMcp + msgTok + 1024);
|
|
265
|
+
assert.equal(r.dropped, 1);
|
|
266
|
+
const kept = (oa.tools ?? []).map((t: any) => t.function.name);
|
|
267
|
+
assert.ok(kept.includes("Bash"));
|
|
268
|
+
assert.ok(kept.includes("mcp__db__query"));
|
|
269
|
+
assert.ok(!kept.includes("WebSearch"));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("llm-gateway adaptive tool trimming · resets a forced tool_choice to auto when that tool is trimmed away", () => {
|
|
273
|
+
const oa: any = {
|
|
274
|
+
messages: [{ role: "user", content: "hi" }],
|
|
275
|
+
tools: [
|
|
276
|
+
{ type: "function", function: { name: "Bash", description: "run a command", parameters: {} } }, // small, kept
|
|
277
|
+
{ type: "function", function: { name: "mcp__rare", description: "z".repeat(4000), parameters: {} } }, // fat, dropped
|
|
278
|
+
],
|
|
279
|
+
tool_choice: { type: "function", function: { name: "mcp__rare" } },
|
|
280
|
+
};
|
|
281
|
+
// Budget fits the small Bash tool but not the fat mcp__rare one.
|
|
282
|
+
gw.trimOpenAIToolsToFit(oa, 1024 + 10 + 120);
|
|
283
|
+
const names = (oa.tools ?? []).map((t: any) => t.function.name);
|
|
284
|
+
assert.ok(names.includes("Bash"));
|
|
285
|
+
assert.ok(!names.includes("mcp__rare"));
|
|
286
|
+
assert.equal(oa.tool_choice, "auto");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── Codex mode: Anthropic <-> OpenAI Responses API ───────────────────────
|
|
290
|
+
|
|
291
|
+
test("codex request translation (Anthropic -> Responses API) · maps system→instructions, messages→input, and forces store:false", () => {
|
|
292
|
+
const cx = gw.anthropicToCodexRequest({
|
|
293
|
+
model: "gpt-5.5",
|
|
294
|
+
system: "be terse",
|
|
295
|
+
stream: true,
|
|
296
|
+
max_tokens: 1024,
|
|
297
|
+
messages: [{ role: "user", content: "hi" }],
|
|
298
|
+
});
|
|
299
|
+
assert.equal(cx.model, "gpt-5.5");
|
|
300
|
+
assert.equal(cx.instructions, "be terse");
|
|
301
|
+
assert.equal(cx.store, false);
|
|
302
|
+
assert.equal(cx.stream, true);
|
|
303
|
+
// We deliberately do NOT request reasoning.encrypted_content (we can't carry
|
|
304
|
+
// it back through the Anthropic wire format, and requesting-then-dropping it
|
|
305
|
+
// breaks tool continuation on some backends).
|
|
306
|
+
assert.equal(cx.include, undefined);
|
|
307
|
+
// Codex rejects max_output_tokens — the SDK's max_tokens must be dropped.
|
|
308
|
+
assert.equal(cx.max_output_tokens, undefined);
|
|
309
|
+
assert.deepEqual(cx.input, [{ role: "user", content: [{ type: "input_text", text: "hi" }] }]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("codex request translation (Anthropic -> Responses API) · emits function_call / function_call_output as top-level input items", () => {
|
|
313
|
+
const input: any[] = [];
|
|
314
|
+
gw.translateMessageToResponses(
|
|
315
|
+
{ role: "assistant", content: [{ type: "text", text: "let me check" }, { type: "tool_use", id: "call_1", name: "ls", input: { path: "/" } }] },
|
|
316
|
+
input,
|
|
317
|
+
);
|
|
318
|
+
gw.translateMessageToResponses(
|
|
319
|
+
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "file.txt" }] },
|
|
320
|
+
input,
|
|
321
|
+
);
|
|
322
|
+
assert.deepEqual(input[0], { role: "assistant", content: [{ type: "output_text", text: "let me check" }] });
|
|
323
|
+
assert.deepEqual(input[1], { type: "function_call", call_id: "call_1", name: "ls", arguments: JSON.stringify({ path: "/" }) });
|
|
324
|
+
assert.deepEqual(input[2], { type: "function_call_output", call_id: "call_1", output: "file.txt" });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("codex request translation (Anthropic -> Responses API) · translates tools to the flat Responses shape", () => {
|
|
328
|
+
const cx = gw.anthropicToCodexRequest({
|
|
329
|
+
model: "gpt-5.5",
|
|
330
|
+
messages: [],
|
|
331
|
+
tools: [{ name: "ls", description: "list", input_schema: { type: "object", properties: {} } }],
|
|
332
|
+
tool_choice: { type: "tool", name: "ls" },
|
|
333
|
+
});
|
|
334
|
+
// toMatchObject equivalent: assert the subset of fields we care about.
|
|
335
|
+
assert.equal(cx.tools[0].type, "function");
|
|
336
|
+
assert.equal(cx.tools[0].name, "ls");
|
|
337
|
+
assert.equal(cx.tools[0].description, "list");
|
|
338
|
+
assert.deepEqual(cx.tool_choice, { type: "function", name: "ls" });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("codexResponsesUrl · appends /codex/responses and tolerates partial bases", () => {
|
|
342
|
+
assert.equal(gw.codexResponsesUrl("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/codex/responses");
|
|
343
|
+
assert.equal(gw.codexResponsesUrl("https://chatgpt.com/backend-api/codex"), "https://chatgpt.com/backend-api/codex/responses");
|
|
344
|
+
assert.equal(gw.codexResponsesUrl("https://x/backend-api/codex/responses"), "https://x/backend-api/codex/responses");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ─── codex streaming translation (Responses SSE -> Anthropic SSE) ─────────
|
|
348
|
+
|
|
349
|
+
// These are the REAL event shapes captured from chatgpt.com/backend-api/codex.
|
|
350
|
+
const codexStream = [
|
|
351
|
+
{ type: "response.created", response: { id: "resp_1" } },
|
|
352
|
+
{ type: "response.output_item.added", item: { id: "rs_1", type: "reasoning", encrypted_content: "xxx" } },
|
|
353
|
+
{ type: "response.output_item.added", item: { id: "msg_1", type: "message" } },
|
|
354
|
+
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "po" },
|
|
355
|
+
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "ng" },
|
|
356
|
+
{ type: "response.output_text.done", item_id: "msg_1" },
|
|
357
|
+
{ type: "response.output_item.done", item: { id: "msg_1", type: "message" } },
|
|
358
|
+
{ type: "response.completed", response: { status: "completed", usage: { input_tokens: 21, output_tokens: 17 } } },
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
test("codex streaming translation (Responses SSE -> Anthropic SSE) · drops reasoning, streams text, and closes with usage + end_turn", () => {
|
|
362
|
+
const tr = gw.makeCodexStreamTranslator("gpt-5.5");
|
|
363
|
+
const out: string[] = [];
|
|
364
|
+
for (const e of codexStream) out.push(...tr.push(e));
|
|
365
|
+
out.push(...tr.end());
|
|
366
|
+
const evts = parseSSE(out);
|
|
367
|
+
const types = evts.map((e) => e.type);
|
|
368
|
+
assert.equal(types[0], "message_start");
|
|
369
|
+
assert.ok(types.includes("content_block_start"));
|
|
370
|
+
// exactly one text block opened (reasoning dropped)
|
|
371
|
+
assert.equal(evts.filter((e) => e.type === "content_block_start").length, 1);
|
|
372
|
+
const text = evts.filter((e) => e.type === "content_block_delta").map((e) => e.delta.text).join("");
|
|
373
|
+
assert.equal(text, "pong");
|
|
374
|
+
const md = evts.find((e) => e.type === "message_delta");
|
|
375
|
+
assert.equal(md.delta.stop_reason, "end_turn");
|
|
376
|
+
assert.deepEqual(md.usage, { input_tokens: 21, output_tokens: 17 });
|
|
377
|
+
assert.equal(types.at(-1), "message_stop");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("codex streaming translation (Responses SSE -> Anthropic SSE) · maps a function call to a tool_use block with input_json_delta and tool_use stop", () => {
|
|
381
|
+
const tr = gw.makeCodexStreamTranslator("gpt-5.5");
|
|
382
|
+
const evseq = [
|
|
383
|
+
{ type: "response.created", response: { id: "r" } },
|
|
384
|
+
{ type: "response.output_item.added", item: { id: "fc_1", type: "function_call", call_id: "call_abc", name: "ls" } },
|
|
385
|
+
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"path":' },
|
|
386
|
+
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '"/"}' },
|
|
387
|
+
{ type: "response.output_item.done", item: { id: "fc_1", type: "function_call" } },
|
|
388
|
+
{ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3 } } },
|
|
389
|
+
];
|
|
390
|
+
const out: string[] = [];
|
|
391
|
+
for (const e of evseq) out.push(...tr.push(e));
|
|
392
|
+
out.push(...tr.end());
|
|
393
|
+
const evts = parseSSE(out);
|
|
394
|
+
const start = evts.find((e) => e.type === "content_block_start");
|
|
395
|
+
assert.equal(start.content_block.type, "tool_use");
|
|
396
|
+
assert.equal(start.content_block.id, "call_abc");
|
|
397
|
+
assert.equal(start.content_block.name, "ls");
|
|
398
|
+
const json = evts.filter((e) => e.type === "content_block_delta").map((e) => e.delta.partial_json).join("");
|
|
399
|
+
assert.deepEqual(JSON.parse(json), { path: "/" });
|
|
400
|
+
assert.equal(evts.find((e) => e.type === "message_delta").delta.stop_reason, "tool_use");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("codex streaming translation (Responses SSE -> Anthropic SSE) · falls back to the completed item's arguments when a tool call streams no deltas", () => {
|
|
404
|
+
const tr = gw.makeCodexStreamTranslator("gpt-5.5");
|
|
405
|
+
const out: string[] = [];
|
|
406
|
+
for (const e of [
|
|
407
|
+
{ type: "response.created", response: { id: "r" } },
|
|
408
|
+
{ type: "response.output_item.added", item: { id: "fc_2", type: "function_call", call_id: "call_x", name: "ls" } },
|
|
409
|
+
// no function_call_arguments.delta events — args only on the done item
|
|
410
|
+
{ type: "response.output_item.done", item: { id: "fc_2", type: "function_call", arguments: '{"path":"/tmp"}' } },
|
|
411
|
+
{ type: "response.completed", response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } },
|
|
412
|
+
]) out.push(...tr.push(e));
|
|
413
|
+
out.push(...tr.end());
|
|
414
|
+
const evts = parseSSE(out);
|
|
415
|
+
const json = evts.filter((e) => e.type === "content_block_delta").map((e) => e.delta.partial_json).join("");
|
|
416
|
+
assert.deepEqual(JSON.parse(json), { path: "/tmp" });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ─── foldAnthropicSSE (non-streaming collapse) ─────────────────────────────
|
|
420
|
+
|
|
421
|
+
test("foldAnthropicSSE (non-streaming collapse) · reassembles text + usage into a Messages response", () => {
|
|
422
|
+
const tr = gw.makeCodexStreamTranslator("gpt-5.5");
|
|
423
|
+
const out: string[] = [];
|
|
424
|
+
for (const e of [
|
|
425
|
+
{ type: "response.output_item.added", item: { id: "m", type: "message" } },
|
|
426
|
+
{ type: "response.output_text.delta", item_id: "m", delta: "hello" },
|
|
427
|
+
{ type: "response.output_item.done", item: { id: "m" } },
|
|
428
|
+
{ type: "response.completed", response: { status: "completed", usage: { input_tokens: 2, output_tokens: 1 } } },
|
|
429
|
+
]) out.push(...tr.push(e));
|
|
430
|
+
out.push(...tr.end());
|
|
431
|
+
const msg = gw.foldAnthropicSSE(out, "gpt-5.5");
|
|
432
|
+
assert.equal(msg.type, "message");
|
|
433
|
+
assert.deepEqual(msg.content, [{ type: "text", text: "hello" }]);
|
|
434
|
+
assert.equal(msg.stop_reason, "end_turn");
|
|
435
|
+
assert.deepEqual(msg.usage, { input_tokens: 2, output_tokens: 1 });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Foreman divergences from the upstream file
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
test('llm-gateway · folds a stray system turn into the leading one', () => {
|
|
443
|
+
// Qwen3's chat template rejects the whole request with "system message must
|
|
444
|
+
// be at the beginning" if one appears at any later index — which is what a
|
|
445
|
+
// real director hits partway through a mission. OpenAI itself is lenient,
|
|
446
|
+
// which is why upstream never needed this.
|
|
447
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
448
|
+
model: 'm', system: 'charter',
|
|
449
|
+
messages: [
|
|
450
|
+
{ role: 'user', content: 'a' },
|
|
451
|
+
{ role: 'system', content: 'injected later' },
|
|
452
|
+
{ role: 'user', content: 'b' },
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
assert.deepEqual(oa.messages.map((m: any) => m.role), ['system', 'user', 'user']);
|
|
456
|
+
assert.equal(oa.messages[0].content, 'charter\n\ninjected later');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('llm-gateway · creates a leading system turn when there was none', () => {
|
|
460
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
461
|
+
model: 'm',
|
|
462
|
+
messages: [{ role: 'user', content: 'a' }, { role: 'system', content: 'late' }],
|
|
463
|
+
});
|
|
464
|
+
assert.deepEqual(oa.messages.map((m: any) => m.role), ['system', 'user']);
|
|
465
|
+
assert.equal(oa.messages[0].content, 'late');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('llm-gateway · leaves a well-formed conversation untouched', () => {
|
|
469
|
+
const oa = gw.anthropicToOpenAIRequest({
|
|
470
|
+
model: 'm', system: 's',
|
|
471
|
+
messages: [
|
|
472
|
+
{ role: 'user', content: 'hi' },
|
|
473
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'W', input: {} }] },
|
|
474
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] },
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
assert.deepEqual(oa.messages.map((m: any) => m.role), ['system', 'user', 'assistant', 'tool']);
|
|
478
|
+
});
|