@copilotkit/aimock 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -1
- package/README.md +4 -1
- package/dist/aws-event-stream.cjs +20 -23
- package/dist/aws-event-stream.cjs.map +1 -1
- package/dist/aws-event-stream.d.cts +0 -1
- package/dist/aws-event-stream.d.ts +0 -1
- package/dist/aws-event-stream.js +20 -24
- package/dist/aws-event-stream.js.map +1 -1
- package/dist/bedrock-converse.cjs +79 -82
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +80 -83
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +112 -121
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +113 -122
- package/dist/bedrock.js.map +1 -1
- package/dist/cohere.cjs +58 -106
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +60 -108
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.d.cts.map +1 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/fixture-loader.cjs +103 -2
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +104 -4
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-interactions.cjs +63 -121
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +65 -123
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +242 -155
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +244 -157
- package/dist/gemini.js.map +1 -1
- package/dist/helpers.cjs +400 -7
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +51 -1
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +51 -1
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +391 -8
- package/dist/helpers.js.map +1 -1
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp-fakes.d.cts.map +1 -1
- package/dist/messages.cjs +159 -141
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +161 -143
- package/dist/messages.js.map +1 -1
- package/dist/misbehavior.cjs +62 -3
- package/dist/misbehavior.cjs.map +1 -1
- package/dist/misbehavior.d.cts.map +1 -1
- package/dist/misbehavior.d.ts.map +1 -1
- package/dist/misbehavior.js +63 -4
- package/dist/misbehavior.js.map +1 -1
- package/dist/ndjson-writer.cjs +21 -23
- package/dist/ndjson-writer.cjs.map +1 -1
- package/dist/ndjson-writer.d.cts +0 -1
- package/dist/ndjson-writer.d.cts.map +1 -1
- package/dist/ndjson-writer.d.ts +0 -1
- package/dist/ndjson-writer.d.ts.map +1 -1
- package/dist/ndjson-writer.js +21 -24
- package/dist/ndjson-writer.js.map +1 -1
- package/dist/ollama.cjs +153 -147
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.d.cts.map +1 -1
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +155 -149
- package/dist/ollama.js.map +1 -1
- package/dist/openrouter-chat.cjs +1 -1
- package/dist/openrouter-chat.js +1 -1
- package/dist/raw-validation.cjs +177 -0
- package/dist/raw-validation.cjs.map +1 -0
- package/dist/raw-validation.js +176 -0
- package/dist/raw-validation.js.map +1 -0
- package/dist/response/deliver.cjs +144 -0
- package/dist/response/deliver.cjs.map +1 -0
- package/dist/response/deliver.js +143 -0
- package/dist/response/deliver.js.map +1 -0
- package/dist/response/frame-loop.cjs +102 -0
- package/dist/response/frame-loop.cjs.map +1 -0
- package/dist/response/frame-loop.js +100 -0
- package/dist/response/frame-loop.js.map +1 -0
- package/dist/response/lifecycle.cjs +223 -0
- package/dist/response/lifecycle.cjs.map +1 -0
- package/dist/response/lifecycle.js +211 -0
- package/dist/response/lifecycle.js.map +1 -0
- package/dist/response/proxy.cjs +71 -0
- package/dist/response/proxy.cjs.map +1 -0
- package/dist/response/proxy.js +70 -0
- package/dist/response/proxy.js.map +1 -0
- package/dist/response/raw.cjs +228 -0
- package/dist/response/raw.cjs.map +1 -0
- package/dist/response/raw.js +227 -0
- package/dist/response/raw.js.map +1 -0
- package/dist/response/timing.cjs +157 -0
- package/dist/response/timing.cjs.map +1 -0
- package/dist/response/timing.js +150 -0
- package/dist/response/timing.js.map +1 -0
- package/dist/responses.cjs +269 -148
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.d.cts.map +1 -1
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +270 -149
- package/dist/responses.js.map +1 -1
- package/dist/router.cjs +20 -4
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts +7 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts +7 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +21 -5
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +89 -120
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -122
- package/dist/server.js.map +1 -1
- package/dist/sse-writer.cjs +49 -30
- package/dist/sse-writer.cjs.map +1 -1
- package/dist/sse-writer.d.cts +0 -1
- package/dist/sse-writer.d.cts.map +1 -1
- package/dist/sse-writer.d.ts +0 -1
- package/dist/sse-writer.d.ts.map +1 -1
- package/dist/sse-writer.js +49 -31
- package/dist/sse-writer.js.map +1 -1
- package/dist/transcription.cjs +19 -33
- package/dist/transcription.cjs.map +1 -1
- package/dist/transcription.d.cts.map +1 -1
- package/dist/transcription.d.ts.map +1 -1
- package/dist/transcription.js +20 -34
- package/dist/transcription.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +173 -10
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +173 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ws-framing.cjs +12 -6
- package/dist/ws-framing.cjs.map +1 -1
- package/dist/ws-framing.d.cts +2 -0
- package/dist/ws-framing.d.cts.map +1 -1
- package/dist/ws-framing.d.ts +2 -0
- package/dist/ws-framing.d.ts.map +1 -1
- package/dist/ws-framing.js +12 -6
- package/dist/ws-framing.js.map +1 -1
- package/dist/ws-gemini-live.cjs +120 -93
- package/dist/ws-gemini-live.cjs.map +1 -1
- package/dist/ws-gemini-live.js +120 -93
- package/dist/ws-gemini-live.js.map +1 -1
- package/dist/ws-realtime.cjs +80 -70
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +80 -70
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +72 -53
- package/dist/ws-responses.cjs.map +1 -1
- package/dist/ws-responses.d.cts.map +1 -1
- package/dist/ws-responses.d.ts.map +1 -1
- package/dist/ws-responses.js +73 -54
- package/dist/ws-responses.js.map +1 -1
- package/package.json +1 -1
- package/skills/write-fixtures/SKILL.md +34 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const require_runtime = require('./_virtual/_rolldown/runtime.cjs');
|
|
2
|
+
let node_http = require("node:http");
|
|
3
|
+
|
|
4
|
+
//#region src/raw-validation.ts
|
|
5
|
+
/**
|
|
6
|
+
* @internal Shape checks for raw passthrough responses (#549). One pure
|
|
7
|
+
* checker serves load-time validation (`validateFixtures`) and the serve-time
|
|
8
|
+
* check, which runs on every raw fixture served (including factories and
|
|
9
|
+
* `on()` fixtures, which skip load validation). Not exported from the package
|
|
10
|
+
* entry point.
|
|
11
|
+
*/
|
|
12
|
+
/** Every {@link RawWireId}; the `satisfies` + exhaustiveness check keep it in sync. */
|
|
13
|
+
const RAW_WIRE_IDS = [
|
|
14
|
+
"openai-responses",
|
|
15
|
+
"openai-chat",
|
|
16
|
+
"anthropic",
|
|
17
|
+
"gemini",
|
|
18
|
+
"gemini-interactions",
|
|
19
|
+
"cohere",
|
|
20
|
+
"ollama",
|
|
21
|
+
"ollama-generate",
|
|
22
|
+
"bedrock-invoke",
|
|
23
|
+
"bedrock-converse"
|
|
24
|
+
];
|
|
25
|
+
function isRawWireId(value) {
|
|
26
|
+
return typeof value === "string" && RAW_WIRE_IDS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
/** Headers that break or misdescribe a body aimock frames itself. */
|
|
29
|
+
const FORBIDDEN_HEADERS = new Set([
|
|
30
|
+
"content-length",
|
|
31
|
+
"transfer-encoding",
|
|
32
|
+
"connection",
|
|
33
|
+
"content-encoding"
|
|
34
|
+
]);
|
|
35
|
+
/** Wires whose framing takes the SSE event name from the event's string `type`. */
|
|
36
|
+
const TYPE_NAMED_WIRES = new Set([
|
|
37
|
+
"openai-responses",
|
|
38
|
+
"anthropic",
|
|
39
|
+
"cohere"
|
|
40
|
+
]);
|
|
41
|
+
/** The fields of `raw`; any other key is reported as ignored. */
|
|
42
|
+
const RAW_FIELDS = [
|
|
43
|
+
"wire",
|
|
44
|
+
"events",
|
|
45
|
+
"json",
|
|
46
|
+
"status",
|
|
47
|
+
"headers",
|
|
48
|
+
"done"
|
|
49
|
+
];
|
|
50
|
+
/** Statuses whose replies carry no body: Node drops anything written for them. */
|
|
51
|
+
const BODYLESS_STATUSES = new Set([204, 304]);
|
|
52
|
+
/**
|
|
53
|
+
* The longest AWS event-stream header value, in bytes. The encoder writes the
|
|
54
|
+
* length as a uint16 (aws-event-stream.ts), so it cannot frame a longer one.
|
|
55
|
+
*/
|
|
56
|
+
const EVENT_STREAM_HEADER_VALUE_MAX_BYTES = 65535;
|
|
57
|
+
/** A plain object, as JSON.parse makes one: not an array, Date, Map or class instance. */
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
60
|
+
const proto = Object.getPrototypeOf(value);
|
|
61
|
+
return proto === Object.prototype || proto === null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The first part of `value` that JSON.parse cannot produce (a sparse-array
|
|
65
|
+
* hole, `undefined`, a non-finite number, a function, a Date or other
|
|
66
|
+
* non-plain object, a cycle), as `[path, problem]`. Undefined for plain JSON.
|
|
67
|
+
*/
|
|
68
|
+
function jsonProblem(value, path, ancestors = []) {
|
|
69
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
|
|
70
|
+
if (typeof value === "number") return Number.isFinite(value) ? void 0 : [path, "must be a finite number"];
|
|
71
|
+
if (typeof value !== "object") return [path, `must be a JSON value; got ${typeof value}`];
|
|
72
|
+
if (ancestors.includes(value)) return [path, "must not contain itself"];
|
|
73
|
+
const inner = [...ancestors, value];
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
for (let i = 0; i < value.length; i++) {
|
|
76
|
+
if (!(i in value)) return [`${path}[${i}]`, "must not be a hole in a sparse array"];
|
|
77
|
+
const problem = jsonProblem(value[i], `${path}[${i}]`, inner);
|
|
78
|
+
if (problem) return problem;
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!isPlainObject(value)) return [path, "must be a plain JSON object"];
|
|
83
|
+
for (const [key, item] of Object.entries(value)) {
|
|
84
|
+
const problem = jsonProblem(item, `${path}[${JSON.stringify(key)}]`, inner);
|
|
85
|
+
if (problem) return problem;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check a raw response's `raw` value and the fixture facts in `context`.
|
|
90
|
+
* Returns every finding. The fixture is servable when no finding has severity
|
|
91
|
+
* `"error"`: an empty array or one with only warnings. Pure: no logging, no
|
|
92
|
+
* throwing. Load validation and serve time both call it, so they agree on
|
|
93
|
+
* what a valid raw fixture is.
|
|
94
|
+
*/
|
|
95
|
+
function checkRawResponse(raw, context = {}) {
|
|
96
|
+
const findings = [];
|
|
97
|
+
const error = (message) => findings.push({
|
|
98
|
+
severity: "error",
|
|
99
|
+
message
|
|
100
|
+
});
|
|
101
|
+
for (const key of context.otherResponseKeys ?? []) error(`raw cannot be combined with ${JSON.stringify(key)}: a raw fixture has no other response type`);
|
|
102
|
+
if (context.fixtureMisbehavior) error("misbehavior does not apply to raw fixtures");
|
|
103
|
+
if (!isPlainObject(raw)) {
|
|
104
|
+
error("raw must be an object");
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
for (const key of Object.keys(raw)) if (!RAW_FIELDS.includes(key)) findings.push({
|
|
108
|
+
severity: "warning",
|
|
109
|
+
message: `raw[${JSON.stringify(key)}] is ignored: not a raw field (${RAW_FIELDS.join(", ")})`
|
|
110
|
+
});
|
|
111
|
+
const wire = isRawWireId(raw.wire) ? raw.wire : void 0;
|
|
112
|
+
if (wire === void 0) error(`raw.wire must be one of ${RAW_WIRE_IDS.join(", ")}; got ${JSON.stringify(raw.wire)}`);
|
|
113
|
+
const events = raw.events;
|
|
114
|
+
if (events === void 0 && raw.json === void 0) error("raw needs \"events\" (streaming), \"json\" (non-streaming), or both");
|
|
115
|
+
if (events !== void 0) if (!Array.isArray(events)) error("raw.events must be an array");
|
|
116
|
+
else {
|
|
117
|
+
if (events.length === 0) findings.push({
|
|
118
|
+
severity: "warning",
|
|
119
|
+
message: "raw.events is empty; a streaming request gets no frames"
|
|
120
|
+
});
|
|
121
|
+
for (let i = 0; i < events.length; i++) {
|
|
122
|
+
const event = events[i];
|
|
123
|
+
if (!isPlainObject(event)) {
|
|
124
|
+
error(`raw.events[${i}] must be a JSON object`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const problem = jsonProblem(event, `raw.events[${i}]`);
|
|
128
|
+
if (problem) {
|
|
129
|
+
error(`${problem[0]} ${problem[1]}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (wire !== void 0 && TYPE_NAMED_WIRES.has(wire)) {
|
|
133
|
+
if (typeof event.type !== "string") error(`raw.events[${i}] needs a string "type" on ${wire}`);
|
|
134
|
+
else if (/[\r\n]/.test(event.type)) error(`raw.events[${i}].type must not contain a line break (CR or LF) on ${wire}`);
|
|
135
|
+
}
|
|
136
|
+
if (wire === "bedrock-converse") {
|
|
137
|
+
const keys = Object.keys(event);
|
|
138
|
+
if (keys.length !== 1) error(`raw.events[${i}] must have exactly one top-level key (the event type) on bedrock-converse`);
|
|
139
|
+
else if (Buffer.byteLength(keys[0], "utf8") > EVENT_STREAM_HEADER_VALUE_MAX_BYTES) error(`raw.events[${i}] has an event type key over ${EVENT_STREAM_HEADER_VALUE_MAX_BYTES} bytes, the event-stream header limit, on bedrock-converse`);
|
|
140
|
+
else if (!isPlainObject(event[keys[0]])) error(`raw.events[${i}].${keys[0]} must be a JSON object (the frame payload) on bedrock-converse`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (raw.status !== void 0) {
|
|
145
|
+
const status = raw.status;
|
|
146
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status > 599) error(`raw.status must be an integer from 200 to 599; got ${JSON.stringify(status)}`);
|
|
147
|
+
else if (BODYLESS_STATUSES.has(status)) error(`raw.status ${status} cannot carry a body, so "events" and "json" would be dropped; use a status that allows a body`);
|
|
148
|
+
}
|
|
149
|
+
if (raw.headers !== void 0) if (!isPlainObject(raw.headers)) error("raw.headers must be an object of string values");
|
|
150
|
+
else for (const [name, value] of Object.entries(raw.headers)) {
|
|
151
|
+
const label = `raw.headers[${JSON.stringify(name)}]`;
|
|
152
|
+
if (typeof value !== "string") {
|
|
153
|
+
error(`${label} must be a string`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
(0, node_http.validateHeaderName)(name);
|
|
158
|
+
} catch {
|
|
159
|
+
error(`${label} is not a valid header name`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
(0, node_http.validateHeaderValue)(name, value);
|
|
164
|
+
} catch {
|
|
165
|
+
error(`${label} has an invalid header value`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (FORBIDDEN_HEADERS.has(name.toLowerCase())) error(`${label} is not allowed: aimock frames the body and sets this header itself`);
|
|
169
|
+
}
|
|
170
|
+
if (raw.done !== void 0 && typeof raw.done !== "boolean") error(`raw.done must be a boolean; got ${JSON.stringify(raw.done)}`);
|
|
171
|
+
else if (raw.done !== void 0 && wire !== void 0 && wire !== "openai-chat") error(`raw.done applies only to openai-chat (the only wire with a [DONE] terminal), not ${wire}`);
|
|
172
|
+
return findings;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
exports.checkRawResponse = checkRawResponse;
|
|
177
|
+
//# sourceMappingURL=raw-validation.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"raw-validation.cjs","names":[],"sources":["../src/raw-validation.ts"],"sourcesContent":["/**\n * @internal Shape checks for raw passthrough responses (#549). One pure\n * checker serves load-time validation (`validateFixtures`) and the serve-time\n * check, which runs on every raw fixture served (including factories and\n * `on()` fixtures, which skip load validation). Not exported from the package\n * entry point.\n */\nimport { validateHeaderName, validateHeaderValue } from \"node:http\";\nimport type { RawResponse, RawWireId } from \"./types.js\";\n\nexport interface RawFinding {\n severity: \"error\" | \"warning\";\n message: string;\n}\n\n/** Every {@link RawWireId}; the `satisfies` + exhaustiveness check keep it in sync. */\nexport const RAW_WIRE_IDS = [\n \"openai-responses\",\n \"openai-chat\",\n \"anthropic\",\n \"gemini\",\n \"gemini-interactions\",\n \"cohere\",\n \"ollama\",\n \"ollama-generate\",\n \"bedrock-invoke\",\n \"bedrock-converse\",\n] as const satisfies readonly RawWireId[];\n\n// Compile-time: fails if RawWireId gains a member RAW_WIRE_IDS lacks.\ntype MissingRawWire = Exclude<RawWireId, (typeof RAW_WIRE_IDS)[number]>;\nconst rawWireIdsComplete: MissingRawWire extends never ? true : never = true;\nvoid rawWireIdsComplete;\n\nexport function isRawWireId(value: unknown): value is RawWireId {\n return typeof value === \"string\" && (RAW_WIRE_IDS as readonly string[]).includes(value);\n}\n\n/** Headers that break or misdescribe a body aimock frames itself. */\nconst FORBIDDEN_HEADERS = new Set([\n \"content-length\",\n \"transfer-encoding\",\n \"connection\",\n \"content-encoding\",\n]);\n\n/** Wires whose framing takes the SSE event name from the event's string `type`. */\nconst TYPE_NAMED_WIRES: ReadonlySet<RawWireId> = new Set([\n \"openai-responses\",\n \"anthropic\",\n \"cohere\",\n]);\n\n/** The fields of `raw`; any other key is reported as ignored. */\nconst RAW_FIELDS: readonly string[] = [\n \"wire\",\n \"events\",\n \"json\",\n \"status\",\n \"headers\",\n \"done\",\n] satisfies (keyof RawResponse[\"raw\"])[];\n\n/** Statuses whose replies carry no body: Node drops anything written for them. */\nconst BODYLESS_STATUSES: ReadonlySet<number> = new Set([204, 304]);\n\n/**\n * The longest AWS event-stream header value, in bytes. The encoder writes the\n * length as a uint16 (aws-event-stream.ts), so it cannot frame a longer one.\n */\nconst EVENT_STREAM_HEADER_VALUE_MAX_BYTES = 0xffff;\n\n/** A plain object, as JSON.parse makes one: not an array, Date, Map or class instance. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) return false;\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\n/**\n * The first part of `value` that JSON.parse cannot produce (a sparse-array\n * hole, `undefined`, a non-finite number, a function, a Date or other\n * non-plain object, a cycle), as `[path, problem]`. Undefined for plain JSON.\n */\nfunction jsonProblem(\n value: unknown,\n path: string,\n ancestors: readonly object[] = [],\n): [string, string] | undefined {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return undefined;\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? undefined : [path, \"must be a finite number\"];\n }\n if (typeof value !== \"object\") return [path, `must be a JSON value; got ${typeof value}`];\n if (ancestors.includes(value)) return [path, \"must not contain itself\"];\n const inner = [...ancestors, value];\n if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n if (!(i in value)) return [`${path}[${i}]`, \"must not be a hole in a sparse array\"];\n const problem = jsonProblem(value[i], `${path}[${i}]`, inner);\n if (problem) return problem;\n }\n return undefined;\n }\n if (!isPlainObject(value)) return [path, \"must be a plain JSON object\"];\n for (const [key, item] of Object.entries(value)) {\n const problem = jsonProblem(item, `${path}[${JSON.stringify(key)}]`, inner);\n if (problem) return problem;\n }\n return undefined;\n}\n\n/** What the caller knows about the fixture around `raw`. */\nexport interface RawCheckContext {\n /**\n * Response-type keys written next to `raw`, such as `customToolCalls` or\n * `responsesBlocks` (a legacy server drops these two before serving).\n */\n otherResponseKeys?: readonly string[];\n /** Whether the fixture sets its own `misbehavior`. */\n fixtureMisbehavior?: boolean;\n}\n\n/**\n * Check a raw response's `raw` value and the fixture facts in `context`.\n * Returns every finding. The fixture is servable when no finding has severity\n * `\"error\"`: an empty array or one with only warnings. Pure: no logging, no\n * throwing. Load validation and serve time both call it, so they agree on\n * what a valid raw fixture is.\n */\nexport function checkRawResponse(raw: unknown, context: RawCheckContext = {}): RawFinding[] {\n const findings: RawFinding[] = [];\n const error = (message: string) => findings.push({ severity: \"error\", message });\n\n for (const key of context.otherResponseKeys ?? []) {\n error(\n `raw cannot be combined with ${JSON.stringify(key)}: a raw fixture has no other response type`,\n );\n }\n if (context.fixtureMisbehavior) {\n error(\"misbehavior does not apply to raw fixtures\");\n }\n\n if (!isPlainObject(raw)) {\n error(\"raw must be an object\");\n return findings;\n }\n\n for (const key of Object.keys(raw)) {\n if (!RAW_FIELDS.includes(key)) {\n findings.push({\n severity: \"warning\",\n message: `raw[${JSON.stringify(key)}] is ignored: not a raw field (${RAW_FIELDS.join(\", \")})`,\n });\n }\n }\n\n const wire = isRawWireId(raw.wire) ? raw.wire : undefined;\n if (wire === undefined) {\n error(`raw.wire must be one of ${RAW_WIRE_IDS.join(\", \")}; got ${JSON.stringify(raw.wire)}`);\n }\n\n const events = raw.events;\n if (events === undefined && raw.json === undefined) {\n error('raw needs \"events\" (streaming), \"json\" (non-streaming), or both');\n }\n if (events !== undefined) {\n if (!Array.isArray(events)) {\n error(\"raw.events must be an array\");\n } else {\n if (events.length === 0) {\n findings.push({\n severity: \"warning\",\n message: \"raw.events is empty; a streaming request gets no frames\",\n });\n }\n // An index loop, not forEach: forEach skips the holes of a sparse array.\n for (let i = 0; i < events.length; i++) {\n const event: unknown = events[i];\n if (!isPlainObject(event)) {\n error(`raw.events[${i}] must be a JSON object`);\n continue;\n }\n const problem = jsonProblem(event, `raw.events[${i}]`);\n if (problem) {\n error(`${problem[0]} ${problem[1]}`);\n continue;\n }\n if (wire !== undefined && TYPE_NAMED_WIRES.has(wire)) {\n if (typeof event.type !== \"string\") {\n error(`raw.events[${i}] needs a string \"type\" on ${wire}`);\n } else if (/[\\r\\n]/.test(event.type)) {\n // The type is written as the SSE `event:` line; a line break\n // would start another SSE field or frame.\n error(`raw.events[${i}].type must not contain a line break (CR or LF) on ${wire}`);\n }\n }\n if (wire === \"bedrock-converse\") {\n const keys = Object.keys(event);\n if (keys.length !== 1) {\n error(\n `raw.events[${i}] must have exactly one top-level key (the event type) on bedrock-converse`,\n );\n } else if (Buffer.byteLength(keys[0], \"utf8\") > EVENT_STREAM_HEADER_VALUE_MAX_BYTES) {\n error(\n `raw.events[${i}] has an event type key over ${EVENT_STREAM_HEADER_VALUE_MAX_BYTES} bytes, the event-stream header limit, on bedrock-converse`,\n );\n } else if (!isPlainObject(event[keys[0]])) {\n error(\n `raw.events[${i}].${keys[0]} must be a JSON object (the frame payload) on bedrock-converse`,\n );\n }\n }\n }\n }\n }\n\n if (raw.status !== undefined) {\n const status = raw.status;\n if (typeof status !== \"number\" || !Number.isInteger(status) || status < 200 || status > 599) {\n error(`raw.status must be an integer from 200 to 599; got ${JSON.stringify(status)}`);\n } else if (BODYLESS_STATUSES.has(status)) {\n error(\n `raw.status ${status} cannot carry a body, so \"events\" and \"json\" would be dropped; use a status that allows a body`,\n );\n }\n }\n\n if (raw.headers !== undefined) {\n if (!isPlainObject(raw.headers)) {\n error(\"raw.headers must be an object of string values\");\n } else {\n for (const [name, value] of Object.entries(raw.headers)) {\n const label = `raw.headers[${JSON.stringify(name)}]`;\n if (typeof value !== \"string\") {\n error(`${label} must be a string`);\n continue;\n }\n try {\n validateHeaderName(name);\n } catch {\n error(`${label} is not a valid header name`);\n continue;\n }\n try {\n validateHeaderValue(name, value);\n } catch {\n error(`${label} has an invalid header value`);\n continue;\n }\n if (FORBIDDEN_HEADERS.has(name.toLowerCase())) {\n error(`${label} is not allowed: aimock frames the body and sets this header itself`);\n }\n }\n }\n }\n\n if (raw.done !== undefined && typeof raw.done !== \"boolean\") {\n error(`raw.done must be a boolean; got ${JSON.stringify(raw.done)}`);\n } else if (raw.done !== undefined && wire !== undefined && wire !== \"openai-chat\") {\n error(\n `raw.done applies only to openai-chat (the only wire with a [DONE] terminal), not ${wire}`,\n );\n }\n\n return findings;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { validateHeaderName, validateHeaderValue } from "node:http";
|
|
2
|
+
|
|
3
|
+
//#region src/raw-validation.ts
|
|
4
|
+
/**
|
|
5
|
+
* @internal Shape checks for raw passthrough responses (#549). One pure
|
|
6
|
+
* checker serves load-time validation (`validateFixtures`) and the serve-time
|
|
7
|
+
* check, which runs on every raw fixture served (including factories and
|
|
8
|
+
* `on()` fixtures, which skip load validation). Not exported from the package
|
|
9
|
+
* entry point.
|
|
10
|
+
*/
|
|
11
|
+
/** Every {@link RawWireId}; the `satisfies` + exhaustiveness check keep it in sync. */
|
|
12
|
+
const RAW_WIRE_IDS = [
|
|
13
|
+
"openai-responses",
|
|
14
|
+
"openai-chat",
|
|
15
|
+
"anthropic",
|
|
16
|
+
"gemini",
|
|
17
|
+
"gemini-interactions",
|
|
18
|
+
"cohere",
|
|
19
|
+
"ollama",
|
|
20
|
+
"ollama-generate",
|
|
21
|
+
"bedrock-invoke",
|
|
22
|
+
"bedrock-converse"
|
|
23
|
+
];
|
|
24
|
+
function isRawWireId(value) {
|
|
25
|
+
return typeof value === "string" && RAW_WIRE_IDS.includes(value);
|
|
26
|
+
}
|
|
27
|
+
/** Headers that break or misdescribe a body aimock frames itself. */
|
|
28
|
+
const FORBIDDEN_HEADERS = new Set([
|
|
29
|
+
"content-length",
|
|
30
|
+
"transfer-encoding",
|
|
31
|
+
"connection",
|
|
32
|
+
"content-encoding"
|
|
33
|
+
]);
|
|
34
|
+
/** Wires whose framing takes the SSE event name from the event's string `type`. */
|
|
35
|
+
const TYPE_NAMED_WIRES = new Set([
|
|
36
|
+
"openai-responses",
|
|
37
|
+
"anthropic",
|
|
38
|
+
"cohere"
|
|
39
|
+
]);
|
|
40
|
+
/** The fields of `raw`; any other key is reported as ignored. */
|
|
41
|
+
const RAW_FIELDS = [
|
|
42
|
+
"wire",
|
|
43
|
+
"events",
|
|
44
|
+
"json",
|
|
45
|
+
"status",
|
|
46
|
+
"headers",
|
|
47
|
+
"done"
|
|
48
|
+
];
|
|
49
|
+
/** Statuses whose replies carry no body: Node drops anything written for them. */
|
|
50
|
+
const BODYLESS_STATUSES = new Set([204, 304]);
|
|
51
|
+
/**
|
|
52
|
+
* The longest AWS event-stream header value, in bytes. The encoder writes the
|
|
53
|
+
* length as a uint16 (aws-event-stream.ts), so it cannot frame a longer one.
|
|
54
|
+
*/
|
|
55
|
+
const EVENT_STREAM_HEADER_VALUE_MAX_BYTES = 65535;
|
|
56
|
+
/** A plain object, as JSON.parse makes one: not an array, Date, Map or class instance. */
|
|
57
|
+
function isPlainObject(value) {
|
|
58
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
59
|
+
const proto = Object.getPrototypeOf(value);
|
|
60
|
+
return proto === Object.prototype || proto === null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The first part of `value` that JSON.parse cannot produce (a sparse-array
|
|
64
|
+
* hole, `undefined`, a non-finite number, a function, a Date or other
|
|
65
|
+
* non-plain object, a cycle), as `[path, problem]`. Undefined for plain JSON.
|
|
66
|
+
*/
|
|
67
|
+
function jsonProblem(value, path, ancestors = []) {
|
|
68
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
|
|
69
|
+
if (typeof value === "number") return Number.isFinite(value) ? void 0 : [path, "must be a finite number"];
|
|
70
|
+
if (typeof value !== "object") return [path, `must be a JSON value; got ${typeof value}`];
|
|
71
|
+
if (ancestors.includes(value)) return [path, "must not contain itself"];
|
|
72
|
+
const inner = [...ancestors, value];
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
for (let i = 0; i < value.length; i++) {
|
|
75
|
+
if (!(i in value)) return [`${path}[${i}]`, "must not be a hole in a sparse array"];
|
|
76
|
+
const problem = jsonProblem(value[i], `${path}[${i}]`, inner);
|
|
77
|
+
if (problem) return problem;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!isPlainObject(value)) return [path, "must be a plain JSON object"];
|
|
82
|
+
for (const [key, item] of Object.entries(value)) {
|
|
83
|
+
const problem = jsonProblem(item, `${path}[${JSON.stringify(key)}]`, inner);
|
|
84
|
+
if (problem) return problem;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check a raw response's `raw` value and the fixture facts in `context`.
|
|
89
|
+
* Returns every finding. The fixture is servable when no finding has severity
|
|
90
|
+
* `"error"`: an empty array or one with only warnings. Pure: no logging, no
|
|
91
|
+
* throwing. Load validation and serve time both call it, so they agree on
|
|
92
|
+
* what a valid raw fixture is.
|
|
93
|
+
*/
|
|
94
|
+
function checkRawResponse(raw, context = {}) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
const error = (message) => findings.push({
|
|
97
|
+
severity: "error",
|
|
98
|
+
message
|
|
99
|
+
});
|
|
100
|
+
for (const key of context.otherResponseKeys ?? []) error(`raw cannot be combined with ${JSON.stringify(key)}: a raw fixture has no other response type`);
|
|
101
|
+
if (context.fixtureMisbehavior) error("misbehavior does not apply to raw fixtures");
|
|
102
|
+
if (!isPlainObject(raw)) {
|
|
103
|
+
error("raw must be an object");
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
for (const key of Object.keys(raw)) if (!RAW_FIELDS.includes(key)) findings.push({
|
|
107
|
+
severity: "warning",
|
|
108
|
+
message: `raw[${JSON.stringify(key)}] is ignored: not a raw field (${RAW_FIELDS.join(", ")})`
|
|
109
|
+
});
|
|
110
|
+
const wire = isRawWireId(raw.wire) ? raw.wire : void 0;
|
|
111
|
+
if (wire === void 0) error(`raw.wire must be one of ${RAW_WIRE_IDS.join(", ")}; got ${JSON.stringify(raw.wire)}`);
|
|
112
|
+
const events = raw.events;
|
|
113
|
+
if (events === void 0 && raw.json === void 0) error("raw needs \"events\" (streaming), \"json\" (non-streaming), or both");
|
|
114
|
+
if (events !== void 0) if (!Array.isArray(events)) error("raw.events must be an array");
|
|
115
|
+
else {
|
|
116
|
+
if (events.length === 0) findings.push({
|
|
117
|
+
severity: "warning",
|
|
118
|
+
message: "raw.events is empty; a streaming request gets no frames"
|
|
119
|
+
});
|
|
120
|
+
for (let i = 0; i < events.length; i++) {
|
|
121
|
+
const event = events[i];
|
|
122
|
+
if (!isPlainObject(event)) {
|
|
123
|
+
error(`raw.events[${i}] must be a JSON object`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const problem = jsonProblem(event, `raw.events[${i}]`);
|
|
127
|
+
if (problem) {
|
|
128
|
+
error(`${problem[0]} ${problem[1]}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (wire !== void 0 && TYPE_NAMED_WIRES.has(wire)) {
|
|
132
|
+
if (typeof event.type !== "string") error(`raw.events[${i}] needs a string "type" on ${wire}`);
|
|
133
|
+
else if (/[\r\n]/.test(event.type)) error(`raw.events[${i}].type must not contain a line break (CR or LF) on ${wire}`);
|
|
134
|
+
}
|
|
135
|
+
if (wire === "bedrock-converse") {
|
|
136
|
+
const keys = Object.keys(event);
|
|
137
|
+
if (keys.length !== 1) error(`raw.events[${i}] must have exactly one top-level key (the event type) on bedrock-converse`);
|
|
138
|
+
else if (Buffer.byteLength(keys[0], "utf8") > EVENT_STREAM_HEADER_VALUE_MAX_BYTES) error(`raw.events[${i}] has an event type key over ${EVENT_STREAM_HEADER_VALUE_MAX_BYTES} bytes, the event-stream header limit, on bedrock-converse`);
|
|
139
|
+
else if (!isPlainObject(event[keys[0]])) error(`raw.events[${i}].${keys[0]} must be a JSON object (the frame payload) on bedrock-converse`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (raw.status !== void 0) {
|
|
144
|
+
const status = raw.status;
|
|
145
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status > 599) error(`raw.status must be an integer from 200 to 599; got ${JSON.stringify(status)}`);
|
|
146
|
+
else if (BODYLESS_STATUSES.has(status)) error(`raw.status ${status} cannot carry a body, so "events" and "json" would be dropped; use a status that allows a body`);
|
|
147
|
+
}
|
|
148
|
+
if (raw.headers !== void 0) if (!isPlainObject(raw.headers)) error("raw.headers must be an object of string values");
|
|
149
|
+
else for (const [name, value] of Object.entries(raw.headers)) {
|
|
150
|
+
const label = `raw.headers[${JSON.stringify(name)}]`;
|
|
151
|
+
if (typeof value !== "string") {
|
|
152
|
+
error(`${label} must be a string`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
validateHeaderName(name);
|
|
157
|
+
} catch {
|
|
158
|
+
error(`${label} is not a valid header name`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
validateHeaderValue(name, value);
|
|
163
|
+
} catch {
|
|
164
|
+
error(`${label} has an invalid header value`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (FORBIDDEN_HEADERS.has(name.toLowerCase())) error(`${label} is not allowed: aimock frames the body and sets this header itself`);
|
|
168
|
+
}
|
|
169
|
+
if (raw.done !== void 0 && typeof raw.done !== "boolean") error(`raw.done must be a boolean; got ${JSON.stringify(raw.done)}`);
|
|
170
|
+
else if (raw.done !== void 0 && wire !== void 0 && wire !== "openai-chat") error(`raw.done applies only to openai-chat (the only wire with a [DONE] terminal), not ${wire}`);
|
|
171
|
+
return findings;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
export { checkRawResponse };
|
|
176
|
+
//# sourceMappingURL=raw-validation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"raw-validation.js","names":[],"sources":["../src/raw-validation.ts"],"sourcesContent":["/**\n * @internal Shape checks for raw passthrough responses (#549). One pure\n * checker serves load-time validation (`validateFixtures`) and the serve-time\n * check, which runs on every raw fixture served (including factories and\n * `on()` fixtures, which skip load validation). Not exported from the package\n * entry point.\n */\nimport { validateHeaderName, validateHeaderValue } from \"node:http\";\nimport type { RawResponse, RawWireId } from \"./types.js\";\n\nexport interface RawFinding {\n severity: \"error\" | \"warning\";\n message: string;\n}\n\n/** Every {@link RawWireId}; the `satisfies` + exhaustiveness check keep it in sync. */\nexport const RAW_WIRE_IDS = [\n \"openai-responses\",\n \"openai-chat\",\n \"anthropic\",\n \"gemini\",\n \"gemini-interactions\",\n \"cohere\",\n \"ollama\",\n \"ollama-generate\",\n \"bedrock-invoke\",\n \"bedrock-converse\",\n] as const satisfies readonly RawWireId[];\n\n// Compile-time: fails if RawWireId gains a member RAW_WIRE_IDS lacks.\ntype MissingRawWire = Exclude<RawWireId, (typeof RAW_WIRE_IDS)[number]>;\nconst rawWireIdsComplete: MissingRawWire extends never ? true : never = true;\nvoid rawWireIdsComplete;\n\nexport function isRawWireId(value: unknown): value is RawWireId {\n return typeof value === \"string\" && (RAW_WIRE_IDS as readonly string[]).includes(value);\n}\n\n/** Headers that break or misdescribe a body aimock frames itself. */\nconst FORBIDDEN_HEADERS = new Set([\n \"content-length\",\n \"transfer-encoding\",\n \"connection\",\n \"content-encoding\",\n]);\n\n/** Wires whose framing takes the SSE event name from the event's string `type`. */\nconst TYPE_NAMED_WIRES: ReadonlySet<RawWireId> = new Set([\n \"openai-responses\",\n \"anthropic\",\n \"cohere\",\n]);\n\n/** The fields of `raw`; any other key is reported as ignored. */\nconst RAW_FIELDS: readonly string[] = [\n \"wire\",\n \"events\",\n \"json\",\n \"status\",\n \"headers\",\n \"done\",\n] satisfies (keyof RawResponse[\"raw\"])[];\n\n/** Statuses whose replies carry no body: Node drops anything written for them. */\nconst BODYLESS_STATUSES: ReadonlySet<number> = new Set([204, 304]);\n\n/**\n * The longest AWS event-stream header value, in bytes. The encoder writes the\n * length as a uint16 (aws-event-stream.ts), so it cannot frame a longer one.\n */\nconst EVENT_STREAM_HEADER_VALUE_MAX_BYTES = 0xffff;\n\n/** A plain object, as JSON.parse makes one: not an array, Date, Map or class instance. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) return false;\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\n/**\n * The first part of `value` that JSON.parse cannot produce (a sparse-array\n * hole, `undefined`, a non-finite number, a function, a Date or other\n * non-plain object, a cycle), as `[path, problem]`. Undefined for plain JSON.\n */\nfunction jsonProblem(\n value: unknown,\n path: string,\n ancestors: readonly object[] = [],\n): [string, string] | undefined {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return undefined;\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? undefined : [path, \"must be a finite number\"];\n }\n if (typeof value !== \"object\") return [path, `must be a JSON value; got ${typeof value}`];\n if (ancestors.includes(value)) return [path, \"must not contain itself\"];\n const inner = [...ancestors, value];\n if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n if (!(i in value)) return [`${path}[${i}]`, \"must not be a hole in a sparse array\"];\n const problem = jsonProblem(value[i], `${path}[${i}]`, inner);\n if (problem) return problem;\n }\n return undefined;\n }\n if (!isPlainObject(value)) return [path, \"must be a plain JSON object\"];\n for (const [key, item] of Object.entries(value)) {\n const problem = jsonProblem(item, `${path}[${JSON.stringify(key)}]`, inner);\n if (problem) return problem;\n }\n return undefined;\n}\n\n/** What the caller knows about the fixture around `raw`. */\nexport interface RawCheckContext {\n /**\n * Response-type keys written next to `raw`, such as `customToolCalls` or\n * `responsesBlocks` (a legacy server drops these two before serving).\n */\n otherResponseKeys?: readonly string[];\n /** Whether the fixture sets its own `misbehavior`. */\n fixtureMisbehavior?: boolean;\n}\n\n/**\n * Check a raw response's `raw` value and the fixture facts in `context`.\n * Returns every finding. The fixture is servable when no finding has severity\n * `\"error\"`: an empty array or one with only warnings. Pure: no logging, no\n * throwing. Load validation and serve time both call it, so they agree on\n * what a valid raw fixture is.\n */\nexport function checkRawResponse(raw: unknown, context: RawCheckContext = {}): RawFinding[] {\n const findings: RawFinding[] = [];\n const error = (message: string) => findings.push({ severity: \"error\", message });\n\n for (const key of context.otherResponseKeys ?? []) {\n error(\n `raw cannot be combined with ${JSON.stringify(key)}: a raw fixture has no other response type`,\n );\n }\n if (context.fixtureMisbehavior) {\n error(\"misbehavior does not apply to raw fixtures\");\n }\n\n if (!isPlainObject(raw)) {\n error(\"raw must be an object\");\n return findings;\n }\n\n for (const key of Object.keys(raw)) {\n if (!RAW_FIELDS.includes(key)) {\n findings.push({\n severity: \"warning\",\n message: `raw[${JSON.stringify(key)}] is ignored: not a raw field (${RAW_FIELDS.join(\", \")})`,\n });\n }\n }\n\n const wire = isRawWireId(raw.wire) ? raw.wire : undefined;\n if (wire === undefined) {\n error(`raw.wire must be one of ${RAW_WIRE_IDS.join(\", \")}; got ${JSON.stringify(raw.wire)}`);\n }\n\n const events = raw.events;\n if (events === undefined && raw.json === undefined) {\n error('raw needs \"events\" (streaming), \"json\" (non-streaming), or both');\n }\n if (events !== undefined) {\n if (!Array.isArray(events)) {\n error(\"raw.events must be an array\");\n } else {\n if (events.length === 0) {\n findings.push({\n severity: \"warning\",\n message: \"raw.events is empty; a streaming request gets no frames\",\n });\n }\n // An index loop, not forEach: forEach skips the holes of a sparse array.\n for (let i = 0; i < events.length; i++) {\n const event: unknown = events[i];\n if (!isPlainObject(event)) {\n error(`raw.events[${i}] must be a JSON object`);\n continue;\n }\n const problem = jsonProblem(event, `raw.events[${i}]`);\n if (problem) {\n error(`${problem[0]} ${problem[1]}`);\n continue;\n }\n if (wire !== undefined && TYPE_NAMED_WIRES.has(wire)) {\n if (typeof event.type !== \"string\") {\n error(`raw.events[${i}] needs a string \"type\" on ${wire}`);\n } else if (/[\\r\\n]/.test(event.type)) {\n // The type is written as the SSE `event:` line; a line break\n // would start another SSE field or frame.\n error(`raw.events[${i}].type must not contain a line break (CR or LF) on ${wire}`);\n }\n }\n if (wire === \"bedrock-converse\") {\n const keys = Object.keys(event);\n if (keys.length !== 1) {\n error(\n `raw.events[${i}] must have exactly one top-level key (the event type) on bedrock-converse`,\n );\n } else if (Buffer.byteLength(keys[0], \"utf8\") > EVENT_STREAM_HEADER_VALUE_MAX_BYTES) {\n error(\n `raw.events[${i}] has an event type key over ${EVENT_STREAM_HEADER_VALUE_MAX_BYTES} bytes, the event-stream header limit, on bedrock-converse`,\n );\n } else if (!isPlainObject(event[keys[0]])) {\n error(\n `raw.events[${i}].${keys[0]} must be a JSON object (the frame payload) on bedrock-converse`,\n );\n }\n }\n }\n }\n }\n\n if (raw.status !== undefined) {\n const status = raw.status;\n if (typeof status !== \"number\" || !Number.isInteger(status) || status < 200 || status > 599) {\n error(`raw.status must be an integer from 200 to 599; got ${JSON.stringify(status)}`);\n } else if (BODYLESS_STATUSES.has(status)) {\n error(\n `raw.status ${status} cannot carry a body, so \"events\" and \"json\" would be dropped; use a status that allows a body`,\n );\n }\n }\n\n if (raw.headers !== undefined) {\n if (!isPlainObject(raw.headers)) {\n error(\"raw.headers must be an object of string values\");\n } else {\n for (const [name, value] of Object.entries(raw.headers)) {\n const label = `raw.headers[${JSON.stringify(name)}]`;\n if (typeof value !== \"string\") {\n error(`${label} must be a string`);\n continue;\n }\n try {\n validateHeaderName(name);\n } catch {\n error(`${label} is not a valid header name`);\n continue;\n }\n try {\n validateHeaderValue(name, value);\n } catch {\n error(`${label} has an invalid header value`);\n continue;\n }\n if (FORBIDDEN_HEADERS.has(name.toLowerCase())) {\n error(`${label} is not allowed: aimock frames the body and sets this header itself`);\n }\n }\n }\n }\n\n if (raw.done !== undefined && typeof raw.done !== \"boolean\") {\n error(`raw.done must be a boolean; got ${JSON.stringify(raw.done)}`);\n } else if (raw.done !== undefined && wire !== undefined && wire !== \"openai-chat\") {\n error(\n `raw.done applies only to openai-chat (the only wire with a [DONE] terminal), not ${wire}`,\n );\n }\n\n return findings;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const require_frame_loop = require('./frame-loop.cjs');
|
|
2
|
+
const require_sse_writer = require('../sse-writer.cjs');
|
|
3
|
+
const require_chaos = require('../chaos.cjs');
|
|
4
|
+
const require_lifecycle = require('./lifecycle.cjs');
|
|
5
|
+
const require_timing = require('./timing.cjs');
|
|
6
|
+
|
|
7
|
+
//#region src/response/deliver.ts
|
|
8
|
+
/**
|
|
9
|
+
* Journal a delivery that ended early. The reason is always set: the
|
|
10
|
+
* lifecycle's first cause when it fired, else {@link teardownReason} for the
|
|
11
|
+
* response that is gone. When no byte of the response reached the socket
|
|
12
|
+
* ({@link statusLineSent}), the status is 0, the rule the server and the
|
|
13
|
+
* chaos `disconnect` apply to a torn socket. A fixture interruption
|
|
14
|
+
* (`disconnectAfterMs`, `truncateAfterChunks`) keeps the status it was
|
|
15
|
+
* journaled with, as base recorded it.
|
|
16
|
+
*/
|
|
17
|
+
function journalTeardown(entry, res, lifecycle) {
|
|
18
|
+
require_lifecycle.markInterrupted(entry, lifecycle?.reason() ?? require_lifecycle.teardownReason(res));
|
|
19
|
+
if (lifecycle?.cause() !== "fixture" && !require_lifecycle.statusLineSent(res)) entry.response.status = 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the response was torn down: the lifecycle fired, the response is
|
|
23
|
+
* destroyed or chaos disconnected it, or the client closed the socket.
|
|
24
|
+
*/
|
|
25
|
+
function tornDown(res, lifecycle) {
|
|
26
|
+
const socket = require_lifecycle.requestSocket(res);
|
|
27
|
+
const gone = require_chaos.responseGoneReason(res);
|
|
28
|
+
return lifecycle?.reason() !== void 0 || gone === "client-gone" || gone === "chaos-disconnected" || !res.writableEnded && !!socket && require_lifecycle.closedByClient(socket);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Journal a response that something else on aimock's side already answered
|
|
32
|
+
* (head committed or body ended) as aimock's teardown, with the status the
|
|
33
|
+
* client actually got.
|
|
34
|
+
*/
|
|
35
|
+
function journalAnsweredElsewhere(entry, res) {
|
|
36
|
+
require_lifecycle.markInterrupted(entry, require_lifecycle.AIMOCK_DISCONNECTED);
|
|
37
|
+
entry.response.status = require_lifecycle.statusLineSent(res) ? res.statusCode : 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether `deliverJson` can no longer write its reply, journaling why when it
|
|
41
|
+
* cannot. Torn down ({@link tornDown}): {@link journalTeardown}. Already
|
|
42
|
+
* answered by something else on aimock's side (head committed or body
|
|
43
|
+
* ended): writing again would throw `ERR_HTTP_HEADERS_SENT`, so it is
|
|
44
|
+
* journaled by {@link journalAnsweredElsewhere}, and a body that never ended
|
|
45
|
+
* is destroyed so the client does not hang.
|
|
46
|
+
*/
|
|
47
|
+
function journalUnwritable(entry, res, lifecycle) {
|
|
48
|
+
if (tornDown(res, lifecycle)) {
|
|
49
|
+
journalTeardown(entry, res, lifecycle);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (res.headersSent || res.writableEnded) {
|
|
53
|
+
journalAnsweredElsewhere(entry, res);
|
|
54
|
+
if (!res.writableEnded) res.destroy();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Deliver one streamed reply and journal how it ended. Builds the HTTP
|
|
61
|
+
* lifecycle (client close + fixture interruption) and runs the frame loop on
|
|
62
|
+
* it. When the loop stops early it destroys `res` (if it has not ended) and
|
|
63
|
+
* marks the entry with the first cause's reason (or, when no cause fired, who
|
|
64
|
+
* tore the response down). A response other code already answered (head
|
|
65
|
+
* committed before the stream, or body ended) is journaled as
|
|
66
|
+
* {@link deliverJson} journals it. It disposes the lifecycle on every path.
|
|
67
|
+
*/
|
|
68
|
+
async function deliverStream(res, entry, fixture, codec, frames, timing, opts) {
|
|
69
|
+
require_lifecycle.noteDeliveryStart(res);
|
|
70
|
+
const headSentBefore = res.headersSent;
|
|
71
|
+
const lifecycle = require_lifecycle.httpLifecycle(res, fixture, { streaming: true });
|
|
72
|
+
try {
|
|
73
|
+
if (!await require_frame_loop.runFrameLoop(require_frame_loop.httpSink(res), frames, codec, timing, {
|
|
74
|
+
lifecycle,
|
|
75
|
+
onChunkSent: lifecycle.tick,
|
|
76
|
+
status: opts?.status,
|
|
77
|
+
headers: opts?.headers
|
|
78
|
+
})) {
|
|
79
|
+
const answeredElsewhere = (headSentBefore || res.writableEnded) && !tornDown(res, lifecycle);
|
|
80
|
+
if (!res.writableEnded) res.destroy();
|
|
81
|
+
if (answeredElsewhere) journalAnsweredElsewhere(entry, res);
|
|
82
|
+
else journalTeardown(entry, res, lifecycle);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
lifecycle.dispose();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Deliver one non-streaming JSON reply. The body is a thunk.
|
|
90
|
+
*
|
|
91
|
+
* On both paths it first checks {@link journalUnwritable}, before any wait. A
|
|
92
|
+
* response that can no longer take the reply (torn down, or already answered)
|
|
93
|
+
* is not written, the thunk is never called, and the entry is marked
|
|
94
|
+
* interrupted. An already-answered response whose body never ended is
|
|
95
|
+
* destroyed, so the client does not hang.
|
|
96
|
+
*
|
|
97
|
+
* With non-streaming timing off (the default) it then runs
|
|
98
|
+
* `JSON.stringify(body())`, then `writeHead`, then `end`: the body is built
|
|
99
|
+
* before anything is written, so a builder that throws leaves the response
|
|
100
|
+
* unwritten.
|
|
101
|
+
*
|
|
102
|
+
* With timing on, it waits the non-streaming delay on the client's close
|
|
103
|
+
* signal, checks {@link journalUnwritable} again (the response can be torn
|
|
104
|
+
* down or answered during the wait), then runs the thunk, so a `created`
|
|
105
|
+
* timestamp reflects the write time.
|
|
106
|
+
*
|
|
107
|
+
* Rejects with a `RangeError` when `chunkSize` is a number below 1: that is
|
|
108
|
+
* a programming error, and the delay math divides by it. No user input reaches
|
|
109
|
+
* it, because every caller clamps with `Math.max(1, ...)`. A user value that
|
|
110
|
+
* is not a number clamps to `NaN`: the reply is still served, with a zero
|
|
111
|
+
* wait when timing is on.
|
|
112
|
+
*/
|
|
113
|
+
async function deliverJson(res, entry, fixture, body, opts) {
|
|
114
|
+
if (opts.chunkSize < 1) throw new RangeError(`deliverJson: chunkSize must be >= 1, got ${opts.chunkSize}`);
|
|
115
|
+
const write = () => {
|
|
116
|
+
const text = JSON.stringify(body());
|
|
117
|
+
res.writeHead(opts.status ?? 200, {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
...opts.headers
|
|
120
|
+
});
|
|
121
|
+
res.end(text);
|
|
122
|
+
};
|
|
123
|
+
require_lifecycle.noteDeliveryStart(res);
|
|
124
|
+
if (journalUnwritable(entry, res, void 0)) return;
|
|
125
|
+
if (!require_timing.nonStreamingTimingEnabled(fixture, opts.surface)) {
|
|
126
|
+
write();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const lifecycle = require_lifecycle.httpLifecycle(res, fixture, { streaming: false });
|
|
130
|
+
try {
|
|
131
|
+
const fixtureTimings = require_timing.nonStreamingRecordedTimings(fixture, opts.surface, opts.recordedTimings);
|
|
132
|
+
const recordedTimings = opts.duplicateId ? require_timing.extendRecordedTimings(fixtureTimings, Math.max(1, Math.ceil(opts.payloadLength / opts.chunkSize)), true) : fixtureTimings;
|
|
133
|
+
await require_sse_writer.delay(require_timing.nonStreamingDelayMs(fixture, opts.payloadLength, opts.chunkSize, recordedTimings, opts.replaySpeed), lifecycle.signal);
|
|
134
|
+
if (journalUnwritable(entry, res, lifecycle)) return;
|
|
135
|
+
write();
|
|
136
|
+
} finally {
|
|
137
|
+
lifecycle.dispose();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
exports.deliverJson = deliverJson;
|
|
143
|
+
exports.deliverStream = deliverStream;
|
|
144
|
+
//# sourceMappingURL=deliver.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deliver.cjs","names":["teardownReason","statusLineSent","requestSocket","responseGoneReason","closedByClient","AIMOCK_DISCONNECTED","httpLifecycle","runFrameLoop","httpSink","nonStreamingTimingEnabled","nonStreamingRecordedTimings","extendRecordedTimings","delay","nonStreamingDelayMs"],"sources":["../../src/response/deliver.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { Fixture, JournalEntry, RecordedTimings } from \"../types.js\";\nimport { delay } from \"../sse-writer.js\";\nimport { httpSink, runFrameLoop, type FrameCodec, type FrameTiming } from \"./frame-loop.js\";\nimport { responseGoneReason } from \"../chaos.js\";\nimport {\n AIMOCK_DISCONNECTED,\n closedByClient,\n httpLifecycle,\n markInterrupted,\n noteDeliveryStart,\n statusLineSent,\n teardownReason,\n requestSocket,\n type DeliveryLifecycle,\n} from \"./lifecycle.js\";\nimport {\n extendRecordedTimings,\n nonStreamingDelayMs,\n nonStreamingRecordedTimings,\n nonStreamingTimingEnabled,\n type NonStreamingSurface,\n} from \"./timing.js\";\n\n/** The lifecycle fields the teardown journal reads. */\ntype TeardownSource = Pick<DeliveryLifecycle, \"cause\" | \"reason\">;\n\n/**\n * Journal a delivery that ended early. The reason is always set: the\n * lifecycle's first cause when it fired, else {@link teardownReason} for the\n * response that is gone. When no byte of the response reached the socket\n * ({@link statusLineSent}), the status is 0, the rule the server and the\n * chaos `disconnect` apply to a torn socket. A fixture interruption\n * (`disconnectAfterMs`, `truncateAfterChunks`) keeps the status it was\n * journaled with, as base recorded it.\n */\nfunction journalTeardown(\n entry: JournalEntry,\n res: http.ServerResponse,\n lifecycle: TeardownSource | undefined,\n): void {\n markInterrupted(entry, lifecycle?.reason() ?? teardownReason(res));\n if (lifecycle?.cause() !== \"fixture\" && !statusLineSent(res)) entry.response.status = 0;\n}\n\n/**\n * Whether the response was torn down: the lifecycle fired, the response is\n * destroyed or chaos disconnected it, or the client closed the socket.\n */\nfunction tornDown(res: http.ServerResponse, lifecycle: TeardownSource | undefined): boolean {\n const socket = requestSocket(res);\n const gone = responseGoneReason(res);\n return (\n lifecycle?.reason() !== undefined ||\n gone === \"client-gone\" ||\n gone === \"chaos-disconnected\" ||\n (!res.writableEnded && !!socket && closedByClient(socket))\n );\n}\n\n/**\n * Journal a response that something else on aimock's side already answered\n * (head committed or body ended) as aimock's teardown, with the status the\n * client actually got.\n */\nfunction journalAnsweredElsewhere(entry: JournalEntry, res: http.ServerResponse): void {\n markInterrupted(entry, AIMOCK_DISCONNECTED);\n entry.response.status = statusLineSent(res) ? res.statusCode : 0;\n}\n\n/**\n * Whether `deliverJson` can no longer write its reply, journaling why when it\n * cannot. Torn down ({@link tornDown}): {@link journalTeardown}. Already\n * answered by something else on aimock's side (head committed or body\n * ended): writing again would throw `ERR_HTTP_HEADERS_SENT`, so it is\n * journaled by {@link journalAnsweredElsewhere}, and a body that never ended\n * is destroyed so the client does not hang.\n */\nfunction journalUnwritable(\n entry: JournalEntry,\n res: http.ServerResponse,\n lifecycle: TeardownSource | undefined,\n): boolean {\n if (tornDown(res, lifecycle)) {\n journalTeardown(entry, res, lifecycle);\n return true;\n }\n if (res.headersSent || res.writableEnded) {\n journalAnsweredElsewhere(entry, res);\n if (!res.writableEnded) res.destroy();\n return true;\n }\n return false;\n}\n\n/**\n * Deliver one streamed reply and journal how it ended. Builds the HTTP\n * lifecycle (client close + fixture interruption) and runs the frame loop on\n * it. When the loop stops early it destroys `res` (if it has not ended) and\n * marks the entry with the first cause's reason (or, when no cause fired, who\n * tore the response down). A response other code already answered (head\n * committed before the stream, or body ended) is journaled as\n * {@link deliverJson} journals it. It disposes the lifecycle on every path.\n */\nexport async function deliverStream<F>(\n res: http.ServerResponse,\n entry: JournalEntry,\n fixture: Fixture,\n codec: FrameCodec<F>,\n frames: readonly F[],\n timing: FrameTiming,\n opts?: {\n status?: number; // When set, sent with `writeHead` before the first frame (issue #549).\n headers?: Record<string, string>; // Merged over the codec's headers (issue #549).\n },\n): Promise<void> {\n noteDeliveryStart(res);\n const headSentBefore = res.headersSent;\n const lifecycle = httpLifecycle(res, fixture, { streaming: true });\n try {\n const completed = await runFrameLoop(httpSink(res), frames, codec, timing, {\n lifecycle,\n onChunkSent: lifecycle.tick,\n status: opts?.status,\n headers: opts?.headers,\n });\n if (!completed) {\n // Read before the destroy below, which makes any response look torn down.\n const answeredElsewhere = (headSentBefore || res.writableEnded) && !tornDown(res, lifecycle);\n if (!res.writableEnded) res.destroy();\n if (answeredElsewhere) journalAnsweredElsewhere(entry, res);\n else journalTeardown(entry, res, lifecycle);\n }\n } finally {\n lifecycle.dispose();\n }\n}\n\nexport interface DeliverJsonOptions {\n /**\n * Passed to `nonStreamingTimingEnabled` and `nonStreamingRecordedTimings`:\n * OpenAI chat, or any other provider.\n */\n surface: NonStreamingSurface;\n status?: number; // The response status, 200 when unset (issue #549).\n headers?: Record<string, string>; // Merged over the JSON `Content-Type` header (issue #549).\n /** The served payload length the wait is sized from. */\n payloadLength: number;\n /**\n * The wait is sized in chunks of this many characters. Callers pass\n * `Math.max(1, <fixture or server chunkSize>)`: 1 or more, or `NaN` for a\n * non-numeric value. `NaN` gives a zero wait.\n */\n chunkSize: number;\n recordedTimings?: RecordedTimings;\n /** A K4 duplicate-id plan: extend the recorded gaps before sizing the wait. */\n duplicateId?: boolean;\n replaySpeed?: number;\n}\n\n/**\n * Deliver one non-streaming JSON reply. The body is a thunk.\n *\n * On both paths it first checks {@link journalUnwritable}, before any wait. A\n * response that can no longer take the reply (torn down, or already answered)\n * is not written, the thunk is never called, and the entry is marked\n * interrupted. An already-answered response whose body never ended is\n * destroyed, so the client does not hang.\n *\n * With non-streaming timing off (the default) it then runs\n * `JSON.stringify(body())`, then `writeHead`, then `end`: the body is built\n * before anything is written, so a builder that throws leaves the response\n * unwritten.\n *\n * With timing on, it waits the non-streaming delay on the client's close\n * signal, checks {@link journalUnwritable} again (the response can be torn\n * down or answered during the wait), then runs the thunk, so a `created`\n * timestamp reflects the write time.\n *\n * Rejects with a `RangeError` when `chunkSize` is a number below 1: that is\n * a programming error, and the delay math divides by it. No user input reaches\n * it, because every caller clamps with `Math.max(1, ...)`. A user value that\n * is not a number clamps to `NaN`: the reply is still served, with a zero\n * wait when timing is on.\n */\nexport async function deliverJson(\n res: http.ServerResponse,\n entry: JournalEntry,\n fixture: Fixture,\n body: () => unknown,\n opts: DeliverJsonOptions,\n): Promise<void> {\n if (opts.chunkSize < 1) {\n throw new RangeError(`deliverJson: chunkSize must be >= 1, got ${opts.chunkSize}`);\n }\n const write = (): void => {\n const text = JSON.stringify(body());\n res.writeHead(opts.status ?? 200, { \"Content-Type\": \"application/json\", ...opts.headers });\n res.end(text);\n };\n noteDeliveryStart(res);\n // Checked before the lifecycle and the wait on both paths: a response that\n // cannot take the reply is journaled at once, with no wait.\n if (journalUnwritable(entry, res, undefined)) return;\n if (!nonStreamingTimingEnabled(fixture, opts.surface)) {\n write();\n return;\n }\n const lifecycle = httpLifecycle(res, fixture, { streaming: false });\n try {\n const fixtureTimings = nonStreamingRecordedTimings(fixture, opts.surface, opts.recordedTimings);\n const recordedTimings = opts.duplicateId\n ? extendRecordedTimings(\n fixtureTimings,\n Math.max(1, Math.ceil(opts.payloadLength / opts.chunkSize)),\n true,\n )\n : fixtureTimings;\n await delay(\n nonStreamingDelayMs(\n fixture,\n opts.payloadLength,\n opts.chunkSize,\n recordedTimings,\n opts.replaySpeed,\n ),\n lifecycle.signal,\n );\n if (journalUnwritable(entry, res, lifecycle)) return;\n write();\n } finally {\n lifecycle.dispose();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoCA,SAAS,gBACP,OACA,KACA,WACM;AACN,mCAAgB,OAAO,WAAW,QAAQ,IAAIA,iCAAe,IAAI,CAAC;AAClE,KAAI,WAAW,OAAO,KAAK,aAAa,CAACC,iCAAe,IAAI,CAAE,OAAM,SAAS,SAAS;;;;;;AAOxF,SAAS,SAAS,KAA0B,WAAgD;CAC1F,MAAM,SAASC,gCAAc,IAAI;CACjC,MAAM,OAAOC,iCAAmB,IAAI;AACpC,QACE,WAAW,QAAQ,KAAK,UACxB,SAAS,iBACT,SAAS,wBACR,CAAC,IAAI,iBAAiB,CAAC,CAAC,UAAUC,iCAAe,OAAO;;;;;;;AAS7D,SAAS,yBAAyB,OAAqB,KAAgC;AACrF,mCAAgB,OAAOC,sCAAoB;AAC3C,OAAM,SAAS,SAASJ,iCAAe,IAAI,GAAG,IAAI,aAAa;;;;;;;;;;AAWjE,SAAS,kBACP,OACA,KACA,WACS;AACT,KAAI,SAAS,KAAK,UAAU,EAAE;AAC5B,kBAAgB,OAAO,KAAK,UAAU;AACtC,SAAO;;AAET,KAAI,IAAI,eAAe,IAAI,eAAe;AACxC,2BAAyB,OAAO,IAAI;AACpC,MAAI,CAAC,IAAI,cAAe,KAAI,SAAS;AACrC,SAAO;;AAET,QAAO;;;;;;;;;;;AAYT,eAAsB,cACpB,KACA,OACA,SACA,OACA,QACA,QACA,MAIe;AACf,qCAAkB,IAAI;CACtB,MAAM,iBAAiB,IAAI;CAC3B,MAAM,YAAYK,gCAAc,KAAK,SAAS,EAAE,WAAW,MAAM,CAAC;AAClE,KAAI;AAOF,MAAI,CANc,MAAMC,gCAAaC,4BAAS,IAAI,EAAE,QAAQ,OAAO,QAAQ;GACzE;GACA,aAAa,UAAU;GACvB,QAAQ,MAAM;GACd,SAAS,MAAM;GAChB,CAAC,EACc;GAEd,MAAM,qBAAqB,kBAAkB,IAAI,kBAAkB,CAAC,SAAS,KAAK,UAAU;AAC5F,OAAI,CAAC,IAAI,cAAe,KAAI,SAAS;AACrC,OAAI,kBAAmB,0BAAyB,OAAO,IAAI;OACtD,iBAAgB,OAAO,KAAK,UAAU;;WAErC;AACR,YAAU,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDvB,eAAsB,YACpB,KACA,OACA,SACA,MACA,MACe;AACf,KAAI,KAAK,YAAY,EACnB,OAAM,IAAI,WAAW,4CAA4C,KAAK,YAAY;CAEpF,MAAM,cAAoB;EACxB,MAAM,OAAO,KAAK,UAAU,MAAM,CAAC;AACnC,MAAI,UAAU,KAAK,UAAU,KAAK;GAAE,gBAAgB;GAAoB,GAAG,KAAK;GAAS,CAAC;AAC1F,MAAI,IAAI,KAAK;;AAEf,qCAAkB,IAAI;AAGtB,KAAI,kBAAkB,OAAO,KAAK,OAAU,CAAE;AAC9C,KAAI,CAACC,yCAA0B,SAAS,KAAK,QAAQ,EAAE;AACrD,SAAO;AACP;;CAEF,MAAM,YAAYH,gCAAc,KAAK,SAAS,EAAE,WAAW,OAAO,CAAC;AACnE,KAAI;EACF,MAAM,iBAAiBI,2CAA4B,SAAS,KAAK,SAAS,KAAK,gBAAgB;EAC/F,MAAM,kBAAkB,KAAK,cACzBC,qCACE,gBACA,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,gBAAgB,KAAK,UAAU,CAAC,EAC3D,KACD,GACD;AACJ,QAAMC,yBACJC,mCACE,SACA,KAAK,eACL,KAAK,WACL,iBACA,KAAK,YACN,EACD,UAAU,OACX;AACD,MAAI,kBAAkB,OAAO,KAAK,UAAU,CAAE;AAC9C,SAAO;WACC;AACR,YAAU,SAAS"}
|