vignette/scripts/check-engine-gateway-contract.mjs
2026-06-28 20:12:05 +09:00

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);
}