@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.
- package/dist/acp-agent.d.ts +39 -0
- package/dist/acp-agent.js +143 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +111 -0
- package/dist/adapters/anthropic.js +446 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/responses.d.ts +94 -0
- package/dist/adapters/responses.js +438 -0
- package/dist/backend.d.ts +52 -0
- package/dist/backend.js +57 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +47 -0
- package/dist/front-door-acceptance.d.ts +41 -0
- package/dist/front-door-acceptance.js +219 -0
- package/dist/fusion-backend.d.ts +96 -0
- package/dist/fusion-backend.js +521 -0
- package/dist/fusion-gateway.d.ts +69 -0
- package/dist/fusion-gateway.js +355 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +28 -0
- package/dist/mlx-backend.d.ts +42 -0
- package/dist/mlx-backend.js +71 -0
- package/dist/provenance.d.ts +29 -0
- package/dist/provenance.js +182 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +234 -0
- package/dist/test/acp-agent.test.d.ts +1 -0
- package/dist/test/acp-agent.test.js +66 -0
- package/dist/test/acp-registry.test.d.ts +1 -0
- package/dist/test/acp-registry.test.js +70 -0
- package/dist/test/anthropic.test.d.ts +1 -0
- package/dist/test/anthropic.test.js +251 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +270 -0
- package/dist/test/front-door-acceptance.test.d.ts +1 -0
- package/dist/test/front-door-acceptance.test.js +94 -0
- package/dist/test/fusion-backend-trace.test.d.ts +1 -0
- package/dist/test/fusion-backend-trace.test.js +107 -0
- package/dist/test/fusion-backend.test.d.ts +1 -0
- package/dist/test/fusion-backend.test.js +193 -0
- package/dist/test/fusion-gateway.test.d.ts +1 -0
- package/dist/test/fusion-gateway.test.js +107 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +157 -0
- package/package.json +31 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { anthropicToChat, chatToAnthropicMessage, mapStopReason, openAiSseToAnthropic } from "../adapters/anthropic.js";
|
|
5
|
+
import { OpenAiBackend } from "../backend.js";
|
|
6
|
+
import { MODEL_CALL_ID_HEADER } from "../provenance.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":"Hel"},"finish_reason":null}]}\n\n');
|
|
34
|
+
res.write('data: {"choices":[{"delta":{"content":"lo"},"finish_reason":null}]}\n\n');
|
|
35
|
+
res.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"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-1",
|
|
42
|
+
object: "chat.completion",
|
|
43
|
+
model: body.model,
|
|
44
|
+
choices: [
|
|
45
|
+
{ index: 0, message: { role: "assistant", content: "Hello there" }, finish_reason: "stop" }
|
|
46
|
+
],
|
|
47
|
+
usage: { prompt_tokens: 7, completion_tokens: 3 }
|
|
48
|
+
});
|
|
49
|
+
})();
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
52
|
+
const address = server.address();
|
|
53
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
54
|
+
return {
|
|
55
|
+
url: `http://127.0.0.1:${port}`,
|
|
56
|
+
lastChatBody: () => lastChatBody,
|
|
57
|
+
lastModelCallId: () => lastModelCallId,
|
|
58
|
+
close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
test("anthropicToChat maps system, tools, and tool results", () => {
|
|
62
|
+
const chat = anthropicToChat({
|
|
63
|
+
model: "claude-x",
|
|
64
|
+
system: "be terse",
|
|
65
|
+
max_tokens: 100,
|
|
66
|
+
messages: [
|
|
67
|
+
{ role: "user", content: "hi" },
|
|
68
|
+
{
|
|
69
|
+
role: "assistant",
|
|
70
|
+
content: [{ type: "tool_use", id: "tu_1", name: "search", input: { q: "x" } }]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
role: "user",
|
|
74
|
+
content: [{ type: "tool_result", tool_use_id: "tu_1", content: "result text" }]
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
tools: [{ name: "search", description: "find", input_schema: { type: "object" } }]
|
|
78
|
+
}, "local-model");
|
|
79
|
+
const messages = chat.messages;
|
|
80
|
+
assert.equal(chat.model, "local-model");
|
|
81
|
+
assert.equal(messages[0]?.role, "system");
|
|
82
|
+
assert.equal(messages[1]?.role, "user");
|
|
83
|
+
assert.equal(messages[2]?.role, "assistant");
|
|
84
|
+
assert.ok(Array.isArray(messages[2].tool_calls));
|
|
85
|
+
assert.equal(messages[3]?.role, "tool");
|
|
86
|
+
assert.equal(messages[3].tool_call_id, "tu_1");
|
|
87
|
+
const tools = chat.tools;
|
|
88
|
+
assert.equal(tools[0]?.function.name, "search");
|
|
89
|
+
});
|
|
90
|
+
test("anthropicToChat groups parallel tool_use into one assistant message", () => {
|
|
91
|
+
// Anthropic batches parallel tool calls as multiple tool_use blocks in a
|
|
92
|
+
// single assistant message; they must stay one assistant message followed by
|
|
93
|
+
// the tool results so the chat API's tool_calls pairing stays valid.
|
|
94
|
+
const chat = anthropicToChat({
|
|
95
|
+
messages: [
|
|
96
|
+
{ role: "user", content: "do both" },
|
|
97
|
+
{
|
|
98
|
+
role: "assistant",
|
|
99
|
+
content: [
|
|
100
|
+
{ type: "tool_use", id: "tu_a", name: "read_file", input: { path: "a" } },
|
|
101
|
+
{ type: "tool_use", id: "tu_b", name: "read_file", input: { path: "b" } }
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
role: "user",
|
|
106
|
+
content: [
|
|
107
|
+
{ type: "tool_result", tool_use_id: "tu_a", content: "A" },
|
|
108
|
+
{ type: "tool_result", tool_use_id: "tu_b", content: "B" }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}, "local-model");
|
|
113
|
+
const messages = chat.messages;
|
|
114
|
+
const assistant = messages.find((m) => m.role === "assistant");
|
|
115
|
+
assert.equal(assistant.tool_calls?.length, 2);
|
|
116
|
+
assert.deepEqual(assistant.tool_calls?.map((call) => call.id), ["tu_a", "tu_b"]);
|
|
117
|
+
const toolMessages = messages.filter((m) => m.role === "tool");
|
|
118
|
+
assert.deepEqual(toolMessages.map((m) => m.tool_call_id), ["tu_a", "tu_b"]);
|
|
119
|
+
});
|
|
120
|
+
test("anthropic streaming starts eagerly and pings during the panel phase", async () => {
|
|
121
|
+
// A never-ending upstream simulates the silent fusion panel phase before the
|
|
122
|
+
// judge's first token.
|
|
123
|
+
let upstreamController;
|
|
124
|
+
const upstream = new ReadableStream({
|
|
125
|
+
start(controller) {
|
|
126
|
+
upstreamController = controller;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const decoder = new TextDecoder();
|
|
130
|
+
const reader = openAiSseToAnthropic(upstream, "claude-x").getReader();
|
|
131
|
+
try {
|
|
132
|
+
// message_start must arrive before any upstream data is produced.
|
|
133
|
+
const first = await reader.read();
|
|
134
|
+
assert.ok(first.value !== undefined);
|
|
135
|
+
assert.ok(decoder.decode(first.value).includes("event: message_start"));
|
|
136
|
+
// A ping keepalive must arrive while the upstream is still silent.
|
|
137
|
+
let sawPing = false;
|
|
138
|
+
const deadline = Date.now() + 4000;
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
const { value, done } = await reader.read();
|
|
141
|
+
if (done)
|
|
142
|
+
break;
|
|
143
|
+
if (value !== undefined && decoder.decode(value).includes("event: ping")) {
|
|
144
|
+
sawPing = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
assert.ok(sawPing, "a ping keepalive must be emitted while the upstream is silent");
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await reader.cancel();
|
|
152
|
+
// cancel() already propagates to the upstream; closing again is a no-op.
|
|
153
|
+
try {
|
|
154
|
+
upstreamController.close();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// already closed via cancel
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
test("chatToAnthropicMessage produces a text content block", () => {
|
|
162
|
+
const message = chatToAnthropicMessage({
|
|
163
|
+
id: "cmpl-9",
|
|
164
|
+
choices: [{ message: { content: "hi" }, finish_reason: "stop" }],
|
|
165
|
+
usage: { prompt_tokens: 4, completion_tokens: 1 }
|
|
166
|
+
}, "claude-x");
|
|
167
|
+
assert.equal(message.type, "message");
|
|
168
|
+
const content = message.content;
|
|
169
|
+
assert.equal(content[0]?.type, "text");
|
|
170
|
+
assert.equal(content[0]?.text, "hi");
|
|
171
|
+
assert.equal(message.stop_reason, "end_turn");
|
|
172
|
+
});
|
|
173
|
+
test("mapStopReason maps tool_calls to tool_use", () => {
|
|
174
|
+
assert.equal(mapStopReason("tool_calls"), "tool_use");
|
|
175
|
+
assert.equal(mapStopReason("length"), "max_tokens");
|
|
176
|
+
assert.equal(mapStopReason("stop"), "end_turn");
|
|
177
|
+
});
|
|
178
|
+
test("serves a non-streaming Anthropic message end to end", async () => {
|
|
179
|
+
const mock = await startMock();
|
|
180
|
+
const gateway = await startGateway({
|
|
181
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
187
|
+
body: JSON.stringify({ model: "claude-x", max_tokens: 50, messages: [{ role: "user", content: "hi" }] })
|
|
188
|
+
});
|
|
189
|
+
assert.equal(response.status, 200);
|
|
190
|
+
assert.equal(mock.lastModelCallId(), response.headers.get(MODEL_CALL_ID_HEADER));
|
|
191
|
+
const json = (await response.json());
|
|
192
|
+
assert.equal(json.type, "message");
|
|
193
|
+
assert.equal(json.model, "claude-x");
|
|
194
|
+
assert.equal(json.content[0]?.text, "Hello there");
|
|
195
|
+
// Upstream got the backend model, not the claude id.
|
|
196
|
+
assert.equal(mock.lastChatBody()?.model, "local-model");
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await gateway.close();
|
|
200
|
+
await mock.close();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
test("translates a streamed Anthropic message", async () => {
|
|
204
|
+
const mock = await startMock();
|
|
205
|
+
const gateway = await startGateway({
|
|
206
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "local-model" })
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
212
|
+
body: JSON.stringify({ model: "claude-x", max_tokens: 50, stream: true, messages: [{ role: "user", content: "hi" }] })
|
|
213
|
+
});
|
|
214
|
+
assert.equal(response.status, 200);
|
|
215
|
+
assert.equal(response.headers.get("content-type"), "text/event-stream");
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
assert.ok(text.includes("event: message_start"));
|
|
218
|
+
assert.ok(text.includes("event: content_block_start"));
|
|
219
|
+
assert.ok(text.includes('"type":"text_delta","text":"Hel"'));
|
|
220
|
+
assert.ok(text.includes("event: message_stop"));
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await gateway.close();
|
|
224
|
+
await mock.close();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
test("estimates tokens and serves Anthropic discovery", 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 count = await fetch(`${gateway.url()}/v1/messages/count_tokens`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
236
|
+
body: JSON.stringify({ model: "claude-x", messages: [{ role: "user", content: "hello world" }] })
|
|
237
|
+
});
|
|
238
|
+
assert.equal(count.status, 200);
|
|
239
|
+
const counted = (await count.json());
|
|
240
|
+
assert.ok(counted.input_tokens > 0);
|
|
241
|
+
const models = await fetch(`${gateway.url()}/v1/models`, {
|
|
242
|
+
headers: { "anthropic-version": "2023-06-01" }
|
|
243
|
+
});
|
|
244
|
+
const list = (await models.json());
|
|
245
|
+
assert.ok(list.data[0]?.id.startsWith("claude"));
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await gateway.close();
|
|
249
|
+
await mock.close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { assertModelCallRecordV1 } from "@fusionkit/protocol";
|
|
5
|
+
import { OpenAiBackend } from "../backend.js";
|
|
6
|
+
import { MODEL_CALL_ID_HEADER } from "../provenance.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 path = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
25
|
+
if (req.method === "GET" && path === "/v1/models") {
|
|
26
|
+
sendJson(res, 200, { object: "list", data: [{ id: "mock-model", object: "model" }] });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (req.method === "POST" && path === "/v1/chat/completions") {
|
|
30
|
+
const body = JSON.parse((await readAll(req)).toString("utf8"));
|
|
31
|
+
lastChatBody = body;
|
|
32
|
+
lastModelCallId =
|
|
33
|
+
typeof req.headers[MODEL_CALL_ID_HEADER] === "string"
|
|
34
|
+
? req.headers[MODEL_CALL_ID_HEADER]
|
|
35
|
+
: undefined;
|
|
36
|
+
if (body.model === "fail-model") {
|
|
37
|
+
sendJson(res, 500, { error: { message: "upstream failed" } });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (body.stream === true) {
|
|
41
|
+
res.statusCode = 200;
|
|
42
|
+
res.setHeader("content-type", "text/event-stream");
|
|
43
|
+
res.write('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n');
|
|
44
|
+
res.write('data: {"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n');
|
|
45
|
+
res.write("data: [DONE]\n\n");
|
|
46
|
+
res.end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
sendJson(res, 200, {
|
|
50
|
+
id: "chatcmpl-mock",
|
|
51
|
+
object: "chat.completion",
|
|
52
|
+
model: body.model,
|
|
53
|
+
choices: [
|
|
54
|
+
{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }
|
|
55
|
+
],
|
|
56
|
+
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
sendJson(res, 404, { error: "not found" });
|
|
61
|
+
})();
|
|
62
|
+
});
|
|
63
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
64
|
+
const address = server.address();
|
|
65
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
66
|
+
return {
|
|
67
|
+
url: `http://127.0.0.1:${port}`,
|
|
68
|
+
lastChatBody: () => lastChatBody,
|
|
69
|
+
lastModelCallId: () => lastModelCallId,
|
|
70
|
+
close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
test("injects the default model and pipes the completion back", async () => {
|
|
74
|
+
const mock = await startMock();
|
|
75
|
+
const backend = new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "mlx-default" });
|
|
76
|
+
const records = [];
|
|
77
|
+
const gateway = await startGateway({
|
|
78
|
+
backend,
|
|
79
|
+
provenance: { onModelCall: (record) => records.push(record) }
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] })
|
|
86
|
+
});
|
|
87
|
+
assert.equal(response.status, 200);
|
|
88
|
+
const callId = response.headers.get(MODEL_CALL_ID_HEADER);
|
|
89
|
+
assert.ok(callId?.startsWith("model_call_"));
|
|
90
|
+
assert.equal(mock.lastModelCallId(), callId);
|
|
91
|
+
const json = (await response.json());
|
|
92
|
+
assert.equal(json.object, "chat.completion");
|
|
93
|
+
assert.equal(mock.lastChatBody()?.model, "mlx-default");
|
|
94
|
+
assert.equal(records.length, 1);
|
|
95
|
+
assertModelCallRecordV1(records[0]);
|
|
96
|
+
assert.equal(records[0]?.call_id, callId);
|
|
97
|
+
assert.equal(records[0]?.metadata?.dialect, "openai-chat");
|
|
98
|
+
assert.equal(records[0]?.model, "mlx-default");
|
|
99
|
+
assert.equal(records[0]?.metadata?.stream, false);
|
|
100
|
+
assert.equal(records[0]?.usage?.total_tokens, 5);
|
|
101
|
+
assert.equal(records[0]?.metadata?.unknown_usage, false);
|
|
102
|
+
assert.equal(records[0]?.metadata?.unknown_cost, true);
|
|
103
|
+
assert.ok(!JSON.stringify(records[0]).includes('"hi"'));
|
|
104
|
+
assert.ok(!JSON.stringify(records[0]).includes('"ok"'));
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await gateway.close();
|
|
108
|
+
await mock.close();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
test("records failed upstream responses as failed model-call records", async () => {
|
|
112
|
+
const mock = await startMock();
|
|
113
|
+
const records = [];
|
|
114
|
+
const gateway = await startGateway({
|
|
115
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1` }),
|
|
116
|
+
provenance: { onModelCall: (record) => records.push(record) }
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ model: "fail-model", messages: [{ role: "user", content: "fail" }] })
|
|
123
|
+
});
|
|
124
|
+
assert.equal(response.status, 500);
|
|
125
|
+
assert.equal(records.length, 1);
|
|
126
|
+
assertModelCallRecordV1(records[0]);
|
|
127
|
+
assert.equal(records[0]?.status, "failed");
|
|
128
|
+
assert.equal(records[0]?.error?.kind, "provider_error");
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await gateway.close();
|
|
132
|
+
await mock.close();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
test("records thrown backend failures as failed model-call records", async () => {
|
|
136
|
+
const records = [];
|
|
137
|
+
const gateway = await startGateway({
|
|
138
|
+
backend: {
|
|
139
|
+
defaultModel: "throw-model",
|
|
140
|
+
chat: async () => {
|
|
141
|
+
throw new Error("backend exploded");
|
|
142
|
+
},
|
|
143
|
+
models: async () => new Response("{}", { status: 200 }),
|
|
144
|
+
embeddings: async () => new Response("{}", { status: 200 })
|
|
145
|
+
},
|
|
146
|
+
provenance: { onModelCall: (record) => records.push(record) }
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "fail" }] })
|
|
153
|
+
});
|
|
154
|
+
assert.equal(response.status, 502);
|
|
155
|
+
assert.equal(response.headers.get(MODEL_CALL_ID_HEADER), records[0]?.call_id);
|
|
156
|
+
assert.equal(records.length, 1);
|
|
157
|
+
assertModelCallRecordV1(records[0]);
|
|
158
|
+
assert.equal(records[0]?.status, "failed");
|
|
159
|
+
assert.equal(records[0]?.error?.kind, "provider_error");
|
|
160
|
+
assert.equal(records[0]?.metadata?.unknown_usage, true);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await gateway.close();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
test("preserves an explicitly requested model", async () => {
|
|
167
|
+
const mock = await startMock();
|
|
168
|
+
const gateway = await startGateway({
|
|
169
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "mlx-default" })
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({ model: "explicit-model", messages: [{ role: "user", content: "hi" }] })
|
|
176
|
+
});
|
|
177
|
+
assert.equal(response.status, 200);
|
|
178
|
+
assert.equal(mock.lastChatBody()?.model, "explicit-model");
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await gateway.close();
|
|
182
|
+
await mock.close();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
test("lists models from the backend", async () => {
|
|
186
|
+
const mock = await startMock();
|
|
187
|
+
const gateway = await startGateway({
|
|
188
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1` })
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetch(`${gateway.url()}/v1/models`);
|
|
192
|
+
assert.equal(response.status, 200);
|
|
193
|
+
const json = (await response.json());
|
|
194
|
+
assert.equal(json.data.length, 1);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await gateway.close();
|
|
198
|
+
await mock.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
test("streams server-sent events straight through", async () => {
|
|
202
|
+
const mock = await startMock();
|
|
203
|
+
const gateway = await startGateway({
|
|
204
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "mlx-default" })
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "content-type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ stream: true, messages: [{ role: "user", content: "hi" }] })
|
|
211
|
+
});
|
|
212
|
+
assert.equal(response.status, 200);
|
|
213
|
+
assert.equal(response.headers.get("content-type"), "text/event-stream");
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
assert.ok(text.includes("data: [DONE]"));
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await gateway.close();
|
|
219
|
+
await mock.close();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test("returns 404 for an unknown route", async () => {
|
|
223
|
+
const gateway = await startGateway({
|
|
224
|
+
backend: new OpenAiBackend({ baseUrl: "http://127.0.0.1:1/v1" })
|
|
225
|
+
});
|
|
226
|
+
try {
|
|
227
|
+
const response = await fetch(`${gateway.url()}/v1/unknown`, { method: "POST", body: "{}" });
|
|
228
|
+
assert.equal(response.status, 404);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await gateway.close();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
test("rejects malformed JSON with 400", async () => {
|
|
235
|
+
const gateway = await startGateway({
|
|
236
|
+
backend: new OpenAiBackend({ baseUrl: "http://127.0.0.1:1/v1" })
|
|
237
|
+
});
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json" },
|
|
242
|
+
body: "{not json"
|
|
243
|
+
});
|
|
244
|
+
assert.equal(response.status, 400);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await gateway.close();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
test("enforces the auth token when configured", async () => {
|
|
251
|
+
const mock = await startMock();
|
|
252
|
+
const gateway = await startGateway({
|
|
253
|
+
backend: new OpenAiBackend({ baseUrl: `${mock.url}/v1`, defaultModel: "mlx-default" }),
|
|
254
|
+
authToken: "secret"
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
const unauthorized = await fetch(`${gateway.url()}/v1/models`);
|
|
258
|
+
assert.equal(unauthorized.status, 401);
|
|
259
|
+
const authorized = await fetch(`${gateway.url()}/v1/models`, {
|
|
260
|
+
headers: { authorization: "Bearer secret" }
|
|
261
|
+
});
|
|
262
|
+
assert.equal(authorized.status, 200);
|
|
263
|
+
const health = await fetch(`${gateway.url()}/health`);
|
|
264
|
+
assert.equal(health.status, 200);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await gateway.close();
|
|
268
|
+
await mock.close();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { runFrontDoorAcceptance } from "../front-door-acceptance.js";
|
|
4
|
+
import { startFusionGateway } from "../fusion-gateway.js";
|
|
5
|
+
const SENTINEL = "FUSION_OK";
|
|
6
|
+
function sentinelRunner() {
|
|
7
|
+
return async (input) => ({
|
|
8
|
+
finalOutput: `${SENTINEL} handled ${input.dialect}`,
|
|
9
|
+
runId: `run_${input.requestId}`,
|
|
10
|
+
status: "succeeded",
|
|
11
|
+
evidence: ["patch_artifact", "tool_execution", "judge_synthesis"]
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function sentinelAcpRunner() {
|
|
15
|
+
return async (input) => ({
|
|
16
|
+
finalOutput: `${SENTINEL} acp ${input.prompt}`,
|
|
17
|
+
runId: `run_${input.requestId}`,
|
|
18
|
+
status: "succeeded",
|
|
19
|
+
evidence: ["judge_synthesis"]
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function byId(report, id) {
|
|
23
|
+
const outcome = report.front_doors.find((door) => door.id === id);
|
|
24
|
+
assert.ok(outcome, `expected front door ${id}`);
|
|
25
|
+
return outcome;
|
|
26
|
+
}
|
|
27
|
+
test("acceptance passes HTTP front doors and generic ACP, blocks missing adapters", async () => {
|
|
28
|
+
const gateway = await startFusionGateway({ runner: sentinelRunner(), defaultModel: "fusion-panel" });
|
|
29
|
+
try {
|
|
30
|
+
const report = await runFrontDoorAcceptance({
|
|
31
|
+
gatewayUrl: gateway.url(),
|
|
32
|
+
sentinel: SENTINEL,
|
|
33
|
+
acpRunner: sentinelAcpRunner()
|
|
34
|
+
});
|
|
35
|
+
assert.equal(byId(report, "codex-responses").status, "passed");
|
|
36
|
+
assert.equal(byId(report, "claude-messages").status, "passed");
|
|
37
|
+
assert.equal(byId(report, "openai-chat").status, "passed");
|
|
38
|
+
assert.equal(byId(report, "generic-acp").status, "passed");
|
|
39
|
+
assert.ok(byId(report, "codex-responses").evidence.includes("sentinel"));
|
|
40
|
+
assert.ok(byId(report, "codex-responses").evidence.includes("judge_synthesis"));
|
|
41
|
+
assert.equal(byId(report, "codex-acp").status, "blocked");
|
|
42
|
+
assert.equal(byId(report, "claude-acp").status, "blocked");
|
|
43
|
+
assert.equal(byId(report, "cursor-acp").status, "blocked");
|
|
44
|
+
assert.equal(byId(report, "cursor-acp").reason, "cursorkit_backend_not_running");
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await gateway.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test("acceptance uses injected adapter outcomes when provided", async () => {
|
|
51
|
+
const gateway = await startFusionGateway({ runner: sentinelRunner(), defaultModel: "fusion-panel" });
|
|
52
|
+
try {
|
|
53
|
+
const report = await runFrontDoorAcceptance({
|
|
54
|
+
gatewayUrl: gateway.url(),
|
|
55
|
+
sentinel: SENTINEL,
|
|
56
|
+
acpRunner: sentinelAcpRunner(),
|
|
57
|
+
codexAcp: async () => ({ id: "codex-acp", status: "passed", evidence: ["sentinel"] }),
|
|
58
|
+
claudeAcp: async () => ({
|
|
59
|
+
id: "claude-acp",
|
|
60
|
+
status: "skipped_with_reason",
|
|
61
|
+
reason: "claude_credit_or_credential_blocked",
|
|
62
|
+
evidence: []
|
|
63
|
+
}),
|
|
64
|
+
cursorAcp: async () => ({ id: "cursor-acp", status: "passed", evidence: ["sentinel"] })
|
|
65
|
+
});
|
|
66
|
+
assert.equal(byId(report, "codex-acp").status, "passed");
|
|
67
|
+
assert.equal(byId(report, "claude-acp").status, "skipped_with_reason");
|
|
68
|
+
assert.equal(byId(report, "claude-acp").reason, "claude_credit_or_credential_blocked");
|
|
69
|
+
assert.equal(byId(report, "cursor-acp").status, "passed");
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await gateway.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test("acceptance fails a front door when the sentinel is absent", async () => {
|
|
76
|
+
const gateway = await startFusionGateway({
|
|
77
|
+
runner: async (input) => ({
|
|
78
|
+
finalOutput: `no marker for ${input.dialect}`,
|
|
79
|
+
runId: "run_x",
|
|
80
|
+
status: "succeeded",
|
|
81
|
+
evidence: []
|
|
82
|
+
}),
|
|
83
|
+
defaultModel: "fusion-panel"
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const report = await runFrontDoorAcceptance({ gatewayUrl: gateway.url(), sentinel: SENTINEL });
|
|
87
|
+
assert.equal(byId(report, "codex-responses").status, "failed");
|
|
88
|
+
assert.equal(byId(report, "generic-acp").status, "blocked");
|
|
89
|
+
assert.equal(byId(report, "generic-acp").reason, "acp_runner_not_configured");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await gateway.close();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|