279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import assert from "node:assert/strict";
|
|
|
|
const DEFAULT_FIXTURE = "apps/api/engine_gateway/golden/engine_gateway_contract.v1.json";
|
|
const DEFAULT_SCHEMA = "apps/api/engine_gateway/golden/engine_gateway_schema.v1.json";
|
|
const EXPECTED_EVENTS = ["token", "done", "error"];
|
|
|
|
function usage() {
|
|
return [
|
|
"Usage: node scripts/check-engine-gateway-contract.mjs [--fixture path] [--schema path] [--json]",
|
|
"",
|
|
"Validates the engine gateway v1 golden fixture without importing Python code.",
|
|
].join("\n");
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
fixture: DEFAULT_FIXTURE,
|
|
schema: DEFAULT_SCHEMA,
|
|
json: false,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "--fixture") {
|
|
args.fixture = argv[i + 1];
|
|
i += 1;
|
|
} else if (arg === "--schema") {
|
|
args.schema = argv[i + 1];
|
|
i += 1;
|
|
} else if (arg === "--json") {
|
|
args.json = true;
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
console.log(usage());
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}\n${usage()}`);
|
|
}
|
|
}
|
|
|
|
if (!args.fixture || !args.schema) {
|
|
throw new Error(`Missing path argument.\n${usage()}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(readFileSync(resolve(path), "utf8"));
|
|
}
|
|
|
|
function assertPlainObject(value, label) {
|
|
assert.equal(typeof value, "object", `${label} must be an object`);
|
|
assert.notEqual(value, null, `${label} must not be null`);
|
|
assert.equal(Array.isArray(value), false, `${label} must not be an array`);
|
|
}
|
|
|
|
function assertOnlyKeys(value, allowedKeys, label) {
|
|
for (const key of Object.keys(value)) {
|
|
assert.ok(allowedKeys.includes(key), `${label}.${key} is not in the v1 contract`);
|
|
}
|
|
}
|
|
|
|
function assertRequired(value, requiredKeys, label) {
|
|
for (const key of requiredKeys) {
|
|
assert.ok(Object.hasOwn(value, key), `${label}.${key} is required`);
|
|
}
|
|
}
|
|
|
|
function assertNullableObject(value, label) {
|
|
if (value === null || value === undefined) return;
|
|
assertPlainObject(value, label);
|
|
}
|
|
|
|
function assertInteger(value, label) {
|
|
assert.equal(Number.isInteger(value), true, `${label} must be an integer`);
|
|
}
|
|
|
|
function assertNumber(value, label) {
|
|
assert.equal(typeof value, "number", `${label} must be a number`);
|
|
assert.equal(Number.isFinite(value), true, `${label} must be finite`);
|
|
}
|
|
|
|
function assertString(value, label) {
|
|
assert.equal(typeof value, "string", `${label} must be a string`);
|
|
}
|
|
|
|
function schemaDef(schema, name) {
|
|
const def = schema?.$defs?.[name];
|
|
assertPlainObject(def, `$defs.${name}`);
|
|
return def;
|
|
}
|
|
|
|
function assertRootShape(schema, fixture) {
|
|
assert.deepEqual(schema["x-engine-gateway-sse-events"], EXPECTED_EVENTS);
|
|
assertRequired(fixture, schema.required, "fixture");
|
|
assertOnlyKeys(fixture, Object.keys(schema.properties), "fixture");
|
|
assert.equal(fixture.version, schema.properties.version.const);
|
|
}
|
|
|
|
function assertGenerateRequest(schema, request) {
|
|
const requestDef = schemaDef(schema, "GenerateRequest");
|
|
const messageDef = schemaDef(schema, "EngineMessage");
|
|
const allowedRequestKeys = Object.keys(requestDef.properties);
|
|
const messageRoles = messageDef.properties.role.enum;
|
|
const aiRoles = requestDef.properties.ai_role.enum;
|
|
|
|
assertPlainObject(request, "generate_request");
|
|
assertRequired(request, requestDef.required, "generate_request");
|
|
assertOnlyKeys(request, allowedRequestKeys, "generate_request");
|
|
assert.ok(aiRoles.includes(request.ai_role), "generate_request.ai_role must be a known role");
|
|
assert.ok(Array.isArray(request.messages), "generate_request.messages must be an array");
|
|
assert.ok(request.messages.length > 0, "generate_request.messages must not be empty");
|
|
|
|
request.messages.forEach((message, index) => {
|
|
assertPlainObject(message, `generate_request.messages[${index}]`);
|
|
assertRequired(message, messageDef.required, `generate_request.messages[${index}]`);
|
|
assertOnlyKeys(message, Object.keys(messageDef.properties), `generate_request.messages[${index}]`);
|
|
assert.ok(messageRoles.includes(message.role), `generate_request.messages[${index}].role is invalid`);
|
|
assertString(message.content, `generate_request.messages[${index}].content`);
|
|
if (Object.hasOwn(message, "cache")) {
|
|
assert.equal(typeof message.cache, "boolean", `generate_request.messages[${index}].cache must be boolean`);
|
|
}
|
|
});
|
|
|
|
if (Object.hasOwn(request, "model")) {
|
|
assert.ok(request.model === null || typeof request.model === "string", "generate_request.model must be string or null");
|
|
}
|
|
if (Object.hasOwn(request, "session_id")) {
|
|
assert.ok(
|
|
request.session_id === null || typeof request.session_id === "string",
|
|
"generate_request.session_id must be string or null",
|
|
);
|
|
}
|
|
if (Object.hasOwn(request, "max_tokens")) assertInteger(request.max_tokens, "generate_request.max_tokens");
|
|
if (Object.hasOwn(request, "temperature")) assertNumber(request.temperature, "generate_request.temperature");
|
|
if (Object.hasOwn(request, "structured_schema")) assertNullableObject(request.structured_schema, "generate_request.structured_schema");
|
|
if (Object.hasOwn(request, "metadata")) assertPlainObject(request.metadata, "generate_request.metadata");
|
|
}
|
|
|
|
function assertGenerateResponse(schema, response) {
|
|
const responseDef = schemaDef(schema, "GenerateResponse");
|
|
assertPlainObject(response, "generate_response");
|
|
assertRequired(response, responseDef.required, "generate_response");
|
|
assertOnlyKeys(response, Object.keys(responseDef.properties), "generate_response");
|
|
assertString(response.text, "generate_response.text");
|
|
assertString(response.model, "generate_response.model");
|
|
assertString(response.provider, "generate_response.provider");
|
|
if (Object.hasOwn(response, "tokens_in")) assertInteger(response.tokens_in, "generate_response.tokens_in");
|
|
if (Object.hasOwn(response, "tokens_out")) assertInteger(response.tokens_out, "generate_response.tokens_out");
|
|
if (Object.hasOwn(response, "cost_usd")) assertNumber(response.cost_usd, "generate_response.cost_usd");
|
|
if (Object.hasOwn(response, "inference_geo")) {
|
|
assert.ok(
|
|
response.inference_geo === null || typeof response.inference_geo === "string",
|
|
"generate_response.inference_geo must be string or null",
|
|
);
|
|
}
|
|
if (Object.hasOwn(response, "structured")) assertNullableObject(response.structured, "generate_response.structured");
|
|
}
|
|
|
|
function parseSseFrame(frame) {
|
|
assertString(frame, "stream_frame");
|
|
let event = null;
|
|
const dataLines = [];
|
|
|
|
for (const line of frame.split(/\r?\n/)) {
|
|
if (line === "") continue;
|
|
if (line.startsWith("event:")) {
|
|
event = line.slice("event:".length).trim();
|
|
} else if (line.startsWith("data:")) {
|
|
dataLines.push(line.slice("data:".length).trimStart());
|
|
}
|
|
}
|
|
|
|
assert.ok(event, "stream frame must contain an event line");
|
|
assert.ok(EXPECTED_EVENTS.includes(event), `unknown stream event: ${event}`);
|
|
assert.ok(dataLines.length > 0, `stream event ${event} must contain data`);
|
|
return packetFromEventData(event, dataLines.join("\n"));
|
|
}
|
|
|
|
function packetFromEventData(event, data) {
|
|
if (data === "[DONE]") return null;
|
|
|
|
if (event === "token") {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(data);
|
|
} catch {
|
|
payload = { text: data };
|
|
}
|
|
assertPlainObject(payload, "token payload");
|
|
assertString(payload.text, "token payload.text");
|
|
assertOnlyKeys(payload, ["text"], "token payload");
|
|
return { event, payload };
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(data);
|
|
} catch (error) {
|
|
throw new Error(`stream event ${event} must contain JSON data: ${error.message}`);
|
|
}
|
|
|
|
if (event === "done") {
|
|
assertPlainObject(payload, "done payload");
|
|
assertRequired(payload, ["provider", "model"], "done payload");
|
|
assertOnlyKeys(payload, ["provider", "model", "tokens_in", "tokens_out", "cost_usd", "turns"], "done payload");
|
|
assertString(payload.provider, "done payload.provider");
|
|
assertString(payload.model, "done payload.model");
|
|
if (Object.hasOwn(payload, "tokens_in")) assertInteger(payload.tokens_in, "done payload.tokens_in");
|
|
if (Object.hasOwn(payload, "tokens_out")) assertInteger(payload.tokens_out, "done payload.tokens_out");
|
|
if (Object.hasOwn(payload, "cost_usd")) assertNumber(payload.cost_usd, "done payload.cost_usd");
|
|
if (Object.hasOwn(payload, "turns")) assertInteger(payload.turns, "done payload.turns");
|
|
return { event, payload };
|
|
}
|
|
|
|
assert.equal(event, "error");
|
|
assertPlainObject(payload, "error payload");
|
|
assertRequired(payload, ["detail"], "error payload");
|
|
assertOnlyKeys(payload, ["detail"], "error payload");
|
|
assertString(payload.detail, "error payload.detail");
|
|
return { event, payload };
|
|
}
|
|
|
|
function assertStreamContract(fixture) {
|
|
assert.ok(Array.isArray(fixture.stream_frames), "stream_frames must be an array");
|
|
assert.ok(Array.isArray(fixture.stream_packets), "stream_packets must be an array");
|
|
assert.ok(Array.isArray(fixture.compatibility_lines), "compatibility_lines must be an array");
|
|
|
|
const decodedPackets = [];
|
|
for (const frame of fixture.stream_frames) {
|
|
const packet = parseSseFrame(frame);
|
|
if (packet) decodedPackets.push(packet);
|
|
}
|
|
assert.deepEqual(decodedPackets, fixture.stream_packets);
|
|
|
|
for (const line of fixture.compatibility_lines) {
|
|
assertString(line, "compatibility line");
|
|
if (line.trim() === "data: [DONE]") continue;
|
|
throw new Error(`unsupported compatibility line: ${line}`);
|
|
}
|
|
}
|
|
|
|
function validateContract(schema, fixture) {
|
|
assertRootShape(schema, fixture);
|
|
assertGenerateRequest(schema, fixture.generate_request);
|
|
assertGenerateResponse(schema, fixture.generate_response);
|
|
assertStreamContract(fixture);
|
|
return {
|
|
version: fixture.version,
|
|
streamEvents: schema["x-engine-gateway-sse-events"],
|
|
streamFrameCount: fixture.stream_frames.length,
|
|
decodedPacketCount: fixture.stream_packets.length,
|
|
compatibilityLineCount: fixture.compatibility_lines.length,
|
|
};
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const schema = readJson(args.schema);
|
|
const fixture = readJson(args.fixture);
|
|
const result = validateContract(schema, fixture);
|
|
|
|
if (args.json) {
|
|
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
} else {
|
|
console.log(
|
|
`engine_gateway_contract.v${result.version} OK: ${result.decodedPacketCount} packets, ${result.compatibilityLineCount} compatibility lines`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|