@fusionkit/model-gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/acp-agent.d.ts +39 -0
  2. package/dist/acp-agent.js +143 -0
  3. package/dist/acp-registry.d.ts +36 -0
  4. package/dist/acp-registry.js +85 -0
  5. package/dist/adapters/anthropic.d.ts +111 -0
  6. package/dist/adapters/anthropic.js +446 -0
  7. package/dist/adapters/chat.d.ts +14 -0
  8. package/dist/adapters/chat.js +34 -0
  9. package/dist/adapters/responses.d.ts +94 -0
  10. package/dist/adapters/responses.js +438 -0
  11. package/dist/backend.d.ts +52 -0
  12. package/dist/backend.js +57 -0
  13. package/dist/config.d.ts +22 -0
  14. package/dist/config.js +47 -0
  15. package/dist/front-door-acceptance.d.ts +41 -0
  16. package/dist/front-door-acceptance.js +219 -0
  17. package/dist/fusion-backend.d.ts +96 -0
  18. package/dist/fusion-backend.js +521 -0
  19. package/dist/fusion-gateway.d.ts +69 -0
  20. package/dist/fusion-gateway.js +355 -0
  21. package/dist/index.d.ts +40 -0
  22. package/dist/index.js +28 -0
  23. package/dist/mlx-backend.d.ts +42 -0
  24. package/dist/mlx-backend.js +71 -0
  25. package/dist/provenance.d.ts +29 -0
  26. package/dist/provenance.js +182 -0
  27. package/dist/server.d.ts +27 -0
  28. package/dist/server.js +234 -0
  29. package/dist/test/acp-agent.test.d.ts +1 -0
  30. package/dist/test/acp-agent.test.js +66 -0
  31. package/dist/test/acp-registry.test.d.ts +1 -0
  32. package/dist/test/acp-registry.test.js +70 -0
  33. package/dist/test/anthropic.test.d.ts +1 -0
  34. package/dist/test/anthropic.test.js +251 -0
  35. package/dist/test/chat.test.d.ts +1 -0
  36. package/dist/test/chat.test.js +270 -0
  37. package/dist/test/front-door-acceptance.test.d.ts +1 -0
  38. package/dist/test/front-door-acceptance.test.js +94 -0
  39. package/dist/test/fusion-backend-trace.test.d.ts +1 -0
  40. package/dist/test/fusion-backend-trace.test.js +107 -0
  41. package/dist/test/fusion-backend.test.d.ts +1 -0
  42. package/dist/test/fusion-backend.test.js +193 -0
  43. package/dist/test/fusion-gateway.test.d.ts +1 -0
  44. package/dist/test/fusion-gateway.test.js +107 -0
  45. package/dist/test/responses.test.d.ts +1 -0
  46. package/dist/test/responses.test.js +157 -0
  47. package/package.json +31 -0
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { FusionBackend } from "../fusion-backend.js";
8
+ // node:test isolates each file in its own process, so enabling the trace emitter
9
+ // here (before its lazy singleton is created) does not affect other suites.
10
+ const traceDir = mkdtempSync(join(tmpdir(), "fusion-judge-trace-"));
11
+ process.env.FUSION_TRACE_DIR = traceDir;
12
+ delete process.env.FUSION_TRACE_URL;
13
+ function candidate(id) {
14
+ return { trajectory_id: id, model_id: id, status: "succeeded", final_output: "ok", steps: [] };
15
+ }
16
+ function readEvents() {
17
+ const events = [];
18
+ for (const file of readdirSync(traceDir)) {
19
+ if (!file.endsWith(".jsonl"))
20
+ continue;
21
+ for (const line of readFileSync(join(traceDir, file), "utf8").split("\n")) {
22
+ if (line.trim().length === 0)
23
+ continue;
24
+ events.push(JSON.parse(line));
25
+ }
26
+ }
27
+ return events;
28
+ }
29
+ async function startStepServer(handler) {
30
+ const server = createServer(handler);
31
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
32
+ const address = server.address();
33
+ const port = typeof address === "object" && address !== null ? address.port : 0;
34
+ return {
35
+ url: `http://127.0.0.1:${port}/v1/fusion/trajectory:step`,
36
+ close: () => new Promise((resolve) => server.close(() => resolve()))
37
+ };
38
+ }
39
+ test("FusionBackend emits the full judge prompt and final output", async () => {
40
+ const step = await startStepServer((_req, res) => {
41
+ res.writeHead(200, { "content-type": "application/json" });
42
+ res.end(JSON.stringify({
43
+ choices: [{ message: { role: "assistant", content: "FUSED ANSWER" } }],
44
+ usage: { total_tokens: 12 }
45
+ }));
46
+ });
47
+ try {
48
+ const backend = new FusionBackend({
49
+ stepUrl: step.url,
50
+ judgeModel: "judge-x",
51
+ runPanels: async () => [candidate("c1"), candidate("c2")]
52
+ });
53
+ const res = await backend.chat({ messages: [{ role: "user", content: "the task" }], stream: false });
54
+ await res.text();
55
+ // judge.final is captured from a cloned response asynchronously.
56
+ await new Promise((resolve) => setTimeout(resolve, 200));
57
+ const events = readEvents();
58
+ const request = events.find((event) => event.event_type === "judge.request");
59
+ assert.ok(request, "expected a judge.request event");
60
+ assert.equal(request.component, "judge");
61
+ assert.equal(request.payload?.judge_model, "judge-x");
62
+ assert.equal(request.payload?.turn, 1, "first user message is turn 1");
63
+ assert.deepEqual(request.payload?.trajectory_ids, ["c1", "c2"]);
64
+ assert.ok(Array.isArray(request.payload?.messages), "judge prompt carries the conversation");
65
+ assert.ok(Array.isArray(request.payload?.trajectories), "judge prompt carries candidate trajectories");
66
+ assert.ok(typeof request.parent_span_id === "string", "judge span nests under the session span");
67
+ const final = events.find((event) => event.event_type === "judge.final");
68
+ assert.ok(final, "expected a judge.final event");
69
+ assert.equal(final.payload?.final_output, "FUSED ANSWER");
70
+ assert.equal(final.span_id, request.span_id, "request and final share the judge span");
71
+ }
72
+ finally {
73
+ await step.close();
74
+ }
75
+ });
76
+ test("an intermediate tool-call turn emits judge.thinking, not judge.final", async () => {
77
+ const step = await startStepServer((_req, res) => {
78
+ res.writeHead(200, { "content-type": "application/json" });
79
+ res.end(JSON.stringify({
80
+ choices: [
81
+ {
82
+ message: {
83
+ role: "assistant",
84
+ content: null,
85
+ tool_calls: [{ id: "t1", type: "function", function: { name: "run", arguments: "{}" } }]
86
+ },
87
+ finish_reason: "tool_calls"
88
+ }
89
+ ]
90
+ }));
91
+ });
92
+ try {
93
+ const since = Date.now();
94
+ const backend = new FusionBackend({ stepUrl: step.url, runPanels: async () => [candidate("c1")] });
95
+ const res = await backend.chat({ messages: [{ role: "user", content: "intermediate task" }], stream: false });
96
+ await res.text();
97
+ await new Promise((resolve) => setTimeout(resolve, 200));
98
+ const events = readEvents().filter((event) => event.ts >= since);
99
+ assert.equal(events.some((event) => event.event_type === "judge.final"), false, "an intermediate tool-call turn must not be reported as judge.final");
100
+ const thinking = events.find((event) => event.event_type === "judge.thinking");
101
+ assert.ok(thinking, "expected a judge.thinking event for the intermediate step");
102
+ assert.ok(thinking.payload?.tool_calls, "judge.thinking carries the intermediate tool calls");
103
+ }
104
+ finally {
105
+ await step.close();
106
+ }
107
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,193 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import { test } from "node:test";
4
+ import { FusionBackend } from "../fusion-backend.js";
5
+ function candidate(modelId, status = "succeeded") {
6
+ return { trajectory_id: `t_${modelId}`, model_id: modelId, status, final_output: "ok" };
7
+ }
8
+ const UNREACHABLE_STEP = "http://127.0.0.1:1/v1/fusion/trajectory:step";
9
+ async function startStepServer(handler) {
10
+ let calls = 0;
11
+ const server = createServer((req, res) => {
12
+ calls += 1;
13
+ handler(req, res);
14
+ });
15
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
16
+ const address = server.address();
17
+ const port = typeof address === "object" && address !== null ? address.port : 0;
18
+ return {
19
+ url: `http://127.0.0.1:${port}/v1/fusion/trajectory:step`,
20
+ calls: () => calls,
21
+ close: () => new Promise((resolve) => server.close(() => resolve()))
22
+ };
23
+ }
24
+ const userTurn = { messages: [{ role: "user", content: "do the task" }] };
25
+ test("non-streaming panel failure returns an error and does not cache the session", async () => {
26
+ let panelCalls = 0;
27
+ const backend = new FusionBackend({
28
+ stepUrl: UNREACHABLE_STEP,
29
+ runPanels: async () => {
30
+ panelCalls += 1;
31
+ throw new Error("panel boom");
32
+ }
33
+ });
34
+ const first = await backend.chat({ ...userTurn, stream: false });
35
+ assert.equal(first.status, 502);
36
+ const body = (await first.json());
37
+ assert.match(body.error?.message ?? "", /panel boom/);
38
+ // The failed session is evicted, so the next turn re-runs the panel.
39
+ const second = await backend.chat({ ...userTurn, stream: false });
40
+ assert.equal(second.status, 502);
41
+ assert.equal(panelCalls, 2);
42
+ });
43
+ test("non-streaming empty candidates is an error, not a blank success", async () => {
44
+ const backend = new FusionBackend({ stepUrl: UNREACHABLE_STEP, runPanels: async () => [] });
45
+ const res = await backend.chat({ ...userTurn, stream: false });
46
+ assert.equal(res.status, 502);
47
+ });
48
+ test("non-streaming all-failed candidates is an error", async () => {
49
+ const backend = new FusionBackend({
50
+ stepUrl: UNREACHABLE_STEP,
51
+ runPanels: async () => [candidate("a", "failed"), candidate("b", "failed")]
52
+ });
53
+ const res = await backend.chat({ ...userTurn, stream: false });
54
+ assert.equal(res.status, 502);
55
+ });
56
+ test("non-streaming success forwards the trajectory:step response and runs panels once", async () => {
57
+ const step = await startStepServer((_req, res) => {
58
+ res.writeHead(200, { "content-type": "application/json" });
59
+ res.end(JSON.stringify({ choices: [{ message: { role: "assistant", content: "fused" } }] }));
60
+ });
61
+ try {
62
+ let panelCalls = 0;
63
+ const backend = new FusionBackend({
64
+ stepUrl: step.url,
65
+ runPanels: async () => {
66
+ panelCalls += 1;
67
+ return [candidate("a")];
68
+ }
69
+ });
70
+ const res = await backend.chat({ ...userTurn, stream: false });
71
+ assert.equal(res.status, 200);
72
+ const body = (await res.json());
73
+ assert.equal(body.choices[0]?.message.content, "fused");
74
+ // A second turn with the same prefix reuses the cached panel run.
75
+ await (await backend.chat({ ...userTurn, stream: false })).json();
76
+ assert.equal(panelCalls, 1);
77
+ assert.equal(step.calls(), 2);
78
+ }
79
+ finally {
80
+ await step.close();
81
+ }
82
+ });
83
+ test("non-streaming surfaces a trajectory:step error status", async () => {
84
+ const step = await startStepServer((_req, res) => {
85
+ res.writeHead(500);
86
+ res.end("boom");
87
+ });
88
+ try {
89
+ const backend = new FusionBackend({ stepUrl: step.url, runPanels: async () => [candidate("a")] });
90
+ const res = await backend.chat({ ...userTurn, stream: false });
91
+ assert.equal(res.status, 500);
92
+ }
93
+ finally {
94
+ await step.close();
95
+ }
96
+ });
97
+ test("streaming panel failure emits a terminal error event and evicts the session", async () => {
98
+ let panelCalls = 0;
99
+ const backend = new FusionBackend({
100
+ stepUrl: UNREACHABLE_STEP,
101
+ runPanels: async () => {
102
+ panelCalls += 1;
103
+ throw new Error("panel boom");
104
+ }
105
+ });
106
+ const res = await backend.chat({ ...userTurn, stream: true });
107
+ assert.equal(res.status, 200);
108
+ const text = await res.text();
109
+ assert.match(text, /fusion error/);
110
+ assert.match(text, /"finish_reason":"error"/);
111
+ assert.match(text, /\[DONE\]/);
112
+ await (await backend.chat({ ...userTurn, stream: true })).text();
113
+ assert.equal(panelCalls, 2);
114
+ });
115
+ test("an already-aborted signal aborts the trajectory:step fetch", async () => {
116
+ const backend = new FusionBackend({ stepUrl: UNREACHABLE_STEP, runPanels: async () => [candidate("a")] });
117
+ await assert.rejects(() => backend.chat({ ...userTurn, stream: false }, AbortSignal.abort()));
118
+ });
119
+ test("expired sessions are evicted so panels re-run after the TTL", async () => {
120
+ const step = await startStepServer((_req, res) => {
121
+ res.writeHead(200, { "content-type": "application/json" });
122
+ res.end(JSON.stringify({ choices: [{ message: { content: "ok" } }] }));
123
+ });
124
+ try {
125
+ let panelCalls = 0;
126
+ const backend = new FusionBackend({
127
+ stepUrl: step.url,
128
+ sessionTtlMs: 50,
129
+ runPanels: async () => {
130
+ panelCalls += 1;
131
+ return [candidate("a")];
132
+ }
133
+ });
134
+ await (await backend.chat({ ...userTurn, stream: false })).json();
135
+ await (await backend.chat({ ...userTurn, stream: false })).json();
136
+ assert.equal(panelCalls, 1, "within the TTL the panel run is cached");
137
+ await new Promise((resolve) => setTimeout(resolve, 80));
138
+ await (await backend.chat({ ...userTurn, stream: false })).json();
139
+ assert.equal(panelCalls, 2, "after the TTL the session is evicted and panels re-run");
140
+ }
141
+ finally {
142
+ await step.close();
143
+ }
144
+ });
145
+ test("the panel re-runs per user turn but is reused within a turn's tool loop", async () => {
146
+ const step = await startStepServer((_req, res) => {
147
+ res.writeHead(200, { "content-type": "application/json" });
148
+ res.end(JSON.stringify({ choices: [{ message: { content: "ok" } }] }));
149
+ });
150
+ try {
151
+ let panelCalls = 0;
152
+ const turnsSeen = [];
153
+ const backend = new FusionBackend({
154
+ stepUrl: step.url,
155
+ runPanels: async (input) => {
156
+ panelCalls += 1;
157
+ turnsSeen.push(input.turn);
158
+ return [candidate(`c${input.turn}`)];
159
+ }
160
+ });
161
+ const system = { role: "system", content: "S" };
162
+ const first = { role: "user", content: "task one" };
163
+ // Turn 1: the first user message runs the panel.
164
+ await (await backend.chat({ messages: [system, first], stream: false })).json();
165
+ assert.equal(panelCalls, 1);
166
+ // Internal tool-loop continuation (same user-message count) reuses turn 1.
167
+ await (await backend.chat({
168
+ messages: [
169
+ system,
170
+ first,
171
+ { role: "assistant", content: null, tool_calls: [{ id: "t", type: "function" }] },
172
+ { role: "tool", tool_call_id: "t", content: "tool result" }
173
+ ],
174
+ stream: false
175
+ })).json();
176
+ assert.equal(panelCalls, 1, "a tool-loop continuation reuses the turn's candidates");
177
+ // Follow-up user message: a new turn, so the panel runs again.
178
+ await (await backend.chat({
179
+ messages: [
180
+ system,
181
+ first,
182
+ { role: "assistant", content: "answer one" },
183
+ { role: "user", content: "task two" }
184
+ ],
185
+ stream: false
186
+ })).json();
187
+ assert.equal(panelCalls, 2, "a follow-up user message re-runs the panel");
188
+ assert.deepEqual(turnsSeen, [1, 2], "each panel run is stamped with its user turn");
189
+ }
190
+ finally {
191
+ await step.close();
192
+ }
193
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { FUSION_EVIDENCE_HEADER, FUSION_RUN_ID_HEADER, FUSION_STATUS_HEADER, promptFromAnthropic, promptFromChat, promptFromResponses, startFusionGateway } from "../fusion-gateway.js";
4
+ function recordingRunner() {
5
+ const calls = [];
6
+ const runner = async (input) => {
7
+ calls.push(input);
8
+ return {
9
+ finalOutput: `FUSION_OK:${input.dialect}:${input.prompt}`,
10
+ runId: `run_${calls.length}`,
11
+ status: "succeeded",
12
+ evidence: ["patch_artifact", "tool_execution", "judge_synthesis"]
13
+ };
14
+ };
15
+ return { runner, calls };
16
+ }
17
+ test("prompt extractors pull user text from each dialect", () => {
18
+ assert.equal(promptFromResponses({
19
+ instructions: "sys",
20
+ input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }]
21
+ }), "sys\n\nhello");
22
+ assert.equal(promptFromAnthropic({
23
+ system: "sys",
24
+ messages: [{ role: "user", content: "hi there" }]
25
+ }), "sys\n\nhi there");
26
+ assert.equal(promptFromChat({ messages: [{ role: "user", content: "do the thing" }] }), "do the thing");
27
+ });
28
+ test("gateway translates Responses, Anthropic, and Chat front doors", async () => {
29
+ const { runner, calls } = recordingRunner();
30
+ const gateway = await startFusionGateway({ runner, defaultModel: "fusion-panel" });
31
+ try {
32
+ const health = await fetch(`${gateway.url()}/health`);
33
+ assert.equal(health.status, 200);
34
+ const responses = await fetch(`${gateway.url()}/v1/responses`, {
35
+ method: "POST",
36
+ headers: { "content-type": "application/json" },
37
+ body: JSON.stringify({
38
+ model: "fusion-panel",
39
+ input: [{ role: "user", content: [{ type: "input_text", text: "codex prompt" }] }]
40
+ })
41
+ });
42
+ assert.equal(responses.status, 200);
43
+ assert.equal(responses.headers.get(FUSION_STATUS_HEADER), "succeeded");
44
+ assert.equal(responses.headers.get(FUSION_RUN_ID_HEADER), "run_1");
45
+ assert.deepEqual(JSON.parse(responses.headers.get(FUSION_EVIDENCE_HEADER) ?? "[]"), [
46
+ "patch_artifact",
47
+ "tool_execution",
48
+ "judge_synthesis"
49
+ ]);
50
+ const responsesBody = (await responses.json());
51
+ assert.equal(responsesBody.object, "response");
52
+ assert.match(responsesBody.output[0]?.content[0]?.text ?? "", /FUSION_OK:openai-responses:codex prompt/);
53
+ const messages = await fetch(`${gateway.url()}/v1/messages`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
56
+ body: JSON.stringify({
57
+ model: "fusion-panel",
58
+ max_tokens: 256,
59
+ messages: [{ role: "user", content: "claude prompt" }]
60
+ })
61
+ });
62
+ const messagesBody = (await messages.json());
63
+ assert.equal(messagesBody.type, "message");
64
+ assert.match(messagesBody.content[0]?.text ?? "", /FUSION_OK:anthropic-messages:claude prompt/);
65
+ const chat = await fetch(`${gateway.url()}/v1/chat/completions`, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ model: "fusion-panel",
70
+ messages: [{ role: "user", content: "cursor prompt" }]
71
+ })
72
+ });
73
+ const chatBody = (await chat.json());
74
+ assert.equal(chatBody.object, "chat.completion");
75
+ assert.match(chatBody.choices[0]?.message.content ?? "", /FUSION_OK:openai-chat:cursor prompt/);
76
+ assert.deepEqual(calls.map((call) => call.dialect), ["openai-responses", "anthropic-messages", "openai-chat"]);
77
+ }
78
+ finally {
79
+ await gateway.close();
80
+ }
81
+ });
82
+ test("gateway serves OpenAI and Anthropic model lists and enforces auth", async () => {
83
+ const { runner } = recordingRunner();
84
+ const gateway = await startFusionGateway({
85
+ runner,
86
+ defaultModel: "fusion-panel",
87
+ authToken: "secret"
88
+ });
89
+ try {
90
+ const unauthorized = await fetch(`${gateway.url()}/v1/models`);
91
+ assert.equal(unauthorized.status, 401);
92
+ const openai = await fetch(`${gateway.url()}/v1/models`, {
93
+ headers: { authorization: "Bearer secret" }
94
+ });
95
+ const openaiBody = (await openai.json());
96
+ assert.equal(openaiBody.data[0]?.id, "fusion-panel");
97
+ const anthropic = await fetch(`${gateway.url()}/v1/models`, {
98
+ headers: { authorization: "Bearer secret", "anthropic-version": "2023-06-01" }
99
+ });
100
+ const anthropicBody = (await anthropic.json());
101
+ assert.equal(anthropicBody.data[0]?.type, "model");
102
+ assert.equal(anthropicBody.data[0]?.id, "fusion-panel");
103
+ }
104
+ finally {
105
+ await gateway.close();
106
+ }
107
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
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 { responsesToChat } from "../adapters/responses.js";
7
+ import { startGateway } from "../server.js";
8
+ function sendJson(res, status, value) {
9
+ res.statusCode = status;
10
+ res.setHeader("content-type", "application/json");
11
+ res.end(Buffer.from(JSON.stringify(value), "utf8"));
12
+ }
13
+ async function readAll(req) {
14
+ const chunks = [];
15
+ for await (const chunk of req)
16
+ chunks.push(chunk);
17
+ return Buffer.concat(chunks);
18
+ }
19
+ async function startMock() {
20
+ let lastChatBody;
21
+ let lastModelCallId;
22
+ const server = createServer((req, res) => {
23
+ void (async () => {
24
+ const body = JSON.parse((await readAll(req)).toString("utf8"));
25
+ lastChatBody = body;
26
+ lastModelCallId =
27
+ typeof req.headers[MODEL_CALL_ID_HEADER] === "string"
28
+ ? req.headers[MODEL_CALL_ID_HEADER]
29
+ : undefined;
30
+ if (body.stream === true) {
31
+ res.statusCode = 200;
32
+ res.setHeader("content-type", "text/event-stream");
33
+ res.write('data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}]}\n\n');
34
+ res.write('data: {"choices":[{"delta":{"content":" there"},"finish_reason":null}]}\n\n');
35
+ res.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}\n\n');
36
+ res.write("data: [DONE]\n\n");
37
+ res.end();
38
+ return;
39
+ }
40
+ sendJson(res, 200, {
41
+ id: "cmpl-2",
42
+ object: "chat.completion",
43
+ model: body.model,
44
+ choices: [{ index: 0, message: { role: "assistant", content: "Final answer" }, finish_reason: "stop" }],
45
+ usage: { prompt_tokens: 6, completion_tokens: 2 }
46
+ });
47
+ })();
48
+ });
49
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
50
+ const address = server.address();
51
+ const port = typeof address === "object" && address !== null ? address.port : 0;
52
+ return {
53
+ url: `http://127.0.0.1:${port}`,
54
+ lastChatBody: () => lastChatBody,
55
+ lastModelCallId: () => lastModelCallId,
56
+ close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
57
+ };
58
+ }
59
+ test("responsesToChat maps instructions, input items, and function output", () => {
60
+ const chat = responsesToChat({
61
+ model: "gpt-x",
62
+ instructions: "be terse",
63
+ input: [
64
+ { type: "message", role: "user", content: "search please" },
65
+ { type: "function_call", call_id: "call_1", name: "search", arguments: '{"q":"x"}' },
66
+ { type: "function_call_output", call_id: "call_1", output: "found" }
67
+ ],
68
+ tools: [{ type: "function", name: "search", description: "find", parameters: { type: "object" } }]
69
+ }, "local-model");
70
+ const messages = chat.messages;
71
+ assert.equal(chat.model, "local-model");
72
+ assert.equal(messages[0]?.role, "system");
73
+ assert.equal(messages[1]?.role, "user");
74
+ assert.equal(messages[2]?.role, "assistant");
75
+ assert.ok(Array.isArray(messages[2].tool_calls));
76
+ assert.equal(messages[3]?.role, "tool");
77
+ assert.equal(messages[3].tool_call_id, "call_1");
78
+ const tools = chat.tools;
79
+ assert.equal(tools[0]?.function.name, "search");
80
+ });
81
+ test("responsesToChat coalesces parallel function calls into one assistant message", () => {
82
+ // Codex emits parallel tool calls as separate function_call items; they must
83
+ // become a single assistant message so the following tool messages answer it
84
+ // (the chat API rejects an assistant tool_calls message that is not directly
85
+ // followed by tool responses for each tool_call_id).
86
+ const chat = responsesToChat({
87
+ input: [
88
+ { type: "message", role: "user", content: "fix it" },
89
+ { type: "function_call", call_id: "call_a", name: "read_file", arguments: '{"path":"a.js"}' },
90
+ { type: "function_call", call_id: "call_b", name: "read_file", arguments: '{"path":"b.js"}' },
91
+ { type: "function_call_output", call_id: "call_a", output: "A" },
92
+ { type: "function_call_output", call_id: "call_b", output: "B" }
93
+ ]
94
+ }, "local-model");
95
+ const messages = chat.messages;
96
+ // user, assistant(tool_calls:[a,b]), tool(a), tool(b)
97
+ assert.equal(messages.length, 4);
98
+ assert.equal(messages[1]?.role, "assistant");
99
+ const toolCalls = messages[1].tool_calls ?? [];
100
+ assert.equal(toolCalls.length, 2);
101
+ assert.deepEqual(toolCalls.map((call) => call.id), ["call_a", "call_b"]);
102
+ assert.equal(messages[2]?.role, "tool");
103
+ assert.equal(messages[2].tool_call_id, "call_a");
104
+ assert.equal(messages[3]?.role, "tool");
105
+ assert.equal(messages[3].tool_call_id, "call_b");
106
+ });
107
+ test("serves a non-streaming Responses object end to end", async () => {
108
+ const mock = await startMock();
109
+ const gateway = await startGateway({
110
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
111
+ });
112
+ try {
113
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ model: "gpt-x", input: "hello" })
117
+ });
118
+ assert.equal(response.status, 200);
119
+ assert.equal(mock.lastModelCallId(), response.headers.get(MODEL_CALL_ID_HEADER));
120
+ const json = (await response.json());
121
+ assert.equal(json.object, "response");
122
+ assert.equal(json.status, "completed");
123
+ assert.equal(json.output[0]?.type, "message");
124
+ assert.equal(json.output[0]?.content?.[0]?.text, "Final answer");
125
+ assert.equal(json.usage.output_tokens, 2);
126
+ assert.equal(mock.lastChatBody()?.model, "local-model");
127
+ }
128
+ finally {
129
+ await gateway.close();
130
+ await mock.close();
131
+ }
132
+ });
133
+ test("translates a streamed Responses event sequence", async () => {
134
+ const mock = await startMock();
135
+ const gateway = await startGateway({
136
+ backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
137
+ });
138
+ try {
139
+ const response = await fetch(`${gateway.url()}/v1/responses`, {
140
+ method: "POST",
141
+ headers: { "content-type": "application/json" },
142
+ body: JSON.stringify({ model: "gpt-x", stream: true, input: "hello" })
143
+ });
144
+ assert.equal(response.status, 200);
145
+ assert.equal(response.headers.get("content-type"), "text/event-stream");
146
+ const text = await response.text();
147
+ assert.ok(text.includes("event: response.created"));
148
+ assert.ok(text.includes("event: response.output_item.added"));
149
+ assert.ok(text.includes('event: response.output_text.delta'));
150
+ assert.ok(text.includes('"delta":"Hi"'));
151
+ assert.ok(text.includes("event: response.completed"));
152
+ }
153
+ finally {
154
+ await gateway.close();
155
+ await mock.close();
156
+ }
157
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@fusionkit/model-gateway",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/model-gateway"
9
+ },
10
+ "description": "Native local-model gateway: fronts an OpenAI-compatible local model and exposes the wire dialects each agent harness needs (OpenAI chat, Anthropic Messages, OpenAI Responses) so a local model can transparently back Claude Code, Codex, opencode, and Cursor.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "@fusionkit/adapter-ai-sdk": "0.1.0",
29
+ "@fusionkit/protocol": "0.1.0"
30
+ }
31
+ }