@velum-labs/routekit-gateway 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/acp-agent.d.ts +38 -0
  4. package/dist/acp-agent.js +142 -0
  5. package/dist/acp-registry.d.ts +36 -0
  6. package/dist/acp-registry.js +85 -0
  7. package/dist/adapters/anthropic.d.ts +131 -0
  8. package/dist/adapters/anthropic.js +1195 -0
  9. package/dist/adapters/chat.d.ts +14 -0
  10. package/dist/adapters/chat.js +34 -0
  11. package/dist/adapters/cursor.d.ts +34 -0
  12. package/dist/adapters/cursor.js +305 -0
  13. package/dist/adapters/dropped.d.ts +10 -0
  14. package/dist/adapters/dropped.js +24 -0
  15. package/dist/adapters/openai-chat-wire.d.ts +93 -0
  16. package/dist/adapters/openai-chat-wire.js +143 -0
  17. package/dist/adapters/responses-stream.d.ts +7 -0
  18. package/dist/adapters/responses-stream.js +597 -0
  19. package/dist/adapters/responses.d.ts +174 -0
  20. package/dist/adapters/responses.js +778 -0
  21. package/dist/adapters/server-tool-loop.d.ts +94 -0
  22. package/dist/adapters/server-tool-loop.js +477 -0
  23. package/dist/adapters/upstream-error.d.ts +14 -0
  24. package/dist/adapters/upstream-error.js +25 -0
  25. package/dist/adapters/validate.d.ts +27 -0
  26. package/dist/adapters/validate.js +180 -0
  27. package/dist/adapters/web-search.d.ts +46 -0
  28. package/dist/adapters/web-search.js +151 -0
  29. package/dist/auth.d.ts +10 -0
  30. package/dist/auth.js +28 -0
  31. package/dist/backend.d.ts +151 -0
  32. package/dist/backend.js +143 -0
  33. package/dist/capacity-pool.d.ts +31 -0
  34. package/dist/capacity-pool.js +99 -0
  35. package/dist/cost.d.ts +49 -0
  36. package/dist/cost.js +112 -0
  37. package/dist/endpoint-health.d.ts +54 -0
  38. package/dist/endpoint-health.js +123 -0
  39. package/dist/index.d.ts +40 -0
  40. package/dist/index.js +23 -0
  41. package/dist/provenance.d.ts +31 -0
  42. package/dist/provenance.js +191 -0
  43. package/dist/provider-backends.d.ts +40 -0
  44. package/dist/provider-backends.js +1050 -0
  45. package/dist/provider-source.d.ts +40 -0
  46. package/dist/provider-source.js +293 -0
  47. package/dist/router.d.ts +168 -0
  48. package/dist/router.js +474 -0
  49. package/dist/server.d.ts +67 -0
  50. package/dist/server.js +930 -0
  51. package/dist/sse/chat-assembler.d.ts +45 -0
  52. package/dist/sse/chat-assembler.js +190 -0
  53. package/dist/sse/parse.d.ts +50 -0
  54. package/dist/sse/parse.js +149 -0
  55. package/dist/sse-wire.d.ts +10 -0
  56. package/dist/sse-wire.js +31 -0
  57. package/dist/switching-proxy.d.ts +15 -0
  58. package/dist/switching-proxy.js +232 -0
  59. package/dist/test/acp-agent.test.d.ts +1 -0
  60. package/dist/test/acp-agent.test.js +66 -0
  61. package/dist/test/acp-registry.test.d.ts +1 -0
  62. package/dist/test/acp-registry.test.js +70 -0
  63. package/dist/test/anthropic.test.d.ts +1 -0
  64. package/dist/test/anthropic.test.js +793 -0
  65. package/dist/test/auth.test.d.ts +1 -0
  66. package/dist/test/auth.test.js +25 -0
  67. package/dist/test/boundary.test.d.ts +1 -0
  68. package/dist/test/boundary.test.js +32 -0
  69. package/dist/test/chat.test.d.ts +1 -0
  70. package/dist/test/chat.test.js +418 -0
  71. package/dist/test/cost.test.d.ts +1 -0
  72. package/dist/test/cost.test.js +60 -0
  73. package/dist/test/cursor.test.d.ts +1 -0
  74. package/dist/test/cursor.test.js +100 -0
  75. package/dist/test/drain.test.d.ts +1 -0
  76. package/dist/test/drain.test.js +116 -0
  77. package/dist/test/dropped.test.d.ts +1 -0
  78. package/dist/test/dropped.test.js +80 -0
  79. package/dist/test/endpoint-health.test.d.ts +1 -0
  80. package/dist/test/endpoint-health.test.js +73 -0
  81. package/dist/test/provenance.test.d.ts +1 -0
  82. package/dist/test/provenance.test.js +176 -0
  83. package/dist/test/provider-backends.test.d.ts +1 -0
  84. package/dist/test/provider-backends.test.js +699 -0
  85. package/dist/test/responses.test.d.ts +1 -0
  86. package/dist/test/responses.test.js +813 -0
  87. package/dist/test/routed-backend.test.d.ts +1 -0
  88. package/dist/test/routed-backend.test.js +39 -0
  89. package/dist/test/router.test.d.ts +1 -0
  90. package/dist/test/router.test.js +297 -0
  91. package/dist/test/server-resilience.test.d.ts +1 -0
  92. package/dist/test/server-resilience.test.js +169 -0
  93. package/dist/test/sse-codec.test.d.ts +1 -0
  94. package/dist/test/sse-codec.test.js +186 -0
  95. package/dist/test/web-search-loop.test.d.ts +1 -0
  96. package/dist/test/web-search-loop.test.js +469 -0
  97. package/dist/test/wire-validation.test.d.ts +1 -0
  98. package/dist/test/wire-validation.test.js +140 -0
  99. package/package.json +48 -0
@@ -0,0 +1,813 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { test } from "node:test";
4
+ import { OpenAiBackend } from "../backend.js";
5
+ import { MODEL_CALL_ID_HEADER } from "../provenance.js";
6
+ import { CatalogBackend } from "../router.js";
7
+ import { chatToResponses, customToolNames, openAiSseToResponses, responsesToChat, responsesToolRegistry } from "../adapters/responses.js";
8
+ import { startGateway } from "../server.js";
9
+ function sendJson(res, status, value) {
10
+ res.statusCode = status;
11
+ res.setHeader("content-type", "application/json");
12
+ res.end(Buffer.from(JSON.stringify(value), "utf8"));
13
+ }
14
+ async function readAll(req) {
15
+ const chunks = [];
16
+ for await (const chunk of req)
17
+ chunks.push(chunk);
18
+ return Buffer.concat(chunks);
19
+ }
20
+ async function startMock() {
21
+ let lastChatBody;
22
+ let lastModelCallId;
23
+ const server = createServer((req, res) => {
24
+ void (async () => {
25
+ const body = JSON.parse((await readAll(req)).toString("utf8"));
26
+ lastChatBody = body;
27
+ lastModelCallId =
28
+ typeof req.headers[MODEL_CALL_ID_HEADER] === "string"
29
+ ? req.headers[MODEL_CALL_ID_HEADER]
30
+ : undefined;
31
+ if (body.stream === true) {
32
+ res.statusCode = 200;
33
+ res.setHeader("content-type", "text/event-stream");
34
+ res.write('data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}]}\n\n');
35
+ res.write('data: {"choices":[{"delta":{"content":" there"},"finish_reason":null}]}\n\n');
36
+ res.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}\n\n');
37
+ res.write("data: [DONE]\n\n");
38
+ res.end();
39
+ return;
40
+ }
41
+ sendJson(res, 200, {
42
+ id: "cmpl-2",
43
+ object: "chat.completion",
44
+ model: body.model,
45
+ choices: [{ index: 0, message: { role: "assistant", content: "Final answer" }, finish_reason: "stop" }],
46
+ usage: { prompt_tokens: 6, completion_tokens: 2 }
47
+ });
48
+ })();
49
+ });
50
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
51
+ const address = server.address();
52
+ const port = typeof address === "object" && address !== null ? address.port : 0;
53
+ return {
54
+ url: `http://127.0.0.1:${port}`,
55
+ lastChatBody: () => lastChatBody,
56
+ lastModelCallId: () => lastModelCallId,
57
+ close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
58
+ };
59
+ }
60
+ test("responsesToChat maps instructions, input items, and function output", () => {
61
+ const chat = responsesToChat({
62
+ model: "gpt-x",
63
+ instructions: "be terse",
64
+ input: [
65
+ { type: "message", role: "user", content: "search please" },
66
+ { type: "function_call", call_id: "call_1", name: "search", arguments: '{"q":"x"}' },
67
+ { type: "function_call_output", call_id: "call_1", output: "found" }
68
+ ],
69
+ tools: [{ type: "function", name: "search", description: "find", parameters: { type: "object" } }]
70
+ }, "local-model");
71
+ const messages = chat.messages;
72
+ assert.equal(chat.model, "local-model");
73
+ assert.equal(messages[0]?.role, "system");
74
+ assert.equal(messages[1]?.role, "user");
75
+ assert.equal(messages[2]?.role, "assistant");
76
+ assert.ok(Array.isArray(messages[2].tool_calls));
77
+ assert.equal(messages[3]?.role, "tool");
78
+ assert.equal(messages[3].tool_call_id, "call_1");
79
+ const tools = chat.tools;
80
+ assert.equal(tools[0]?.function.name, "search");
81
+ });
82
+ test("responsesToChat coalesces parallel function calls into one assistant message", () => {
83
+ // Codex emits parallel tool calls as separate function_call items; they must
84
+ // become a single assistant message so the following tool messages answer it
85
+ // (the chat API rejects an assistant tool_calls message that is not directly
86
+ // followed by tool responses for each tool_call_id).
87
+ const chat = responsesToChat({
88
+ input: [
89
+ { type: "message", role: "user", content: "fix it" },
90
+ { type: "function_call", call_id: "call_a", name: "read_file", arguments: '{"path":"a.js"}' },
91
+ { type: "function_call", call_id: "call_b", name: "read_file", arguments: '{"path":"b.js"}' },
92
+ { type: "function_call_output", call_id: "call_a", output: "A" },
93
+ { type: "function_call_output", call_id: "call_b", output: "B" }
94
+ ]
95
+ }, "local-model");
96
+ const messages = chat.messages;
97
+ // user, assistant(tool_calls:[a,b]), tool(a), tool(b)
98
+ assert.equal(messages.length, 4);
99
+ assert.equal(messages[1]?.role, "assistant");
100
+ const toolCalls = messages[1].tool_calls ?? [];
101
+ assert.equal(toolCalls.length, 2);
102
+ assert.deepEqual(toolCalls.map((call) => call.id), ["call_a", "call_b"]);
103
+ assert.equal(messages[2]?.role, "tool");
104
+ assert.equal(messages[2].tool_call_id, "call_a");
105
+ assert.equal(messages[3]?.role, "tool");
106
+ assert.equal(messages[3].tool_call_id, "call_b");
107
+ });
108
+ test("responsesToChat folds an assistant text item and its following function calls into one message", () => {
109
+ // A model that answers with text + tool calls in a single turn comes back
110
+ // from Codex as a message item followed by function_call items (with the
111
+ // echoed reasoning item in between). Replaying them as two assistant
112
+ // messages derails tool-calling models (qwen3-coder stops mid-task with a
113
+ // text-only "Now let me check X:" turn), so they must merge back into one.
114
+ const chat = responsesToChat({
115
+ input: [
116
+ { type: "message", role: "user", content: "what's in this repo?" },
117
+ { type: "message", role: "assistant", content: "Let me check the README:\n\n" },
118
+ { type: "reasoning", summary: [{ type: "summary_text", text: "beat" }] },
119
+ { type: "function_call", call_id: "call_1", name: "exec_command", arguments: '{"cmd":"cat README.md"}' },
120
+ { type: "function_call_output", call_id: "call_1", output: "# RouteKit" }
121
+ ]
122
+ }, "local-model");
123
+ const messages = chat.messages;
124
+ // user, assistant(content + tool_calls), tool — NOT a separate tool_calls message.
125
+ assert.equal(messages.length, 3);
126
+ assert.equal(messages[1]?.role, "assistant");
127
+ assert.equal(messages[1]?.content, "Let me check the README:\n\n");
128
+ const toolCalls = messages[1].tool_calls ?? [];
129
+ assert.equal(toolCalls.length, 1);
130
+ assert.equal(toolCalls[0]?.id, "call_1");
131
+ assert.equal(messages[2]?.role, "tool");
132
+ });
133
+ test("responsesToChat does not fold function calls into a non-adjacent assistant message", () => {
134
+ // An earlier assistant answer separated from the calls by a user turn must
135
+ // stay text-only; the calls get their own assistant message in position.
136
+ const chat = responsesToChat({
137
+ input: [
138
+ { type: "message", role: "user", content: "hi" },
139
+ { type: "message", role: "assistant", content: "Done." },
140
+ { type: "message", role: "user", content: "now run ls" },
141
+ { type: "function_call", call_id: "call_2", name: "exec_command", arguments: '{"cmd":"ls"}' },
142
+ { type: "function_call_output", call_id: "call_2", output: "files" }
143
+ ]
144
+ }, "local-model");
145
+ const messages = chat.messages;
146
+ // user, assistant(text), user, assistant(tool_calls), tool
147
+ assert.equal(messages.length, 5);
148
+ assert.equal(messages[1].tool_calls, undefined);
149
+ assert.equal(messages[3]?.role, "assistant");
150
+ assert.equal(messages[3]?.content, null);
151
+ const toolCalls = messages[3].tool_calls ?? [];
152
+ assert.equal(toolCalls[0]?.id, "call_2");
153
+ });
154
+ test("responsesToChat tolerates reasoning: null and text: null (Codex custom-provider slugs)", () => {
155
+ // Regression (ENG-615): Codex serializes `reasoning: null` for any model
156
+ // slug it cannot resolve to reasoning metadata — which includes custom
157
+ // endpoints routed through a compatible gateway (e.g. `grok-4`, `deepseek`).
158
+ // The adapter used to dereference it (`Cannot read properties of null
159
+ // (reading 'effort')`), turning EVERY member request into a 502 and the
160
+ // whole custom-endpoint response into an `exit_error`.
161
+ const chat = responsesToChat({ model: "grok-4", input: "say OK", reasoning: null, text: null, stream: true }, "grok-4");
162
+ assert.equal(chat.model, "grok-4");
163
+ assert.equal(chat.reasoning_effort, undefined);
164
+ assert.equal(chat.response_format, undefined);
165
+ });
166
+ test("responsesToChat treats Codex reasoning effort null as absent", () => {
167
+ const chat = responsesToChat({ model: "gpt-5.5", input: "say OK", reasoning: { effort: null } }, "gpt-5.5");
168
+ assert.equal(chat.reasoning_effort, undefined);
169
+ });
170
+ test("responsesToChat still maps a real reasoning effort", () => {
171
+ const chat = responsesToChat({ model: "gpt-5.5", input: "say OK", reasoning: { effort: "medium" } }, "gpt-5.5");
172
+ assert.equal(chat.reasoning_effort, "medium");
173
+ });
174
+ test("responsesToChat treats Codex's explicit null fields as absent", () => {
175
+ // Codex sends `"reasoning": null` (and can null other optional fields) when
176
+ // the selected model's metadata advertises no reasoning levels — the default
177
+ // for a custom-provider model. Reading `.effort` off
178
+ // that null used to throw, turning every custom-provider Codex turn into a 502 (and
179
+ // leaving the --observe dashboard empty because no turn ever ran).
180
+ const chat = responsesToChat({
181
+ model: "route-primary",
182
+ input: "say hi",
183
+ reasoning: null,
184
+ text: null,
185
+ tool_choice: null,
186
+ metadata: null,
187
+ previous_response_id: null,
188
+ include: []
189
+ }, "local-model");
190
+ assert.equal(chat.model, "local-model");
191
+ assert.deepEqual(chat.messages, [{ role: "user", content: "say hi" }]);
192
+ assert.equal(chat.reasoning_effort, undefined);
193
+ assert.equal(chat.response_format, undefined);
194
+ assert.equal(chat.tool_choice, undefined);
195
+ });
196
+ test("serves a Responses request carrying reasoning: null end to end", async () => {
197
+ // The member capture gateway path: codex exec -> /v1/responses with
198
+ // `reasoning: null` -> chat completion upstream. Must be a 200, never a 502.
199
+ const mock = await startMock();
200
+ const gateway = await startGateway({
201
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "grok-4" })
202
+ });
203
+ try {
204
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
205
+ method: "POST",
206
+ headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({
208
+ model: "grok-4",
209
+ input: "say OK",
210
+ reasoning: null,
211
+ include: [],
212
+ store: false,
213
+ stream: false
214
+ })
215
+ });
216
+ assert.equal(response.status, 200);
217
+ const json = (await response.json());
218
+ assert.equal(json.status, "completed");
219
+ assert.equal(json.output[0]?.content?.[0]?.text, "Final answer");
220
+ assert.equal(mock.lastChatBody()?.reasoning_effort, undefined);
221
+ }
222
+ finally {
223
+ await gateway.close();
224
+ await mock.close();
225
+ }
226
+ });
227
+ test("serves a Responses request with null optional fields end to end", async () => {
228
+ const mock = await startMock();
229
+ const gateway = await startGateway({
230
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
231
+ });
232
+ try {
233
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
234
+ method: "POST",
235
+ headers: { "content-type": "application/json" },
236
+ body: JSON.stringify({
237
+ model: "route-primary",
238
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }],
239
+ reasoning: null,
240
+ text: null,
241
+ tool_choice: "auto",
242
+ parallel_tool_calls: false,
243
+ store: false,
244
+ include: []
245
+ })
246
+ });
247
+ assert.equal(response.status, 200);
248
+ const json = (await response.json());
249
+ assert.equal(json.object, "response");
250
+ assert.equal(json.status, "completed");
251
+ }
252
+ finally {
253
+ await gateway.close();
254
+ await mock.close();
255
+ }
256
+ });
257
+ test("serves a non-streaming Responses object end to end", async () => {
258
+ const mock = await startMock();
259
+ const gateway = await startGateway({
260
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
261
+ });
262
+ try {
263
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify({ model: "gpt-x", input: "hello" })
267
+ });
268
+ assert.equal(response.status, 200);
269
+ assert.equal(mock.lastModelCallId(), response.headers.get(MODEL_CALL_ID_HEADER));
270
+ const json = (await response.json());
271
+ assert.equal(json.object, "response");
272
+ assert.equal(json.status, "completed");
273
+ assert.equal(json.output[0]?.type, "message");
274
+ assert.equal(json.output[0]?.content?.[0]?.text, "Final answer");
275
+ assert.equal(json.usage.output_tokens, 2);
276
+ assert.equal(mock.lastChatBody()?.model, "local-model");
277
+ }
278
+ finally {
279
+ await gateway.close();
280
+ await mock.close();
281
+ }
282
+ });
283
+ // ---- custom (freeform) tool round-trip: Codex apply_patch ----
284
+ const PATCH = "*** Begin Patch\n*** Update File: a.md\n@@\n-old\n+new\n*** End Patch\n";
285
+ function sseStream(...chunks) {
286
+ const encoder = new TextEncoder();
287
+ return new ReadableStream({
288
+ start(controller) {
289
+ for (const chunk of chunks)
290
+ controller.enqueue(encoder.encode(chunk));
291
+ controller.close();
292
+ }
293
+ });
294
+ }
295
+ function chatChunk(delta, finish = null) {
296
+ return `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;
297
+ }
298
+ test("responsesToChat forwards a custom tool as a function tool with an {input} schema", () => {
299
+ const body = {
300
+ input: "patch something",
301
+ tools: [
302
+ {
303
+ type: "custom",
304
+ name: "apply_patch",
305
+ description: "Use this to edit files.",
306
+ format: { type: "grammar", syntax: "lark", definition: "start: PATCH" }
307
+ },
308
+ { type: "function", name: "shell", parameters: { type: "object", properties: { cmd: {} } } }
309
+ ]
310
+ };
311
+ assert.deepEqual([...customToolNames(body)], ["apply_patch"]);
312
+ const chat = responsesToChat(body, "local-model");
313
+ const tools = chat.tools;
314
+ assert.equal(tools.length, 2);
315
+ const patch = tools[0]?.function;
316
+ assert.equal(patch?.name, "apply_patch");
317
+ const properties = patch?.parameters.properties;
318
+ assert.equal(properties.input?.type, "string");
319
+ assert.deepEqual(patch?.parameters.required, ["input"]);
320
+ // The freeform contract and the grammar are folded into the description.
321
+ assert.match(patch?.description ?? "", /Use this to edit files\./);
322
+ assert.match(patch?.description ?? "", /"input" field/);
323
+ assert.match(patch?.description ?? "", /start: PATCH/);
324
+ // The plain function tool keeps its own schema untouched.
325
+ assert.deepEqual(tools[1]?.function.parameters, { type: "object", properties: { cmd: {} } });
326
+ });
327
+ test("responsesToChat maps echoed custom_tool_call / custom_tool_call_output items into chat history", () => {
328
+ const chat = responsesToChat({
329
+ input: [
330
+ { type: "message", role: "user", content: "apply the patch" },
331
+ { type: "custom_tool_call", call_id: "call_p", name: "apply_patch", input: PATCH },
332
+ { type: "custom_tool_call_output", call_id: "call_p", output: "Done" }
333
+ ]
334
+ }, "local-model");
335
+ const messages = chat.messages;
336
+ assert.equal(messages.length, 3);
337
+ assert.equal(messages[1]?.role, "assistant");
338
+ const toolCalls = messages[1]
339
+ .tool_calls ?? [];
340
+ assert.equal(toolCalls.length, 1);
341
+ assert.equal(toolCalls[0]?.id, "call_p");
342
+ assert.equal(toolCalls[0]?.function.name, "apply_patch");
343
+ assert.deepEqual(JSON.parse(toolCalls[0]?.function.arguments ?? ""), { input: PATCH });
344
+ assert.equal(messages[2]?.role, "tool");
345
+ assert.equal(messages[2].tool_call_id, "call_p");
346
+ assert.equal(messages[2]?.content, "Done");
347
+ });
348
+ test("chatToResponses emits a custom_tool_call item with raw input for a custom-declared tool", () => {
349
+ const custom = new Map([["apply_patch", { kind: "custom" }]]);
350
+ const openai = {
351
+ id: "cmpl-3",
352
+ choices: [
353
+ {
354
+ message: {
355
+ content: null,
356
+ tool_calls: [
357
+ { id: "call_p", function: { name: "apply_patch", arguments: JSON.stringify({ input: PATCH }) } },
358
+ { id: "call_s", function: { name: "shell", arguments: '{"cmd":"ls"}' } }
359
+ ]
360
+ }
361
+ }
362
+ ]
363
+ };
364
+ const response = chatToResponses(openai, "route-primary", custom);
365
+ const output = response.output;
366
+ assert.equal(output.length, 2);
367
+ assert.equal(output[0]?.type, "custom_tool_call");
368
+ assert.equal(output[0]?.call_id, "call_p");
369
+ assert.equal(output[0]?.name, "apply_patch");
370
+ assert.equal(output[0]?.input, PATCH);
371
+ assert.equal(output[1]?.type, "function_call");
372
+ assert.equal(output[1]?.arguments, '{"cmd":"ls"}');
373
+ });
374
+ test("chatToResponses preserves provider cost metadata", () => {
375
+ const response = chatToResponses({
376
+ id: "cmpl-cost",
377
+ choices: [{ message: { content: "ok" } }],
378
+ usage: { prompt_tokens: 3, completion_tokens: 2 },
379
+ provider_cost: {
380
+ source: "provider",
381
+ cost_usd: 0.0042,
382
+ generation_id: "gen_test"
383
+ }
384
+ }, "route-primary");
385
+ assert.deepEqual(response.provider_cost, {
386
+ source: "provider",
387
+ cost_usd: 0.0042,
388
+ generation_id: "gen_test"
389
+ });
390
+ });
391
+ test("chatToResponses passes non-JSON custom tool arguments through as raw input", () => {
392
+ const openai = {
393
+ choices: [
394
+ {
395
+ message: {
396
+ content: null,
397
+ tool_calls: [{ id: "call_p", function: { name: "apply_patch", arguments: PATCH } }]
398
+ }
399
+ }
400
+ ]
401
+ };
402
+ const response = chatToResponses(openai, "route-primary", new Map([["apply_patch", { kind: "custom" }]]));
403
+ const output = response.output;
404
+ assert.equal(output[0]?.type, "custom_tool_call");
405
+ assert.equal(output[0]?.input, PATCH);
406
+ });
407
+ test("openAiSseToResponses streams a custom tool call as custom_tool_call events", async () => {
408
+ const args = JSON.stringify({ input: PATCH });
409
+ const upstream = sseStream(chatChunk({ tool_calls: [{ index: 0, id: "call_p", function: { name: "apply_patch", arguments: args.slice(0, 12) } }] }), chatChunk({ tool_calls: [{ index: 0, function: { arguments: args.slice(12) } }] }), chatChunk({}, "tool_calls"), "data: [DONE]\n\n");
410
+ const text = await new Response(openAiSseToResponses(upstream, "route-primary", new Map([["apply_patch", { kind: "custom" }]]))).text();
411
+ assert.ok(text.includes('"type":"custom_tool_call"'));
412
+ assert.ok(text.includes("event: response.custom_tool_call_input.delta"));
413
+ assert.ok(text.includes("event: response.custom_tool_call_input.done"));
414
+ // The raw patch text (not the JSON wrapper) is what reaches the caller.
415
+ assert.ok(text.includes(JSON.stringify(PATCH).slice(1, -1)));
416
+ assert.ok(!text.includes("response.function_call_arguments"), "custom calls emit no function-call argument events");
417
+ // The terminal response object carries the completed custom_tool_call item.
418
+ const completed = text
419
+ .split("\n\n")
420
+ .find((event) => event.startsWith("event: response.completed"));
421
+ assert.ok(completed !== undefined);
422
+ const payload = JSON.parse(completed.slice(completed.indexOf("data:") + 5));
423
+ const item = payload.response.output.find((entry) => entry.type === "custom_tool_call");
424
+ assert.equal(item?.name, "apply_patch");
425
+ assert.equal(item?.input, PATCH);
426
+ });
427
+ // Typed (nameless) tool declarations, verbatim shapes from Codex 0.142:
428
+ // `tool_search` is client-executed discovery; `web_search` is server-executed.
429
+ const TOOL_SEARCH_DECL = {
430
+ type: "tool_search",
431
+ execution: "client",
432
+ description: "Searches over deferred tool metadata.",
433
+ parameters: {
434
+ type: "object",
435
+ properties: { query: { type: "string" }, limit: { type: "number" } },
436
+ required: ["query"]
437
+ }
438
+ };
439
+ const WEB_SEARCH_DECL = { type: "web_search", external_web_access: false };
440
+ test("responsesToolRegistry classifies function, custom, and client-typed tools", () => {
441
+ const registry = responsesToolRegistry({
442
+ tools: [
443
+ { type: "function", name: "shell", parameters: {} },
444
+ { type: "custom", name: "apply_patch" },
445
+ TOOL_SEARCH_DECL,
446
+ WEB_SEARCH_DECL
447
+ ]
448
+ });
449
+ assert.equal(registry.get("shell")?.kind, "function");
450
+ assert.equal(registry.get("apply_patch")?.kind, "custom");
451
+ assert.equal(registry.get("tool_search")?.kind, "typed");
452
+ // Server-executed typed tools are not callable through the gateway.
453
+ assert.equal(registry.has("web_search"), false);
454
+ });
455
+ test("responsesToChat projects a client-typed tool under its type and excludes server-typed tools", () => {
456
+ const chat = responsesToChat({
457
+ input: "find tools",
458
+ tools: [{ type: "function", name: "shell", parameters: {} }, TOOL_SEARCH_DECL, WEB_SEARCH_DECL]
459
+ }, "local-model");
460
+ const tools = chat.tools;
461
+ assert.deepEqual(tools.map((tool) => tool.function.name), ["shell", "tool_search"]);
462
+ assert.equal(tools[1]?.function.description, TOOL_SEARCH_DECL.description);
463
+ assert.deepEqual(tools[1]?.function.parameters, TOOL_SEARCH_DECL.parameters);
464
+ });
465
+ test("responsesToChat resolves a typed tool_choice to the projected function name", () => {
466
+ const chat = responsesToChat({ input: "x", tools: [TOOL_SEARCH_DECL], tool_choice: { type: "tool_search" } }, "local-model");
467
+ assert.deepEqual(chat.tool_choice, { type: "function", function: { name: "tool_search" } });
468
+ });
469
+ test("responsesToChat replays echoed typed call/output items into chat history", () => {
470
+ const args = { query: "spawn sub-agent", limit: 8 };
471
+ const discovered = [{ type: "namespace", name: "multi_agent_v1", tools: [{ name: "spawn_agent" }] }];
472
+ const chat = responsesToChat({
473
+ input: [
474
+ { type: "message", role: "user", content: "spawn a sub-agent" },
475
+ {
476
+ type: "tool_search_call",
477
+ call_id: "call_ts",
478
+ status: "completed",
479
+ execution: "client",
480
+ arguments: args
481
+ },
482
+ { type: "tool_search_output", call_id: "call_ts", status: "completed", execution: "client", tools: discovered }
483
+ ]
484
+ }, "local-model");
485
+ const messages = chat.messages;
486
+ assert.equal(messages.length, 3);
487
+ const toolCalls = messages[1]
488
+ .tool_calls ?? [];
489
+ assert.equal(toolCalls[0]?.id, "call_ts");
490
+ assert.equal(toolCalls[0]?.function.name, "tool_search");
491
+ assert.deepEqual(JSON.parse(toolCalls[0]?.function.arguments ?? ""), args);
492
+ assert.equal(messages[2]?.role, "tool");
493
+ assert.equal(messages[2].tool_call_id, "call_ts");
494
+ const result = JSON.parse(String(messages[2]?.content));
495
+ assert.deepEqual(result.tools, discovered);
496
+ });
497
+ test("chatToResponses emits a native typed item for a call resolved as typed", () => {
498
+ const registry = responsesToolRegistry({ tools: [TOOL_SEARCH_DECL] });
499
+ const openai = {
500
+ choices: [
501
+ {
502
+ message: {
503
+ content: null,
504
+ tool_calls: [{ id: "call_ts", function: { name: "tool_search", arguments: '{"query":"spawn","limit":4}' } }]
505
+ }
506
+ }
507
+ ]
508
+ };
509
+ const response = chatToResponses(openai, "route-primary", registry);
510
+ const output = response.output;
511
+ assert.equal(output.length, 1);
512
+ assert.equal(output[0]?.type, "tool_search_call");
513
+ assert.equal(output[0]?.call_id, "call_ts");
514
+ assert.equal(output[0]?.execution, "client");
515
+ assert.equal(output[0]?.status, "completed");
516
+ // Native typed items carry arguments as a JSON value, not a string.
517
+ assert.deepEqual(output[0]?.arguments, { query: "spawn", limit: 4 });
518
+ });
519
+ test("openAiSseToResponses streams a typed tool call as its native item", async () => {
520
+ const registry = responsesToolRegistry({ tools: [TOOL_SEARCH_DECL] });
521
+ const args = '{"query":"spawn sub-agent","limit":8}';
522
+ const upstream = sseStream(chatChunk({ tool_calls: [{ index: 0, id: "call_ts", function: { name: "tool_search", arguments: args.slice(0, 10) } }] }), chatChunk({ tool_calls: [{ index: 0, function: { arguments: args.slice(10) } }] }), chatChunk({}, "tool_calls"), "data: [DONE]\n\n");
523
+ const text = await new Response(openAiSseToResponses(upstream, "route-primary", registry)).text();
524
+ assert.ok(text.includes('"type":"tool_search_call"'));
525
+ assert.ok(!text.includes('"type":"function_call"'), "typed calls never surface as function_call items");
526
+ assert.ok(!text.includes("response.function_call_arguments"), "typed calls emit no argument delta events");
527
+ const completed = text.split("\n\n").find((event) => event.startsWith("event: response.completed"));
528
+ assert.ok(completed !== undefined);
529
+ const payload = JSON.parse(completed.slice(completed.indexOf("data:") + 5));
530
+ const item = payload.response.output.find((entry) => entry.type === "tool_search_call");
531
+ assert.equal(item?.call_id, "call_ts");
532
+ assert.equal(item?.execution, "client");
533
+ assert.deepEqual(item?.arguments, { query: "spawn sub-agent", limit: 8 });
534
+ });
535
+ test("openAiSseToResponses keeps function tools on the incremental function_call path", async () => {
536
+ const upstream = sseStream(chatChunk({ tool_calls: [{ index: 0, id: "call_s", function: { name: "shell", arguments: '{"cmd":"ls"}' } }] }), chatChunk({}, "tool_calls"), "data: [DONE]\n\n");
537
+ const text = await new Response(openAiSseToResponses(upstream, "route-primary", new Map([["apply_patch", { kind: "custom" }]]))).text();
538
+ assert.ok(text.includes('"type":"function_call"'));
539
+ assert.ok(text.includes("event: response.function_call_arguments.delta"));
540
+ assert.ok(!text.includes("custom_tool_call"));
541
+ });
542
+ test("a mid-stream provider error event becomes response.failed with the upstream message", async () => {
543
+ // The router surfaces a classified provider failure as an OpenAI-style
544
+ // `data: {"error": {...}}` SSE event. The Responses translation must carry
545
+ // that message to the consumer (codex shows it verbatim) instead of ending
546
+ // the stream as a bare disconnect.
547
+ const stream = openAiSseToResponses(sseStream(`data: ${JSON.stringify({
548
+ error: {
549
+ message: "openrouter call failed (unknown); see the server logs for the provider's message",
550
+ type: "provider_error",
551
+ code: "unknown"
552
+ }
553
+ })}\n\n`, "data: [DONE]\n\n"), "grok-4");
554
+ const text = await new Response(stream).text();
555
+ assert.ok(text.includes("event: response.failed"));
556
+ assert.ok(text.includes("openrouter call failed (unknown)"));
557
+ assert.ok(!text.includes("event: response.completed"));
558
+ });
559
+ test("translates a streamed Responses event sequence", async () => {
560
+ const mock = await startMock();
561
+ const gateway = await startGateway({
562
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
563
+ });
564
+ try {
565
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
566
+ method: "POST",
567
+ headers: { "content-type": "application/json" },
568
+ body: JSON.stringify({ model: "gpt-x", stream: true, input: "hello" })
569
+ });
570
+ assert.equal(response.status, 200);
571
+ assert.equal(response.headers.get("content-type"), "text/event-stream");
572
+ const text = await response.text();
573
+ assert.ok(text.includes("event: response.created"));
574
+ assert.ok(text.includes("event: response.output_item.added"));
575
+ assert.ok(text.includes('event: response.output_text.delta'));
576
+ assert.ok(text.includes('"delta":"Hi"'));
577
+ assert.ok(text.includes("event: response.completed"));
578
+ }
579
+ finally {
580
+ await gateway.close();
581
+ await mock.close();
582
+ }
583
+ });
584
+ test("Codex picker aliases use the canonical catalog and pooled native relay", async () => {
585
+ const sourceCalls = [];
586
+ const sourceBodies = [];
587
+ const source = (sourceId) => ({
588
+ sourceId,
589
+ discoverModels: async () => [
590
+ {
591
+ id: sourceId === "codex"
592
+ ? "gpt-5.5"
593
+ : "claude-sonnet-4-6"
594
+ }
595
+ ],
596
+ chat: async (body) => {
597
+ const request = body;
598
+ sourceCalls.push(request.model);
599
+ sourceBodies.push(request);
600
+ return Response.json({
601
+ id: "chatcmpl_cross_provider",
602
+ choices: [
603
+ {
604
+ index: 0,
605
+ message: { role: "assistant", content: "CROSS_PROVIDER_OK" },
606
+ finish_reason: "stop"
607
+ }
608
+ ],
609
+ usage: { prompt_tokens: 1, completion_tokens: 1 }
610
+ });
611
+ },
612
+ embeddings: async () => Response.json({})
613
+ });
614
+ const backend = await CatalogBackend.create({
615
+ config: {
616
+ providers: { codex: {}, "claude-code": {} },
617
+ defaultModel: "codex/gpt-5.5"
618
+ },
619
+ sources: {
620
+ codex: source("codex"),
621
+ "claude-code": source("claude-code")
622
+ }
623
+ });
624
+ const relayedBodies = [];
625
+ const relay = {
626
+ dialect: "codex",
627
+ shouldRelay: () => false,
628
+ relay: async (_headers, body) => {
629
+ relayedBodies.push(body);
630
+ return Response.json({
631
+ id: "resp_native",
632
+ object: "response",
633
+ status: "completed",
634
+ model: body.model,
635
+ output: [],
636
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
637
+ });
638
+ },
639
+ mergedCatalog: async () => ({
640
+ models: [
641
+ {
642
+ slug: "gpt-5.5",
643
+ display_name: "GPT-5.5",
644
+ description: "Native Codex model",
645
+ visibility: "list",
646
+ priority: 7
647
+ }
648
+ ],
649
+ etag: 'W/"upstream-catalog"'
650
+ })
651
+ };
652
+ const gateway = await startGateway({
653
+ backend,
654
+ providerRelays: { codex: relay }
655
+ });
656
+ try {
657
+ const catalogResponse = await fetch(`${gateway.url()}/v1/models?client_version=1.0.0`);
658
+ assert.equal(catalogResponse.headers.get("etag"), null, "a projected managed catalog must not reuse the upstream ETag");
659
+ const catalog = (await catalogResponse.json());
660
+ assert.deepEqual(catalog.data.map((model) => model.id), ["codex/gpt-5.5", "claude-code/claude-sonnet-4-6"]);
661
+ assert.deepEqual(catalog.models.map(({ slug, display_name }) => [slug, display_name]), [
662
+ ["gpt-5.5", "GPT-5.5"],
663
+ [
664
+ "claude-code/claude-sonnet-4-6",
665
+ "claude-code/claude-sonnet-4-6"
666
+ ]
667
+ ]);
668
+ for (const model of ["gpt-5.5", "codex/gpt-5.5"]) {
669
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
670
+ method: "POST",
671
+ headers: { "content-type": "application/json" },
672
+ body: JSON.stringify({
673
+ model,
674
+ instructions: "You are Codex, a coding agent based on GPT-5.",
675
+ input: "hi",
676
+ store: false,
677
+ reasoning: { effort: "high" }
678
+ })
679
+ });
680
+ assert.equal(response.status, 200);
681
+ assert.equal((await response.json()).model, "gpt-5.5");
682
+ }
683
+ assert.deepEqual(relayedBodies.map((body) => body.model), ["gpt-5.5", "gpt-5.5"]);
684
+ assert.ok(relayedBodies.every((body) => body.store === false));
685
+ assert.ok(relayedBodies.every((body) => body.reasoning?.effort ===
686
+ "high"));
687
+ assert.ok(relayedBodies.every((body) => body.instructions ===
688
+ "You are Codex, a coding agent based on GPT-5."), "native Codex routes preserve the stock instructions verbatim");
689
+ assert.deepEqual(sourceCalls, []);
690
+ const foreign = await fetch(`${gateway.url()}/v1/responses`, {
691
+ method: "POST",
692
+ headers: { "content-type": "application/json" },
693
+ body: JSON.stringify({
694
+ model: "claude-code/claude-sonnet-4-6",
695
+ instructions: "You are Codex, a coding agent based on GPT-5.",
696
+ input: "hi"
697
+ })
698
+ });
699
+ assert.equal(foreign.status, 200);
700
+ assert.deepEqual(sourceCalls, ["claude-sonnet-4-6"]);
701
+ assert.ok(!JSON.stringify(sourceBodies[0]?.messages).includes("based on GPT-5"), "a stale startup-model identity must not cross into a foreign provider");
702
+ const unknown = await fetch(`${gateway.url()}/v1/responses`, {
703
+ method: "POST",
704
+ headers: { "content-type": "application/json" },
705
+ body: JSON.stringify({ model: "gpt-not-real", input: "hi" })
706
+ });
707
+ assert.equal(unknown.status, 400);
708
+ assert.match(await unknown.text(), /unknown model/);
709
+ assert.deepEqual(relayedBodies.map((body) => body.model), ["gpt-5.5", "gpt-5.5"]);
710
+ }
711
+ finally {
712
+ await gateway.close();
713
+ }
714
+ });
715
+ async function codexAliasBackend(sourceCalls) {
716
+ return await CatalogBackend.create({
717
+ config: {
718
+ providers: { codex: {} },
719
+ defaultModel: "codex/matrix-codex"
720
+ },
721
+ sources: {
722
+ codex: {
723
+ sourceId: "codex",
724
+ discoverModels: async () => [{ id: "matrix-codex" }],
725
+ chat: async (body) => {
726
+ sourceCalls.push(body.model);
727
+ return Response.json({
728
+ id: "chatcmpl_codex_alias",
729
+ choices: [
730
+ {
731
+ index: 0,
732
+ message: { role: "assistant", content: "CODEX_ALIAS_OK" },
733
+ finish_reason: "stop"
734
+ }
735
+ ],
736
+ usage: { prompt_tokens: 1, completion_tokens: 1 }
737
+ });
738
+ },
739
+ embeddings: async () => Response.json({})
740
+ }
741
+ }
742
+ });
743
+ }
744
+ test("Codex native picker alias routes through the catalog without a managed relay", async () => {
745
+ const sourceCalls = [];
746
+ const backend = await codexAliasBackend(sourceCalls);
747
+ const gateway = await startGateway({ backend });
748
+ try {
749
+ const catalogResponse = await fetch(`${gateway.url()}/v1/models`);
750
+ assert.equal(catalogResponse.status, 200);
751
+ const catalog = (await catalogResponse.json());
752
+ assert.deepEqual(catalog.models.map((model) => model.slug), ["matrix-codex"]);
753
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
754
+ method: "POST",
755
+ headers: { "content-type": "application/json" },
756
+ body: JSON.stringify({ model: "matrix-codex", input: "hi" })
757
+ });
758
+ assert.equal(response.status, 200);
759
+ assert.deepEqual(sourceCalls, ["matrix-codex"]);
760
+ const unknown = await fetch(`${gateway.url()}/v1/responses`, {
761
+ method: "POST",
762
+ headers: { "content-type": "application/json" },
763
+ body: JSON.stringify({ model: "not-in-catalog", input: "hi" })
764
+ });
765
+ assert.equal(unknown.status, 400);
766
+ assert.deepEqual(sourceCalls, ["matrix-codex"]);
767
+ }
768
+ finally {
769
+ await gateway.close();
770
+ }
771
+ });
772
+ test("Codex client relay still receives unknown native models after alias resolution", async () => {
773
+ const sourceCalls = [];
774
+ const backend = await codexAliasBackend(sourceCalls);
775
+ const relayCalls = [];
776
+ const relay = {
777
+ dialect: "codex",
778
+ shouldRelay: () => true,
779
+ relay: async (_headers, body) => {
780
+ relayCalls.push(body.model);
781
+ return Response.json({
782
+ id: "resp_client_relay",
783
+ object: "response",
784
+ status: "completed",
785
+ model: body.model,
786
+ output: [],
787
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
788
+ });
789
+ }
790
+ };
791
+ const gateway = await startGateway({ backend, codexRelay: relay });
792
+ try {
793
+ const managed = await fetch(`${gateway.url()}/v1/responses`, {
794
+ method: "POST",
795
+ headers: { "content-type": "application/json" },
796
+ body: JSON.stringify({ model: "matrix-codex", input: "hi" })
797
+ });
798
+ assert.equal(managed.status, 200);
799
+ assert.deepEqual(sourceCalls, ["matrix-codex"]);
800
+ assert.deepEqual(relayCalls, []);
801
+ const relayed = await fetch(`${gateway.url()}/v1/responses`, {
802
+ method: "POST",
803
+ headers: { "content-type": "application/json" },
804
+ body: JSON.stringify({ model: "upstream-only", input: "hi" })
805
+ });
806
+ assert.equal(relayed.status, 200);
807
+ assert.deepEqual(sourceCalls, ["matrix-codex"]);
808
+ assert.deepEqual(relayCalls, ["upstream-only"]);
809
+ }
810
+ finally {
811
+ await gateway.close();
812
+ }
813
+ });