179 lines
6.5 KiB
TypeScript
179 lines
6.5 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const BASE_URL = "http://localhost:20128";
|
|
|
|
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(new Request(`${BASE_URL}/api/v1`, { method: "GET" })),
|
|
getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })),
|
|
]);
|
|
|
|
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(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" }));
|
|
|
|
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();
|
|
|
|
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();
|
|
|
|
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);
|
|
});
|