런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue