512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
// ─── DB bootstrap (needed for getOrCreateApiKey) ──────────────────────────────
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ninerouter-exec-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.NODE_ENV = "test";
|
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
// Seed version_manager row so getOrCreateApiKey can read/write
|
|
db.prepare(
|
|
`INSERT OR IGNORE INTO version_manager
|
|
(tool, status, port, auto_start, auto_update, provider_expose)
|
|
VALUES ('9router', 'stopped', 20130, 0, 1, 0)`
|
|
).run();
|
|
|
|
// ─── Registry helpers ─────────────────────────────────────────────────────────
|
|
const { registerSupervisor, unregisterSupervisor } =
|
|
await import("../../src/lib/services/registry.ts");
|
|
const { ServiceSupervisor } = await import("../../src/lib/services/ServiceSupervisor.ts");
|
|
|
|
function makeFakeSupervisor(state: "running" | "stopped" | "error" | "starting" = "running") {
|
|
const sup = new ServiceSupervisor({
|
|
tool: "9router",
|
|
port: 20130,
|
|
spawnArgs: () => ({
|
|
command: process.execPath,
|
|
args: ["-e", "setTimeout(() => {}, 60000)"],
|
|
env: process.env,
|
|
cwd: process.cwd(),
|
|
}),
|
|
healthUrl: () => "http://127.0.0.1:20130/api/health",
|
|
healthIntervalMs: 2000,
|
|
stopTimeoutMs: 3000,
|
|
logsBufferBytes: 64 * 1024,
|
|
});
|
|
// Patch internal state without actually spawning a process
|
|
// @ts-ignore — accessing private field for test purposes
|
|
sup["state"] = state;
|
|
return sup;
|
|
}
|
|
|
|
// ─── Original env snapshot ────────────────────────────────────────────────────
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnv = { ...process.env };
|
|
|
|
function restoreEnv(key: string) {
|
|
if (originalEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalEnv[key];
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
restoreEnv("NINEROUTER_HOST");
|
|
restoreEnv("NINEROUTER_PORT");
|
|
unregisterSupervisor("9router");
|
|
});
|
|
|
|
describe("NineRouterExecutor", () => {
|
|
let NineRouterExecutor: typeof import("../../open-sse/executors/ninerouter.ts").NineRouterExecutor;
|
|
let NINEROUTER_FALLBACK_HINT: string;
|
|
let NINEROUTER_FALLBACK_HINT_HEADER: string;
|
|
|
|
before(async () => {
|
|
process.env.NINEROUTER_HOST = "";
|
|
process.env.NINEROUTER_PORT = "";
|
|
const mod = await import("../../open-sse/executors/ninerouter.ts");
|
|
NineRouterExecutor = mod.NineRouterExecutor;
|
|
NINEROUTER_FALLBACK_HINT = mod.NINEROUTER_FALLBACK_HINT;
|
|
NINEROUTER_FALLBACK_HINT_HEADER = mod.NINEROUTER_FALLBACK_HINT_HEADER;
|
|
});
|
|
|
|
describe("constructor / provider", () => {
|
|
it("exposes provider name '9router'", () => {
|
|
const exec = new NineRouterExecutor();
|
|
assert.equal(exec.getProvider(), "9router");
|
|
});
|
|
|
|
it("accepts explicit base URL", () => {
|
|
const exec = new NineRouterExecutor("http://10.0.0.1:9999");
|
|
const url = exec.buildUrl("model", true);
|
|
assert.equal(url, "http://10.0.0.1:9999/v1/chat/completions");
|
|
});
|
|
});
|
|
|
|
describe("buildUrl", () => {
|
|
it("defaults to 127.0.0.1:20130", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const url = exec.buildUrl("any-model", true);
|
|
assert.equal(url, "http://127.0.0.1:20130/v1/chat/completions");
|
|
});
|
|
|
|
it("respects NINEROUTER_HOST and NINEROUTER_PORT env vars", () => {
|
|
process.env.NINEROUTER_HOST = "10.0.0.2";
|
|
process.env.NINEROUTER_PORT = "29999";
|
|
const exec = new NineRouterExecutor();
|
|
const url = exec.buildUrl("model", true);
|
|
assert.equal(url, "http://10.0.0.2:29999/v1/chat/completions");
|
|
});
|
|
});
|
|
|
|
describe("buildHeaders", () => {
|
|
it("returns Content-Type only when no credentials given", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({});
|
|
assert.equal(headers["Content-Type"], "application/json");
|
|
assert.equal(headers["Authorization"], undefined);
|
|
});
|
|
|
|
it("adds Authorization from apiKey", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({ apiKey: "nr_abc123" });
|
|
assert.equal(headers["Authorization"], "Bearer nr_abc123");
|
|
});
|
|
|
|
it("falls back to accessToken when no apiKey", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({ accessToken: "tok_xyz" });
|
|
assert.equal(headers["Authorization"], "Bearer tok_xyz");
|
|
});
|
|
|
|
it("prefers apiKey over accessToken", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({ apiKey: "nr_key", accessToken: "tok" });
|
|
assert.equal(headers["Authorization"], "Bearer nr_key");
|
|
});
|
|
|
|
it("sets Accept: text/event-stream for streaming", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({}, true);
|
|
assert.equal(headers["Accept"], "text/event-stream");
|
|
});
|
|
|
|
it("omits Accept for non-streaming", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const headers = exec.buildHeaders({}, false);
|
|
assert.equal(headers["Accept"], undefined);
|
|
});
|
|
});
|
|
|
|
describe("transformRequest", () => {
|
|
it("copies model into body when different", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const result = exec.transformRequest(
|
|
"new-model",
|
|
{ model: "old", messages: [] },
|
|
true,
|
|
{}
|
|
) as Record<string, unknown>;
|
|
assert.equal(result.model, "new-model");
|
|
assert.deepEqual(result.messages, []);
|
|
});
|
|
|
|
it("passes body unchanged when model already matches", () => {
|
|
const exec = new NineRouterExecutor();
|
|
const result = exec.transformRequest(
|
|
"same",
|
|
{ model: "same", messages: [] },
|
|
true,
|
|
{}
|
|
) as Record<string, unknown>;
|
|
assert.equal(result.model, "same");
|
|
});
|
|
|
|
it("returns non-object body as-is", () => {
|
|
const exec = new NineRouterExecutor();
|
|
assert.equal(exec.transformRequest("m", "raw-string", true, {}), "raw-string");
|
|
});
|
|
|
|
it("returns null as-is", () => {
|
|
const exec = new NineRouterExecutor();
|
|
assert.equal(exec.transformRequest("m", null, true, {}), null);
|
|
});
|
|
});
|
|
|
|
describe("Anthropic-shape detection → endpoint selection", () => {
|
|
async function captureUrl(body: unknown) {
|
|
const sup = makeFakeSupervisor("running");
|
|
registerSupervisor(sup);
|
|
let capturedUrl = "";
|
|
globalThis.fetch = async (url: string | URL | Request) => {
|
|
capturedUrl = String(url);
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
await exec.execute({ model: "m", body, stream: true, credentials: {} });
|
|
return capturedUrl;
|
|
}
|
|
|
|
it("uses /v1/messages for Anthropic shape (top-level system)", async () => {
|
|
const url = await captureUrl({
|
|
model: "claude-opus-4-7",
|
|
system: [{ type: "text", text: "be helpful" }],
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
assert.equal(url, "http://127.0.0.1:20130/v1/messages");
|
|
});
|
|
|
|
it("uses /v1/messages for Anthropic shape (content array in messages)", async () => {
|
|
const url = await captureUrl({
|
|
model: "claude-opus-4-7",
|
|
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
});
|
|
assert.equal(url, "http://127.0.0.1:20130/v1/messages");
|
|
});
|
|
|
|
it("uses /v1/messages for Anthropic shape (top-level thinking)", async () => {
|
|
const url = await captureUrl({
|
|
model: "claude-opus-4-7",
|
|
thinking: { type: "enabled", budget_tokens: 5000 },
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
assert.equal(url, "http://127.0.0.1:20130/v1/messages");
|
|
});
|
|
|
|
it("uses /v1/chat/completions for OpenAI shape (string content)", async () => {
|
|
const url = await captureUrl({
|
|
model: "gpt-5",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
assert.equal(url, "http://127.0.0.1:20130/v1/chat/completions");
|
|
});
|
|
});
|
|
|
|
describe("execute — G-01: supervisor check + dynamic lookup + prefix strip", () => {
|
|
it("returns 503 with code service_not_running when supervisor is null (not registered)", async () => {
|
|
// No supervisor registered → getSupervisor returns null
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.execute({
|
|
model: "9router/cx/gpt-5-mini",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
assert.equal(result.response.status, 503);
|
|
const body = await result.response.json();
|
|
assert.equal(body.error.code, "service_not_running");
|
|
});
|
|
|
|
it("returns 503 with code service_not_running when supervisor state is stopped", async () => {
|
|
const sup = makeFakeSupervisor("stopped");
|
|
registerSupervisor(sup);
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.execute({
|
|
model: "9router/auto/sonnet",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
assert.equal(result.response.status, 503);
|
|
const body = await result.response.json();
|
|
assert.equal(body.error.code, "service_not_running");
|
|
});
|
|
|
|
it("returns 503 with code service_not_running when supervisor state is error", async () => {
|
|
const sup = makeFakeSupervisor("error");
|
|
registerSupervisor(sup);
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.execute({
|
|
model: "9router/auto/sonnet",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
assert.equal(result.response.status, 503);
|
|
const body = await result.response.json();
|
|
assert.equal(body.error.code, "service_not_running");
|
|
});
|
|
|
|
it("G-02: 503 service_not_running response includes X-Omni-Fallback-Hint: connection_cooldown", async () => {
|
|
// No supervisor → 503
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.execute({
|
|
model: "9router/auto/sonnet",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
assert.equal(result.response.status, 503);
|
|
assert.equal(
|
|
result.response.headers.get(NINEROUTER_FALLBACK_HINT_HEADER),
|
|
NINEROUTER_FALLBACK_HINT
|
|
);
|
|
});
|
|
|
|
it("strips 9router/ prefix from model id before sending upstream", async () => {
|
|
const sup = makeFakeSupervisor("running");
|
|
registerSupervisor(sup);
|
|
let capturedBody: Record<string, unknown> = {};
|
|
globalThis.fetch = async (_url: string | URL | Request, opts: RequestInit) => {
|
|
capturedBody = JSON.parse(opts.body as string);
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
await exec.execute({
|
|
model: "9router/cx/gpt-5-mini",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
// upstream model should be "cx/gpt-5-mini" not "9router/cx/gpt-5-mini"
|
|
assert.equal(capturedBody.model, "cx/gpt-5-mini");
|
|
});
|
|
|
|
it("model without 9router/ prefix is sent as-is", async () => {
|
|
const sup = makeFakeSupervisor("running");
|
|
registerSupervisor(sup);
|
|
let capturedBody: Record<string, unknown> = {};
|
|
globalThis.fetch = async (_url: string | URL | Request, opts: RequestInit) => {
|
|
capturedBody = JSON.parse(opts.body as string);
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
await exec.execute({
|
|
model: "cx/gpt-5-mini",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
assert.equal(capturedBody.model, "cx/gpt-5-mini");
|
|
});
|
|
|
|
it("re-reads port from registry on each call (not cached in constructor)", async () => {
|
|
// First supervisor at port 20130
|
|
const sup1 = makeFakeSupervisor("running");
|
|
registerSupervisor(sup1);
|
|
const capturedUrls: string[] = [];
|
|
globalThis.fetch = async (url: string | URL | Request) => {
|
|
capturedUrls.push(String(url));
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:19999"); // constructor base URL is irrelevant
|
|
await exec.execute({
|
|
model: "9router/m",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
// Should use port from supervisor.getStatus().port (20130) not constructor
|
|
assert.ok(
|
|
capturedUrls[0].includes(":20130"),
|
|
`Expected port 20130 in URL, got: ${capturedUrls[0]}`
|
|
);
|
|
});
|
|
|
|
it("re-reads apiKey on each call (not cached in constructor)", async () => {
|
|
const sup = makeFakeSupervisor("running");
|
|
registerSupervisor(sup);
|
|
const capturedAuthHeaders: string[] = [];
|
|
globalThis.fetch = async (_url: string | URL | Request, opts: RequestInit) => {
|
|
const h = opts.headers as Record<string, string>;
|
|
capturedAuthHeaders.push(h["Authorization"] ?? "");
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
// Call twice — both should get a Bearer token from getOrCreateApiKey
|
|
await exec.execute({
|
|
model: "9router/m",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
await exec.execute({
|
|
model: "9router/m",
|
|
body: { messages: [] },
|
|
stream: false,
|
|
credentials: {},
|
|
});
|
|
// Both calls must have a Bearer nr_ token (never undefined)
|
|
assert.ok(
|
|
capturedAuthHeaders.every((h) => h.startsWith("Bearer nr_")),
|
|
`Expected all auth headers to start with 'Bearer nr_', got: ${capturedAuthHeaders.join(", ")}`
|
|
);
|
|
// Both calls get the same key (stable getOrCreate)
|
|
assert.equal(capturedAuthHeaders[0], capturedAuthHeaders[1]);
|
|
});
|
|
});
|
|
|
|
describe("execute — existing behavior (supervisor running)", () => {
|
|
beforeEach(() => {
|
|
const sup = makeFakeSupervisor("running");
|
|
registerSupervisor(sup);
|
|
});
|
|
|
|
it("sends correct method, headers, and body", async () => {
|
|
let capturedUrl = "",
|
|
capturedOptions: RequestInit = {};
|
|
globalThis.fetch = async (url: string | URL | Request, opts: RequestInit) => {
|
|
capturedUrl = String(url);
|
|
capturedOptions = opts;
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
await exec.execute({
|
|
model: "test-model",
|
|
body: { messages: [{ role: "user", content: "hello" }] },
|
|
stream: true,
|
|
credentials: { apiKey: "nr_secret" },
|
|
});
|
|
|
|
assert.equal(capturedUrl, "http://127.0.0.1:20130/v1/chat/completions");
|
|
assert.equal(capturedOptions.method, "POST");
|
|
assert.ok(capturedOptions.signal);
|
|
const headers = capturedOptions.headers as Record<string, string>;
|
|
// apiKey from getOrCreateApiKey overrides credentials.apiKey
|
|
assert.ok(headers["Authorization"].startsWith("Bearer nr_"));
|
|
const body = JSON.parse(capturedOptions.body as string);
|
|
assert.equal(body.messages[0].content, "hello");
|
|
});
|
|
|
|
it("merges upstreamExtraHeaders", async () => {
|
|
let capturedHeaders: Record<string, string> = {};
|
|
globalThis.fetch = async (_url: string | URL | Request, opts: RequestInit) => {
|
|
capturedHeaders = opts.headers as Record<string, string>;
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
await exec.execute({
|
|
model: "m",
|
|
body: {},
|
|
stream: false,
|
|
credentials: {},
|
|
upstreamExtraHeaders: { "X-Custom-Header": "yes" },
|
|
});
|
|
|
|
assert.equal(capturedHeaders["X-Custom-Header"], "yes");
|
|
});
|
|
|
|
it("returns { response, url, headers, transformedBody }", async () => {
|
|
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.execute({
|
|
model: "m",
|
|
body: { messages: [] },
|
|
stream: true,
|
|
credentials: {},
|
|
});
|
|
|
|
assert.ok(result.response);
|
|
assert.ok(result.url);
|
|
assert.ok(result.headers);
|
|
assert.ok(result.transformedBody !== undefined);
|
|
});
|
|
});
|
|
|
|
describe("healthCheck", () => {
|
|
it("probes /api/health and returns ok:true on 200", async () => {
|
|
let capturedUrl = "";
|
|
globalThis.fetch = async (url: string | URL | Request) => {
|
|
capturedUrl = String(url);
|
|
return new Response("{}", { status: 200 });
|
|
};
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.healthCheck();
|
|
|
|
assert.equal(capturedUrl, "http://127.0.0.1:20130/api/health");
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.error, undefined);
|
|
assert.ok(result.latencyMs >= 0);
|
|
});
|
|
|
|
it("returns ok:false with error message on non-2xx", async () => {
|
|
globalThis.fetch = async () => new Response("", { status: 503 });
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.healthCheck();
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.error, "HTTP 503");
|
|
});
|
|
|
|
it("returns ok:false with error message on network failure", async () => {
|
|
globalThis.fetch = async () => {
|
|
throw new Error("ECONNREFUSED");
|
|
};
|
|
|
|
const exec = new NineRouterExecutor("http://127.0.0.1:20130");
|
|
const result = await exec.healthCheck();
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.error?.includes("ECONNREFUSED"));
|
|
});
|
|
});
|
|
|
|
describe("getExecutor registration", () => {
|
|
it("getExecutor('9router') returns a NineRouterExecutor", async () => {
|
|
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
|
const exec = getExecutor("9router");
|
|
assert.equal(exec.getProvider(), "9router");
|
|
});
|
|
|
|
it("getExecutor('nr') alias resolves to NineRouterExecutor", async () => {
|
|
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
|
const exec = getExecutor("nr");
|
|
assert.equal(exec.getProvider(), "9router");
|
|
});
|
|
});
|
|
});
|