import test from "node:test"; import assert from "node:assert/strict"; const BASE_URL = "http://localhost:20128"; // #9320 (`fix(security): require auth for /v1/models when management auth is // configured`) inverted the default: the `/v1` catalog reads are now gated // whenever `isAuthRequired()` is true instead of only when // `settings.requireAuthForModels === true`. The integration CI job sets // `INITIAL_PASSWORD`, which flips `isAuthRequired()` on, so unauthenticated // catalog reads answer 401 there while they answer 200 on a bare dev box. // These are SHAPE contracts, so authenticate them with the deployment env key // (`isConfiguredEnvApiKey` → `validateApiKey` returns true) and let // tests/unit/v1-models-auth-leak-9320.test.ts own the auth-gate contract. const TEST_API_KEY = "sk-v1-contracts-behavior-test-key"; const previousEnvApiKey = process.env.OMNIROUTE_API_KEY; process.env.OMNIROUTE_API_KEY = TEST_API_KEY; test.after(() => { if (previousEnvApiKey === undefined) delete process.env.OMNIROUTE_API_KEY; else process.env.OMNIROUTE_API_KEY = previousEnvApiKey; }); function authedRequest(path: string): Request { return new Request(`${BASE_URL}${path}`, { method: "GET", headers: { Authorization: `Bearer ${TEST_API_KEY}` }, }); } test("contract: /api/v1 OPTIONS exposes CORS and allowed methods", async () => { const { OPTIONS } = await import("../../src/app/api/v1/route.ts"); const response = await OPTIONS(); assert.equal(response.status, 200); assert.ok(response.headers.has("Access-Control-Allow-Methods")); }); test("contract: /api/v1/embeddings OPTIONS exposes POST/GET/OPTIONS", async () => { const { OPTIONS } = await import("../../src/app/api/v1/embeddings/route.ts"); const response = await OPTIONS(); const allowMethods = response.headers.get("Access-Control-Allow-Methods") || ""; assert.equal(response.status, 200); assert.ok(allowMethods.includes("GET")); assert.ok(allowMethods.includes("POST")); assert.ok(allowMethods.includes("OPTIONS")); }); test("contract: /api/v1 and /api/v1/models return consistent model IDs", async () => { const [{ GET: getV1 }, { GET: getV1Models }] = await Promise.all([ import("../../src/app/api/v1/route.ts"), import("../../src/app/api/v1/models/route.ts"), ]); const [v1Response, v1ModelsResponse] = await Promise.all([ getV1(authedRequest("/api/v1")), getV1Models(authedRequest("/api/v1/models")), ]); assert.equal(v1Response.status, 200); assert.equal(v1ModelsResponse.status, 200); const v1Body = (await v1Response.json()) as any; const v1ModelsBody = (await v1ModelsResponse.json()) as any; assert.equal(v1Body.object, "list"); assert.equal(v1ModelsBody.object, "list"); assert.ok(Array.isArray(v1Body.data)); assert.ok(Array.isArray(v1ModelsBody.data)); const v1Ids = [...new Set(v1Body.data.map((item: any) => item.id))].sort(); const v1ModelsIds = [...new Set(v1ModelsBody.data.map((item: any) => item.id))].sort(); assert.deepEqual(v1Ids, v1ModelsIds); }); test("contract: /api/v1/models returns OpenAI-compatible model shape", async () => { const { GET: getV1Models } = await import("../../src/app/api/v1/models/route.ts"); const response = await getV1Models(authedRequest("/api/v1/models")); assert.equal(response.status, 200); const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); // In CI environments without provider connections, models list may be empty — skip shape check if (body.data.length > 0) { const first = body.data[0]; assert.equal(typeof first.id, "string"); assert.equal(first.object, "model"); assert.equal(typeof first.created, "number"); assert.equal(typeof first.owned_by, "string"); } }); test("contract: /api/v1/embeddings GET returns embedding model listing shape", async () => { const { GET: getEmbeddings } = await import("../../src/app/api/v1/embeddings/route.ts"); const response = await getEmbeddings(authedRequest("/api/v1/embeddings")); assert.equal(response.status, 200); const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); // In CI environments without provider connections, the filtered specialty catalog may be empty. if (body.data.length > 0) { const first = body.data[0]; assert.equal(first.object, "model"); assert.equal(first.type, "embedding"); assert.equal(typeof first.id, "string"); assert.equal(typeof first.owned_by, "string"); } }); test("contract: /api/v1/images/generations GET returns image model listing shape", async () => { const { GET: getImageModels } = await import("../../src/app/api/v1/images/generations/route.ts"); const response = await getImageModels(authedRequest("/api/v1/images/generations")); assert.equal(response.status, 200); const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); // In CI environments without provider connections, the filtered specialty catalog may be empty. if (body.data.length > 0) { const first = body.data[0]; assert.equal(first.object, "model"); assert.equal(first.type, "image"); assert.equal(typeof first.id, "string"); assert.equal(typeof first.owned_by, "string"); } }); test("contract: /api/v1/messages/count_tokens returns 400 on invalid JSON", async () => { const { POST: countTokens } = await import("../../src/app/api/v1/messages/count_tokens/route.ts"); const response = await countTokens( new Request(`${BASE_URL}/api/v1/messages/count_tokens`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "not-json", }) ); assert.equal(response.status, 400); const body = (await response.json()) as any; assert.ok(body.error, "error payload should exist"); assert.ok( typeof body.error === "string" || typeof body.error === "object", "error payload should be string or object" ); }); test("contract: /api/v1/messages/count_tokens rejects empty messages payload", async () => { const { POST: countTokens } = await import("../../src/app/api/v1/messages/count_tokens/route.ts"); const response = await countTokens( new Request(`${BASE_URL}/api/v1/messages/count_tokens`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [] }), }) ); assert.equal(response.status, 400); const body = (await response.json()) as any; assert.ok(body.error, "error payload should exist"); assert.ok( typeof body.error === "string" || typeof body.error === "object", "error payload should be string or object" ); }); test("contract: /api/v1/messages/count_tokens computes token estimate from text content", async () => { const { POST: countTokens } = await import("../../src/app/api/v1/messages/count_tokens/route.ts"); const payload = { messages: [ { role: "user", content: "abcd" }, // 4 chars { role: "assistant", content: [{ type: "text", text: "12345678" }], // 8 chars }, ], }; const response = await countTokens( new Request(`${BASE_URL}/api/v1/messages/count_tokens`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) ); assert.equal(response.status, 200); const body = (await response.json()) as any; // Real tiktoken count (countTextTokens): "abcd" => 1, "12345678" => 3 (digits split). // The previous expectation (3) was the old ceil(chars/4) heuristic, replaced by the // tiktoken-based estimator; the accurate total for this payload is 4. assert.equal(body.input_tokens, 4); });