런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -5,7 +5,6 @@ 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 [
@ -86,14 +85,74 @@ function assertString(value, label) {
assert.equal(typeof value, "string", `${label} must be a string`);
}
function assertBoolean(value, label) {
assert.equal(typeof value, "boolean", `${label} must be a boolean`);
}
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);
function resolveSchemaRef(schema, ref, label) {
assertString(ref, label);
const prefix = "#/$defs/";
assert.ok(ref.startsWith(prefix), `${label} must reference a schema definition`);
return schemaDef(schema, ref.slice(prefix.length));
}
function streamEventSpecs(schema) {
const packetDef = schemaDef(schema, "StreamPacket");
assert.ok(Array.isArray(packetDef.oneOf), "$defs.StreamPacket.oneOf must be an array");
const specs = new Map();
for (const [index, variant] of packetDef.oneOf.entries()) {
assertPlainObject(variant, `$defs.StreamPacket.oneOf[${index}]`);
const event = variant.properties?.event?.const;
assertString(event, `$defs.StreamPacket.oneOf[${index}].properties.event.const`);
const payloadRef = variant.properties?.payload?.$ref;
const payloadDef = resolveSchemaRef(
schema,
payloadRef,
`$defs.StreamPacket.oneOf[${index}].properties.payload.$ref`,
);
assert.ok(!specs.has(event), `duplicate stream event spec: ${event}`);
specs.set(event, { event, payloadDef });
}
return specs;
}
function defaultModelSentinel(schema) {
const value = schema["x-engine-gateway-default-model-sentinel"];
assertString(value, "x-engine-gateway-default-model-sentinel");
assert.notEqual(value.trim(), "", "x-engine-gateway-default-model-sentinel must not be blank");
return value;
}
function assertDefaultModelSentinelUsage(fixture, sentinel) {
assert.equal(
fixture.generate_request?.model,
sentinel,
"golden generate_request.model must pin the gateway default-routing sentinel",
);
assert.notEqual(
fixture.generate_response?.model,
sentinel,
"generate_response.model must report the resolved provider model, not the default-routing sentinel",
);
for (const [index, packet] of (fixture.stream_packets ?? []).entries()) {
if (packet?.event !== "done") continue;
assert.notEqual(
packet.payload?.model,
sentinel,
`stream_packets[${index}].payload.model must report the resolved provider model`,
);
}
}
function assertRootShape(schema, fixture, streamSpecs) {
assert.deepEqual(schema["x-engine-gateway-sse-events"], [...streamSpecs.keys()]);
assertDefaultModelSentinelUsage(fixture, defaultModelSentinel(schema));
assertRequired(fixture, schema.required, "fixture");
assertOnlyKeys(fixture, Object.keys(schema.properties), "fixture");
assert.equal(fixture.version, schema.properties.version.const);
@ -159,7 +218,7 @@ function assertGenerateResponse(schema, response) {
if (Object.hasOwn(response, "structured")) assertNullableObject(response.structured, "generate_response.structured");
}
function parseSseFrame(frame) {
function parseSseFrame(frame, streamSpecs) {
assertString(frame, "stream_frame");
let event = null;
const dataLines = [];
@ -174,63 +233,70 @@ function parseSseFrame(frame) {
}
assert.ok(event, "stream frame must contain an event line");
assert.ok(EXPECTED_EVENTS.includes(event), `unknown stream event: ${event}`);
assert.ok(streamSpecs.has(event), `unknown stream event: ${event}`);
assert.ok(dataLines.length > 0, `stream event ${event} must contain data`);
return packetFromEventData(event, dataLines.join("\n"));
return packetFromEventData(event, dataLines.join("\n"), streamSpecs);
}
function packetFromEventData(event, data) {
function packetFromEventData(event, data, streamSpecs) {
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 === "token") {
payload = { text: data };
} else {
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");
const spec = streamSpecs.get(event);
assert.ok(spec, `unknown stream event: ${event}`);
assertPayloadMatchesSchema(payload, spec.payloadDef, `${event} payload`);
return { event, payload };
}
function assertStreamContract(fixture) {
function assertPayloadMatchesSchema(payload, payloadDef, label) {
assertPlainObject(payload, label);
assertRequired(payload, payloadDef.required ?? [], label);
const properties = payloadDef.properties ?? {};
assertOnlyKeys(payload, Object.keys(properties), label);
for (const [key, spec] of Object.entries(properties)) {
if (!Object.hasOwn(payload, key)) continue;
assertValueMatchesSchemaType(payload[key], spec, `${label}.${key}`);
}
}
function assertValueMatchesSchemaType(value, spec, label) {
const types = Array.isArray(spec.type) ? spec.type : [spec.type];
if (value === null && types.includes("null")) return;
if (types.includes("string")) {
assertString(value, label);
} else if (types.includes("integer")) {
assertInteger(value, label);
} else if (types.includes("number")) {
assertNumber(value, label);
} else if (types.includes("boolean")) {
assertBoolean(value, label);
} else if (types.includes("object")) {
assertPlainObject(value, label);
} else if (types.includes("array")) {
assert.ok(Array.isArray(value), `${label} must be an array`);
} else {
throw new Error(`${label} has unsupported schema type: ${spec.type}`);
}
}
function assertStreamContract(fixture, streamSpecs) {
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);
const packet = parseSseFrame(frame, streamSpecs);
if (packet) decodedPackets.push(packet);
}
assert.deepEqual(decodedPackets, fixture.stream_packets);
@ -243,12 +309,14 @@ function assertStreamContract(fixture) {
}
function validateContract(schema, fixture) {
assertRootShape(schema, fixture);
const streamSpecs = streamEventSpecs(schema);
assertRootShape(schema, fixture, streamSpecs);
assertGenerateRequest(schema, fixture.generate_request);
assertGenerateResponse(schema, fixture.generate_response);
assertStreamContract(fixture);
assertStreamContract(fixture, streamSpecs);
return {
version: fixture.version,
defaultModelSentinel: defaultModelSentinel(schema),
streamEvents: schema["x-engine-gateway-sse-events"],
streamFrameCount: fixture.stream_frames.length,
decodedPacketCount: fixture.stream_packets.length,