160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import http from "node:http";
|
|
|
|
const { createOmnirouteWsBridge } = await import("../../scripts/dev/v1-ws-bridge.mjs");
|
|
|
|
function listen(server) {
|
|
return new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function close(server) {
|
|
return new Promise((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
function readRequestBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 10 } = {}) {
|
|
const startedAt = Date.now();
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
const value = predicate();
|
|
if (value) {
|
|
clearInterval(timer);
|
|
resolve(value);
|
|
return;
|
|
}
|
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
clearInterval(timer);
|
|
reject(new Error("Timed out waiting for condition"));
|
|
}
|
|
} catch (error: any) {
|
|
clearInterval(timer);
|
|
reject(error);
|
|
}
|
|
}, intervalMs);
|
|
});
|
|
}
|
|
|
|
test("v1 ws bridge streams correlated request chunks and survives protocol errors", async () => {
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
|
|
if (url.pathname === "/api/v1/ws" && url.searchParams.get("handshake") === "1") {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, path: "/v1/ws", wsAuth: false, authenticated: false }));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/chat/completions" || url.pathname === "/v1/messages") {
|
|
const body = JSON.parse(((await readRequestBody(req)) as any) || "{}");
|
|
const firstMessage = Array.isArray(body.messages) ? body.messages[0] : null;
|
|
const content = typeof firstMessage?.content === "string" ? firstMessage.content : body.model;
|
|
|
|
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
|
|
res.write(
|
|
`data: ${JSON.stringify({ choices: [{ delta: { content: `${content}:part1` } }] })}\n\n`
|
|
);
|
|
setTimeout(() => {
|
|
res.write(
|
|
`data: ${JSON.stringify({ choices: [{ delta: { content: `${content}:part2` } }] })}\n\n`
|
|
);
|
|
res.end("data: [DONE]\n\n");
|
|
}, 10);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ error: "not_found" }));
|
|
});
|
|
|
|
const port = await listen(server);
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const bridge = createOmnirouteWsBridge({ baseUrl, pingIntervalMs: 1000, idleTimeoutMs: 10000 });
|
|
|
|
server.on("upgrade", async (req, socket, head) => {
|
|
const handled = await bridge.handleUpgrade(req, socket, head);
|
|
if (!handled && !socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
});
|
|
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/v1/ws`);
|
|
const messages = [];
|
|
const errors = [];
|
|
ws.addEventListener("message", (event) => {
|
|
messages.push(JSON.parse(String(event.data)));
|
|
});
|
|
ws.addEventListener("error", (event) => {
|
|
errors.push(event);
|
|
});
|
|
|
|
await waitFor(() => messages.find((entry) => entry.type === "session.ready"));
|
|
|
|
ws.send("{bad json");
|
|
await waitFor(() => messages.find((entry) => entry.type === "protocol.error"));
|
|
assert.equal(ws.readyState, WebSocket.OPEN);
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "request",
|
|
id: "req-1",
|
|
payload: {
|
|
model: "openai/gpt-4.1-mini",
|
|
messages: [{ role: "user", content: "alpha" }],
|
|
},
|
|
})
|
|
);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "request",
|
|
id: "req-2",
|
|
endpoint: "/v1/messages",
|
|
payload: {
|
|
model: "anthropic/claude-3.7-sonnet",
|
|
messages: [{ role: "user", content: "beta" }],
|
|
},
|
|
})
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const completedIds = messages
|
|
.filter((entry) => entry.type === "response.completed")
|
|
.map((entry) => entry.id);
|
|
return completedIds.includes("req-1") && completedIds.includes("req-2");
|
|
});
|
|
|
|
const req1Chunks = messages
|
|
.filter((entry) => entry.type === "response.chunk" && entry.id === "req-1")
|
|
.map((entry) => entry.chunk);
|
|
const req2Chunks = messages
|
|
.filter((entry) => entry.type === "response.chunk" && entry.id === "req-2")
|
|
.map((entry) => entry.chunk);
|
|
|
|
assert.equal(errors.length, 0);
|
|
const req1Combined = req1Chunks.join("");
|
|
const req2Combined = req2Chunks.join("");
|
|
assert.match(req1Combined, /alpha:part1/);
|
|
assert.match(req1Combined, /alpha:part2/);
|
|
assert.match(req2Combined, /beta:part1/);
|
|
assert.match(req2Combined, /beta:part2/);
|
|
|
|
ws.close();
|
|
await close(server);
|
|
});
|