@copilotkit/aimock 1.32.0 → 1.33.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 +13 -0
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/cli.cjs +26 -2
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +26 -2
- package/dist/cli.js.map +1 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/helpers.cjs +19 -2
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +19 -3
- package/dist/helpers.js.map +1 -1
- package/dist/llmock.cjs +23 -10
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +23 -10
- package/dist/llmock.js.map +1 -1
- package/dist/recorder.cjs +252 -55
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +253 -56
- package/dist/recorder.js.map +1 -1
- package/dist/types.d.cts +22 -0
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vector-types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/recorder.cjs
CHANGED
|
@@ -20,6 +20,52 @@ let node_string_decoder = require("node:string_decoder");
|
|
|
20
20
|
|
|
21
21
|
//#region src/recorder.ts
|
|
22
22
|
/** Headers to strip when proxying — hop-by-hop (RFC 2616 §13.5.1) + client-set. */
|
|
23
|
+
/**
|
|
24
|
+
* Default ceiling (bytes) for the in-memory proxy-path buffer. Chosen well
|
|
25
|
+
* under V8's ~512 MiB max string length so `rawBuffer.toString()` /
|
|
26
|
+
* stream-collapse never throws `RangeError: Invalid string length`, and so a
|
|
27
|
+
* single huge proxied response cannot spike the heap unbounded. Overridable
|
|
28
|
+
* via `RecordConfig.maxProxyBufferBytes` / `--max-proxy-buffer-bytes`.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_MAX_PROXY_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
31
|
+
/**
|
|
32
|
+
* Default ceiling for the number of SSE/NDJSON/EventStream frames whose
|
|
33
|
+
* per-frame state (`frameTimestamps`, parse buffers) aimock retains for a
|
|
34
|
+
* single proxied response. Frame state is count-indexed, not byte-sized, so a
|
|
35
|
+
* long-lived / never-ending stream accumulates `frameTimestamps` entries (and,
|
|
36
|
+
* if a frame never completes, parse-buffer bytes) UNBOUNDED even when the byte
|
|
37
|
+
* cap is generous — observed as multi-GB heap growth over many hours from a few
|
|
38
|
+
* long nested-sub-agent streams. Tripping truncation on EITHER bytes OR frame
|
|
39
|
+
* count bounds both. 5M frames is generous for any real response (a normal
|
|
40
|
+
* completion is hundreds-to-thousands of frames) while still bounding a runaway
|
|
41
|
+
* stream to ~tens of MB of frame state. Overridable via
|
|
42
|
+
* `RecordConfig.maxProxyBufferFrames` / `--max-proxy-buffer-frames`.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_MAX_PROXY_BUFFER_FRAMES = 5e6;
|
|
45
|
+
/**
|
|
46
|
+
* Absolute hard ceiling (bytes) for any in-memory proxy buffer, independent of
|
|
47
|
+
* the configurable `maxProxyBufferBytes`. V8's maximum STRING length on 64-bit
|
|
48
|
+
* is 2^29 - 1 (~512 MiB of UTF-16 code units), and the proxy buffer is
|
|
49
|
+
* eventually stringified via `rawBuffer.toString()` for collapse/relay — so the
|
|
50
|
+
* BYTE buffer must stay safely under that string limit or the toString throws
|
|
51
|
+
* `RangeError: Invalid string length`. 256 MiB of bytes is well under the
|
|
52
|
+
* ~512 MiB string-length boundary (these are different units — bytes vs UTF-16
|
|
53
|
+
* code units — but 256 MiB of bytes can never decode to more than 256 Mi code
|
|
54
|
+
* units, comfortably below 2^29 - 1). Used to clamp the configurable cap AND to
|
|
55
|
+
* bound the non-progressive relay buffer that must be retained past a cap trip.
|
|
56
|
+
*/
|
|
57
|
+
const PROXY_BUFFER_HARD_CEILING = 256 * 1024 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* Test-only override of the effective hard ceiling. Lets the proxy-buffer
|
|
60
|
+
* enforcement suite exercise the >hard-ceiling fail-loud path with a small
|
|
61
|
+
* body instead of streaming 256 MiB. `undefined` (the default) uses the real
|
|
62
|
+
* `PROXY_BUFFER_HARD_CEILING`. NEVER set from production code.
|
|
63
|
+
*/
|
|
64
|
+
let proxyBufferHardCeilingOverride;
|
|
65
|
+
/** Effective hard ceiling, honoring any active test-only override. */
|
|
66
|
+
function effectiveHardCeiling() {
|
|
67
|
+
return proxyBufferHardCeilingOverride ?? PROXY_BUFFER_HARD_CEILING;
|
|
68
|
+
}
|
|
23
69
|
const STRIP_HEADERS = new Set([
|
|
24
70
|
"connection",
|
|
25
71
|
"keep-alive",
|
|
@@ -65,6 +111,19 @@ function sanitizeHeaderValue(value) {
|
|
|
65
111
|
return value.replace(/[^\t\x20-\x7e\x80-\xff]/g, "?");
|
|
66
112
|
}
|
|
67
113
|
/**
|
|
114
|
+
* Resolve a `content-type` header value to a single string. Node's
|
|
115
|
+
* `http.IncomingHttpHeaders` types `content-type` as `string | string[]`, and
|
|
116
|
+
* a constructed/proxied header object CAN carry an array (e.g. duplicated
|
|
117
|
+
* header lines surfaced by some HTTP stacks). `join(", ")`-ing such an array
|
|
118
|
+
* produces a malformed `Content-Type: application/json, text/html` value — pick
|
|
119
|
+
* the FIRST element instead, which is the value the client should act on.
|
|
120
|
+
* Returns `""` when the header is absent or an empty array.
|
|
121
|
+
*/
|
|
122
|
+
function pickContentType(contentType) {
|
|
123
|
+
if (Array.isArray(contentType)) return contentType.length > 0 ? contentType[0] : "";
|
|
124
|
+
return contentType ?? "";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
68
127
|
* Write a built fixture to disk (snapshot vs. timestamp file layout) and, when
|
|
69
128
|
* the match is non-empty, register it in the in-memory cache so subsequent
|
|
70
129
|
* identical requests match. Extracted from `proxyAndRecord` so the fal
|
|
@@ -97,7 +156,8 @@ function persistFixture(opts) {
|
|
|
97
156
|
}
|
|
98
157
|
} else {
|
|
99
158
|
const timestampFile = `${providerKey}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${node_crypto.randomUUID().slice(0, 8)}.json`;
|
|
100
|
-
|
|
159
|
+
const contextSegment = fixture.match.context ? require_helpers.slugifyContext(fixture.match.context) : void 0;
|
|
160
|
+
filepath = contextSegment ? node_path.join(fixturePath, contextSegment, timestampFile) : node_path.join(fixturePath, timestampFile);
|
|
101
161
|
}
|
|
102
162
|
const fileWarnings = [...isEmptyMatch ? ["Empty match criteria — this fixture will not match any request"] : [], ...warnings];
|
|
103
163
|
try {
|
|
@@ -108,14 +168,19 @@ function persistFixture(opts) {
|
|
|
108
168
|
const existingFixtures = Array.isArray(existing.fixtures) ? existing.fixtures : void 0;
|
|
109
169
|
if (existingFixtures === void 0 && existing.fixtures !== void 0) logger.warn(`Existing fixture file ${filepath} has a non-array "fixtures" — discarding it and starting fresh`);
|
|
110
170
|
fileContent = { fixtures: [...existingFixtures ?? [], fixture] };
|
|
111
|
-
if (
|
|
171
|
+
if (Array.isArray(existing._warnings)) fileWarnings.unshift(...existing._warnings.filter((w) => typeof w === "string"));
|
|
172
|
+
else if (typeof existing._warning === "string" && existing._warning) fileWarnings.unshift(...existing._warning.split("; "));
|
|
112
173
|
} catch (mergeErr) {
|
|
113
174
|
const msg = mergeErr instanceof Error ? mergeErr.message : "unknown";
|
|
114
175
|
logger.warn(`Could not read existing fixture file ${filepath} (${msg}) — overwriting`);
|
|
115
176
|
fileContent = { fixtures: [fixture] };
|
|
116
177
|
}
|
|
117
178
|
else fileContent = { fixtures: [fixture] };
|
|
118
|
-
if (fileWarnings.length > 0)
|
|
179
|
+
if (fileWarnings.length > 0) {
|
|
180
|
+
const dedupedWarnings = [...new Set(fileWarnings)];
|
|
181
|
+
fileContent._warnings = dedupedWarnings;
|
|
182
|
+
fileContent._warning = dedupedWarnings.join("; ");
|
|
183
|
+
}
|
|
119
184
|
const tmpPath = filepath + ".tmp." + process.pid;
|
|
120
185
|
node_fs.writeFileSync(tmpPath, JSON.stringify(fileContent, null, 2), "utf-8");
|
|
121
186
|
node_fs.renameSync(tmpPath, filepath);
|
|
@@ -166,15 +231,21 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
166
231
|
let upstreamHeaders;
|
|
167
232
|
let upstreamBody;
|
|
168
233
|
let rawBuffer;
|
|
234
|
+
let bufferTruncated = false;
|
|
235
|
+
let truncationCap;
|
|
236
|
+
let totalBytes = 0;
|
|
237
|
+
let hardCeilingExceeded = false;
|
|
169
238
|
let streamedToClient = false;
|
|
170
239
|
let clientDisconnected = false;
|
|
171
240
|
let frameTimestamps = [];
|
|
172
241
|
let streamStartTime = 0;
|
|
242
|
+
const maxProxyBufferBytes = clampMaxBufferBytes(record.maxProxyBufferBytes);
|
|
243
|
+
const maxProxyBufferFrames = clampMaxBufferFrames(record.maxProxyBufferFrames);
|
|
173
244
|
try {
|
|
174
245
|
const result = await makeUpstreamRequest(target, forwardHeaders, requestBody, res, req.method, defaults.logger, {
|
|
175
246
|
upstreamTimeoutMs: record.upstreamTimeoutMs,
|
|
176
247
|
bodyTimeoutMs: record.bodyTimeoutMs
|
|
177
|
-
});
|
|
248
|
+
}, maxProxyBufferBytes, maxProxyBufferFrames);
|
|
178
249
|
upstreamStatus = result.status;
|
|
179
250
|
upstreamHeaders = result.headers;
|
|
180
251
|
upstreamBody = result.body;
|
|
@@ -183,6 +254,10 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
183
254
|
clientDisconnected = result.clientDisconnected;
|
|
184
255
|
frameTimestamps = result.frameTimestamps;
|
|
185
256
|
streamStartTime = result.streamStartTime;
|
|
257
|
+
bufferTruncated = result.bufferTruncated;
|
|
258
|
+
truncationCap = result.truncationCap;
|
|
259
|
+
totalBytes = result.totalBytes;
|
|
260
|
+
hardCeilingExceeded = result.hardCeilingExceeded;
|
|
186
261
|
} catch (err) {
|
|
187
262
|
const msg = err instanceof Error ? err.message : "Unknown proxy error";
|
|
188
263
|
defaults.logger.error(`Proxy request failed: ${msg}`);
|
|
@@ -192,16 +267,40 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
192
267
|
message: `Proxy to upstream failed: ${msg}`,
|
|
193
268
|
type: "proxy_error"
|
|
194
269
|
} }));
|
|
195
|
-
} else res.
|
|
270
|
+
} else res.destroy();
|
|
271
|
+
return "relayed";
|
|
272
|
+
}
|
|
273
|
+
if (bufferTruncated) {
|
|
274
|
+
const capDetail = truncationCap === "frame" ? `the ${maxProxyBufferFrames}-frame cap` : `the ${maxProxyBufferBytes}-byte cap`;
|
|
275
|
+
if (!streamedToClient && hardCeilingExceeded && !res.headersSent) {
|
|
276
|
+
const ceilingMiB = Math.round(PROXY_BUFFER_HARD_CEILING / (1024 * 1024));
|
|
277
|
+
defaults.logger.error(`Upstream response exceeded the ${ceilingMiB} MiB proxy hard ceiling (saw ${totalBytes} bytes) on a non-progressive body — cannot relay the full body and refusing to relay a truncated one; returning 502`);
|
|
278
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
279
|
+
res.end(JSON.stringify({ error: {
|
|
280
|
+
message: `Upstream response exceeds ${ceilingMiB}MiB proxy ceiling`,
|
|
281
|
+
type: "proxy_error"
|
|
282
|
+
} }));
|
|
283
|
+
return "relayed";
|
|
284
|
+
}
|
|
285
|
+
defaults.logger.warn(`Upstream response exceeded ${capDetail} (saw ${totalBytes} bytes) — relayed to client, recording skipped`);
|
|
286
|
+
if (!streamedToClient && !res.headersSent) {
|
|
287
|
+
const clientStatus = upstreamStatus >= 200 && upstreamStatus < 300 ? 200 : 502;
|
|
288
|
+
const relayHeaders = {};
|
|
289
|
+
const ct = upstreamHeaders["content-type"];
|
|
290
|
+
relayHeaders["Content-Type"] = (Array.isArray(ct) ? ct.join(", ") : ct ?? "") || "application/json";
|
|
291
|
+
res.writeHead(clientStatus, relayHeaders);
|
|
292
|
+
res.end(rawBuffer);
|
|
293
|
+
} else if (!res.writableEnded) res.end();
|
|
196
294
|
return "relayed";
|
|
197
295
|
}
|
|
198
296
|
const contentType = upstreamHeaders["content-type"];
|
|
199
|
-
const ctString =
|
|
297
|
+
const ctString = pickContentType(contentType);
|
|
200
298
|
const isBinaryStream = ctString.toLowerCase().includes("application/vnd.amazon.eventstream");
|
|
201
299
|
const collapsed = require_stream_collapse.collapseStreamingResponse(ctString, providerKey, isBinaryStream ? rawBuffer : upstreamBody, defaults.logger);
|
|
202
300
|
let fixtureResponse;
|
|
203
|
-
if (ctString.toLowerCase().startsWith("audio/") &&
|
|
301
|
+
if (ctString.toLowerCase().startsWith("audio/") && upstreamStatus >= 200 && upstreamStatus < 300) {
|
|
204
302
|
const audioFormat = ctString.toLowerCase().replace("audio/", "").replace("mpeg", "mp3").split(";")[0].trim();
|
|
303
|
+
if (rawBuffer.length === 0) defaults.logger.warn("Audio response had a zero-length body — recording an empty audio fixture");
|
|
205
304
|
fixtureResponse = {
|
|
206
305
|
audio: rawBuffer.toString("base64"),
|
|
207
306
|
...audioFormat && audioFormat !== "mp3" ? { format: audioFormat } : {}
|
|
@@ -301,15 +400,7 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
301
400
|
json: parsedResponse,
|
|
302
401
|
status: upstreamStatus
|
|
303
402
|
};
|
|
304
|
-
} else
|
|
305
|
-
let encodingFormat;
|
|
306
|
-
try {
|
|
307
|
-
encodingFormat = rawBody ? JSON.parse(rawBody).encoding_format : void 0;
|
|
308
|
-
} catch (err) {
|
|
309
|
-
defaults.logger.debug(`Could not parse encoding_format from raw body: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
310
|
-
}
|
|
311
|
-
fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, encodingFormat, defaults.logger);
|
|
312
|
-
}
|
|
403
|
+
} else fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, defaults.logger);
|
|
313
404
|
}
|
|
314
405
|
if (clientDisconnected) {
|
|
315
406
|
defaults.logger.warn("Client disconnected mid-stream — skipping fixture save to avoid truncated data");
|
|
@@ -410,11 +501,31 @@ function clampTimeout(value, fallback) {
|
|
|
410
501
|
if (value == null || !Number.isFinite(value) || value <= 0) return fallback;
|
|
411
502
|
return value;
|
|
412
503
|
}
|
|
413
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Sanitize the configured proxy-buffer cap: non-finite or non-positive values
|
|
506
|
+
* fall back to the default. Also hard-clamps to just under V8's max string
|
|
507
|
+
* length so a misconfigured large value can never permit an over-string-limit
|
|
508
|
+
* buffer to reach `.toString()`.
|
|
509
|
+
*/
|
|
510
|
+
function clampMaxBufferBytes(value) {
|
|
511
|
+
if (value == null || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_PROXY_BUFFER_BYTES;
|
|
512
|
+
return Math.min(value, PROXY_BUFFER_HARD_CEILING);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Sanitize the configured proxy-buffer frame cap: non-finite or non-positive
|
|
516
|
+
* values fall back to the default. Bounds the count-indexed per-frame state
|
|
517
|
+
* (`frameTimestamps` + parse buffers) that the byte cap cannot bound on its own.
|
|
518
|
+
*/
|
|
519
|
+
function clampMaxBufferFrames(value) {
|
|
520
|
+
if (value == null || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_PROXY_BUFFER_FRAMES;
|
|
521
|
+
return Math.floor(value);
|
|
522
|
+
}
|
|
523
|
+
function makeUpstreamRequest(target, headers, body, clientRes, method = "POST", logger, timeouts, maxBufferBytes = DEFAULT_MAX_PROXY_BUFFER_BYTES, maxBufferFrames = DEFAULT_MAX_PROXY_BUFFER_FRAMES) {
|
|
414
524
|
return new Promise((resolve, reject) => {
|
|
415
525
|
const transport = target.protocol === "https:" ? node_https : node_http;
|
|
416
526
|
const UPSTREAM_TIMEOUT_MS = clampTimeout(timeouts?.upstreamTimeoutMs, 3e4);
|
|
417
527
|
const BODY_TIMEOUT_MS = clampTimeout(timeouts?.bodyTimeoutMs, 3e4);
|
|
528
|
+
let requestSendTime = 0;
|
|
418
529
|
const req = transport.request(target, {
|
|
419
530
|
method,
|
|
420
531
|
timeout: UPSTREAM_TIMEOUT_MS,
|
|
@@ -427,14 +538,14 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
427
538
|
req.destroy(/* @__PURE__ */ new Error(`Upstream response timed out after ${BODY_TIMEOUT_MS / 1e3}s`));
|
|
428
539
|
});
|
|
429
540
|
const ct = res.headers["content-type"];
|
|
430
|
-
const ctStr =
|
|
541
|
+
const ctStr = pickContentType(ct);
|
|
431
542
|
const ctLower = ctStr.toLowerCase();
|
|
432
543
|
const isSSE = ctLower.includes("text/event-stream");
|
|
433
544
|
const isNDJSON = ctLower.includes("application/x-ndjson");
|
|
434
545
|
const isBinaryEventStream = ctLower.includes("application/vnd.amazon.eventstream");
|
|
435
546
|
const isProgressiveStream = isSSE || isNDJSON || isBinaryEventStream;
|
|
436
547
|
const frameTimestamps = [];
|
|
437
|
-
const streamStartTime = Date.now();
|
|
548
|
+
const streamStartTime = requestSendTime || Date.now();
|
|
438
549
|
let frameBuffer = "";
|
|
439
550
|
const frameDecoder = new StreamingFrameDecoder();
|
|
440
551
|
let binaryFrameBuffer = Buffer.alloc(0);
|
|
@@ -456,22 +567,75 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
456
567
|
if (!clientRes.writableFinished) {
|
|
457
568
|
clientDisconnected = true;
|
|
458
569
|
req.destroy();
|
|
570
|
+
res.removeListener("data", onUpstreamData);
|
|
571
|
+
chunks.length = 0;
|
|
572
|
+
bufferedBytes = 0;
|
|
573
|
+
frameTimestamps.length = 0;
|
|
574
|
+
frameBuffer = "";
|
|
575
|
+
binaryFrameBuffer = Buffer.alloc(0);
|
|
459
576
|
}
|
|
460
577
|
});
|
|
461
578
|
}
|
|
462
579
|
const chunks = [];
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
580
|
+
let bufferedBytes = 0;
|
|
581
|
+
let totalBytes = 0;
|
|
582
|
+
let bufferTruncated = false;
|
|
583
|
+
/** Which cap tripped truncation — surfaced to the caller for an accurate warning. */
|
|
584
|
+
let truncationCap;
|
|
585
|
+
const hardCeiling = effectiveHardCeiling();
|
|
586
|
+
/**
|
|
587
|
+
* True when a NON-progressive upstream body exceeded `hardCeiling`, so
|
|
588
|
+
* the buffered `rawBuffer` is a PARTIAL copy that must NOT be relayed as
|
|
589
|
+
* success. Distinct from `bufferTruncated` (which also covers the
|
|
590
|
+
* under-ceiling over-soft-cap case where the full body IS retained).
|
|
591
|
+
*/
|
|
592
|
+
let hardCeilingExceeded = false;
|
|
593
|
+
const tripTruncation = (cap) => {
|
|
594
|
+
bufferTruncated = true;
|
|
595
|
+
truncationCap = cap;
|
|
596
|
+
frameTimestamps.length = 0;
|
|
597
|
+
frameBuffer = "";
|
|
598
|
+
frameDecoder.end();
|
|
599
|
+
binaryFrameBuffer = Buffer.alloc(0);
|
|
600
|
+
if (isProgressiveStream) {
|
|
601
|
+
chunks.length = 0;
|
|
602
|
+
bufferedBytes = 0;
|
|
603
|
+
}
|
|
604
|
+
const detail = cap === "byte" ? `byte cap (${maxBufferBytes} bytes)` : `frame cap (${maxBufferFrames} frames)`;
|
|
605
|
+
logger?.warn(`Upstream response exceeded the proxy buffer ${detail} — relaying full body to client, but skipping in-memory collapse/recording to bound memory`);
|
|
606
|
+
};
|
|
607
|
+
const onUpstreamData = (chunk) => {
|
|
608
|
+
totalBytes += chunk.length;
|
|
609
|
+
if (!bufferTruncated && bufferedBytes + chunk.length > maxBufferBytes) tripTruncation("byte");
|
|
610
|
+
else if (!bufferTruncated && frameTimestamps.length >= maxBufferFrames) tripTruncation("frame");
|
|
611
|
+
if (!bufferTruncated) {
|
|
612
|
+
chunks.push(chunk);
|
|
613
|
+
bufferedBytes += chunk.length;
|
|
614
|
+
} else if (!isProgressiveStream) if (bufferedBytes + chunk.length <= hardCeiling) {
|
|
615
|
+
chunks.push(chunk);
|
|
616
|
+
bufferedBytes += chunk.length;
|
|
617
|
+
} else hardCeilingExceeded = true;
|
|
618
|
+
if (!bufferTruncated && (isSSE || isNDJSON)) {
|
|
466
619
|
frameBuffer += frameDecoder.write(chunk);
|
|
467
620
|
const delimiter = isNDJSON ? /\r?\n/ : /\r?\n\r?\n/;
|
|
468
621
|
const parts = frameBuffer.split(delimiter);
|
|
469
|
-
for (let fi = 0; fi < parts.length - 1; fi++)
|
|
470
|
-
|
|
622
|
+
for (let fi = 0; fi < parts.length - 1; fi++) {
|
|
623
|
+
if (frameTimestamps.length >= maxBufferFrames) {
|
|
624
|
+
tripTruncation("frame");
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
if (parts[fi].trim().length > 0) frameTimestamps.push(Date.now());
|
|
628
|
+
}
|
|
629
|
+
if (!bufferTruncated) frameBuffer = parts[parts.length - 1];
|
|
471
630
|
}
|
|
472
|
-
if (isBinaryEventStream)
|
|
631
|
+
if (!bufferTruncated && isBinaryEventStream) if (bufferedBytes + binaryFrameBuffer.length > maxBufferBytes) tripTruncation("byte");
|
|
632
|
+
else {
|
|
473
633
|
binaryFrameBuffer = Buffer.concat([binaryFrameBuffer, chunk]);
|
|
474
634
|
while (binaryFrameBuffer.length >= 4) {
|
|
635
|
+
if (frameTimestamps.length >= maxBufferFrames) {
|
|
636
|
+
tripTruncation("frame");
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
475
639
|
const totalLen = binaryFrameBuffer.readUInt32BE(0);
|
|
476
640
|
if (totalLen < 12 || binaryFrameBuffer.length < totalLen) break;
|
|
477
641
|
frameTimestamps.push(Date.now());
|
|
@@ -484,11 +648,12 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
484
648
|
logger?.debug(`Failed to relay chunk to client: ${writeErr instanceof Error ? writeErr.message : "unknown"}`);
|
|
485
649
|
clientDisconnected = true;
|
|
486
650
|
}
|
|
487
|
-
}
|
|
651
|
+
};
|
|
652
|
+
res.on("data", onUpstreamData);
|
|
488
653
|
res.on("error", reject);
|
|
489
654
|
res.on("end", () => {
|
|
490
655
|
if (res.socket) res.setTimeout(0);
|
|
491
|
-
if (isSSE || isNDJSON) {
|
|
656
|
+
if (!bufferTruncated && (isSSE || isNDJSON)) {
|
|
492
657
|
frameBuffer += frameDecoder.end();
|
|
493
658
|
if (frameBuffer.trim().length > 0) frameTimestamps.push(Date.now());
|
|
494
659
|
}
|
|
@@ -498,15 +663,20 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
498
663
|
} catch (endErr) {
|
|
499
664
|
logger?.debug(`Failed to end client response: ${endErr instanceof Error ? endErr.message : "unknown"}`);
|
|
500
665
|
}
|
|
666
|
+
const bodyString = !bufferTruncated || bufferTruncated && !streamedToClient ? rawBuffer.toString() : "";
|
|
501
667
|
resolve({
|
|
502
668
|
status: res.statusCode ?? 500,
|
|
503
669
|
headers: res.headers,
|
|
504
|
-
body:
|
|
670
|
+
body: bodyString,
|
|
505
671
|
rawBuffer,
|
|
506
672
|
streamedToClient,
|
|
507
673
|
clientDisconnected,
|
|
508
674
|
frameTimestamps,
|
|
509
|
-
streamStartTime
|
|
675
|
+
streamStartTime,
|
|
676
|
+
bufferTruncated,
|
|
677
|
+
truncationCap,
|
|
678
|
+
totalBytes,
|
|
679
|
+
hardCeilingExceeded
|
|
510
680
|
});
|
|
511
681
|
});
|
|
512
682
|
});
|
|
@@ -514,6 +684,7 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
514
684
|
req.destroy(/* @__PURE__ */ new Error(`Upstream request timed out after ${UPSTREAM_TIMEOUT_MS / 1e3}s: ${target.href}`));
|
|
515
685
|
});
|
|
516
686
|
req.on("error", reject);
|
|
687
|
+
requestSendTime = Date.now();
|
|
517
688
|
req.write(body);
|
|
518
689
|
req.end();
|
|
519
690
|
});
|
|
@@ -528,10 +699,23 @@ function logDroppedReasoningSignature(logger, reasoning, reasoningSignature) {
|
|
|
528
699
|
if (reasoningSignature && !reasoning) logger?.warn("Dropping captured reasoningSignature — no plaintext reasoning to attach it to");
|
|
529
700
|
}
|
|
530
701
|
/**
|
|
702
|
+
* Coerce a tool call's `arguments` field into the string the fixture contract
|
|
703
|
+
* requires (types.ts `ToolCall.arguments: string`). When the upstream omits the
|
|
704
|
+
* arguments field entirely, `JSON.stringify(undefined)` yields the JS value
|
|
705
|
+
* `undefined` (not a string), which then gets dropped on disk-write and breaks
|
|
706
|
+
* cross-provider replay through the OpenAI streaming path. Coalesce to "{}" so
|
|
707
|
+
* `arguments` is ALWAYS a string, matching the streaming recorder's behavior.
|
|
708
|
+
*/
|
|
709
|
+
function toToolCallArguments(raw) {
|
|
710
|
+
if (typeof raw === "string") return raw;
|
|
711
|
+
if (raw === void 0 || raw === null) return "{}";
|
|
712
|
+
return JSON.stringify(raw);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
531
715
|
* Detect the response format from the parsed upstream JSON and convert
|
|
532
716
|
* it into an aimock FixtureResponse.
|
|
533
717
|
*/
|
|
534
|
-
function buildFixtureResponse(parsed, status,
|
|
718
|
+
function buildFixtureResponse(parsed, status, logger) {
|
|
535
719
|
if (parsed === null || parsed === void 0) return {
|
|
536
720
|
error: {
|
|
537
721
|
message: "Upstream returned non-JSON response",
|
|
@@ -554,19 +738,24 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
554
738
|
if (Array.isArray(obj.data) && obj.data.length > 0) {
|
|
555
739
|
const first = obj.data[0];
|
|
556
740
|
if (Array.isArray(first.embedding)) return { embedding: first.embedding };
|
|
557
|
-
if (typeof first.embedding === "string"
|
|
741
|
+
if (typeof first.embedding === "string") {
|
|
558
742
|
const buf = Buffer.from(first.embedding, "base64");
|
|
559
|
-
if (buf.byteLength % 4 !== 0)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
743
|
+
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) logger?.warn(`Could not decode base64 embedding (byteLength=${buf.byteLength} is not a positive multiple of 4) — saving as error fixture`);
|
|
744
|
+
else {
|
|
745
|
+
const copied = new Uint8Array(buf);
|
|
746
|
+
const floats = new Float32Array(copied.buffer, 0, buf.byteLength / 4);
|
|
747
|
+
return { embedding: Array.from(floats) };
|
|
748
|
+
}
|
|
563
749
|
}
|
|
564
|
-
if (
|
|
565
|
-
const
|
|
750
|
+
if (obj.data.some((item) => item.url || item.b64_json)) {
|
|
751
|
+
const dataItems = obj.data;
|
|
752
|
+
const images = dataItems.filter((item) => item.url || item.b64_json).map((item) => ({
|
|
566
753
|
...item.url ? { url: String(item.url) } : {},
|
|
567
754
|
...item.b64_json ? { b64Json: String(item.b64_json) } : {},
|
|
568
755
|
...item.revised_prompt ? { revisedPrompt: String(item.revised_prompt) } : {}
|
|
569
756
|
}));
|
|
757
|
+
const droppedCount = dataItems.length - images.length;
|
|
758
|
+
if (droppedCount > 0) logger?.warn(`Dropped ${droppedCount} image item(s) from batch lacking a non-empty url or b64_json`);
|
|
570
759
|
if (images.length === 1) return { image: images[0] };
|
|
571
760
|
return { images };
|
|
572
761
|
}
|
|
@@ -579,7 +768,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
579
768
|
if (images.length === 1) return { image: images[0] };
|
|
580
769
|
return { images };
|
|
581
770
|
}
|
|
582
|
-
if (typeof obj.text === "string" && (obj.task === "transcribe" || obj.language !== void 0
|
|
771
|
+
if (typeof obj.text === "string" && (obj.task === "transcribe" || obj.language !== void 0 && obj.duration !== void 0) && !("choices" in obj) && !("candidates" in obj) && !("object" in obj) && !("message" in obj) && !("outputs" in obj)) return { transcription: {
|
|
583
772
|
text: obj.text,
|
|
584
773
|
...obj.language ? { language: String(obj.language) } : {},
|
|
585
774
|
...obj.duration !== void 0 ? { duration: Number(obj.duration) } : {},
|
|
@@ -596,7 +785,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
596
785
|
if (hasToolCalls) {
|
|
597
786
|
const toolCalls = fnCallOutputs.map((o) => ({
|
|
598
787
|
name: String(o.name),
|
|
599
|
-
arguments:
|
|
788
|
+
arguments: toToolCallArguments(o.arguments),
|
|
600
789
|
...o.id ? { id: String(o.id) } : {}
|
|
601
790
|
}));
|
|
602
791
|
if (hasContent) return {
|
|
@@ -625,13 +814,13 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
625
814
|
if (message) {
|
|
626
815
|
const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
627
816
|
const hasContent = typeof message.content === "string" && message.content.length > 0;
|
|
628
|
-
const openaiReasoning = typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 ? message.reasoning_content : void 0;
|
|
817
|
+
const openaiReasoning = typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 ? message.reasoning_content : typeof message.reasoning === "string" && message.reasoning.length > 0 ? message.reasoning : void 0;
|
|
629
818
|
if (hasToolCalls) {
|
|
630
819
|
const toolCalls = message.tool_calls.map((tc) => {
|
|
631
820
|
const fn = tc.function;
|
|
632
821
|
return {
|
|
633
822
|
name: String(fn.name),
|
|
634
|
-
arguments:
|
|
823
|
+
arguments: toToolCallArguments(fn.arguments),
|
|
635
824
|
...tc.id ? { id: String(tc.id) } : {}
|
|
636
825
|
};
|
|
637
826
|
});
|
|
@@ -677,7 +866,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
677
866
|
if (hasToolCalls) {
|
|
678
867
|
const toolCalls = toolUseBlocks.map((b) => ({
|
|
679
868
|
name: String(b.name),
|
|
680
|
-
arguments:
|
|
869
|
+
arguments: toToolCallArguments(b.input),
|
|
681
870
|
...b.id ? { id: String(b.id) } : {}
|
|
682
871
|
}));
|
|
683
872
|
if (hasContent) return {
|
|
@@ -711,12 +900,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
711
900
|
const content = obj.candidates[0].content;
|
|
712
901
|
if (content && Array.isArray(content.parts)) {
|
|
713
902
|
const parts = content.parts;
|
|
714
|
-
const audioParts = parts.filter((p) =>
|
|
903
|
+
const audioParts = parts.filter((p) => {
|
|
904
|
+
const inline = p.inlineData;
|
|
905
|
+
if (inline === void 0 || inline === null || typeof inline.data !== "string" || inline.data.length === 0) return false;
|
|
906
|
+
const mt = inline.mimeType;
|
|
907
|
+
if (typeof mt === "string" && mt.length > 0) return mt.startsWith("audio/");
|
|
908
|
+
return true;
|
|
909
|
+
});
|
|
715
910
|
if (audioParts.length > 0) {
|
|
716
911
|
const inlineData = audioParts[0].inlineData;
|
|
717
912
|
return { audio: {
|
|
718
913
|
b64Json: String(inlineData.data ?? ""),
|
|
719
|
-
contentType:
|
|
914
|
+
contentType: typeof inlineData.mimeType === "string" && inlineData.mimeType.length > 0 ? inlineData.mimeType : "audio/mpeg"
|
|
720
915
|
} };
|
|
721
916
|
}
|
|
722
917
|
const fnCallParts = parts.filter((p) => p.functionCall);
|
|
@@ -731,7 +926,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
731
926
|
const fc = p.functionCall;
|
|
732
927
|
return {
|
|
733
928
|
name: String(fc.name),
|
|
734
|
-
arguments:
|
|
929
|
+
arguments: toToolCallArguments(fc.args)
|
|
735
930
|
};
|
|
736
931
|
});
|
|
737
932
|
if (hasContent) return {
|
|
@@ -773,7 +968,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
773
968
|
const tu = b.toolUse;
|
|
774
969
|
return {
|
|
775
970
|
name: String(tu.name ?? ""),
|
|
776
|
-
arguments:
|
|
971
|
+
arguments: toToolCallArguments(tu.input),
|
|
777
972
|
...tu.toolUseId ? { id: String(tu.toolUseId) } : {}
|
|
778
973
|
};
|
|
779
974
|
});
|
|
@@ -800,18 +995,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
800
995
|
if (typeof obj.finish_reason === "string" && obj.message && typeof obj.message === "object" && Array.isArray(obj.message.content)) {
|
|
801
996
|
const msg = obj.message;
|
|
802
997
|
const contentBlocks = msg.content;
|
|
803
|
-
const
|
|
804
|
-
const hasContent =
|
|
998
|
+
const joinedText = contentBlocks.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => String(b.text ?? "")).join("");
|
|
999
|
+
const hasContent = joinedText.length > 0;
|
|
805
1000
|
const toolCallBlocks = contentBlocks.filter((b) => b.type === "tool_call");
|
|
806
1001
|
const msgToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
807
1002
|
if (toolCallBlocks.length > 0) {
|
|
808
1003
|
const toolCalls = toolCallBlocks.map((b) => ({
|
|
809
1004
|
name: String(b.name ?? b.function?.name ?? ""),
|
|
810
|
-
arguments: typeof b.parameters === "string" ? b.parameters : typeof b.parameters === "object" ? JSON.stringify(b.parameters) : typeof b.function?.arguments === "string" ? String(b.function.arguments) :
|
|
1005
|
+
arguments: typeof b.parameters === "string" ? b.parameters : typeof b.parameters === "object" ? JSON.stringify(b.parameters) : typeof b.function?.arguments === "string" ? String(b.function.arguments) : toToolCallArguments(b.function?.arguments),
|
|
811
1006
|
...b.id ? { id: String(b.id) } : {}
|
|
812
1007
|
}));
|
|
813
1008
|
if (hasContent) return {
|
|
814
|
-
content:
|
|
1009
|
+
content: joinedText,
|
|
815
1010
|
toolCalls
|
|
816
1011
|
};
|
|
817
1012
|
return { toolCalls };
|
|
@@ -821,17 +1016,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
821
1016
|
const fn = tc.function;
|
|
822
1017
|
return {
|
|
823
1018
|
name: String(tc.name ?? fn?.name ?? ""),
|
|
824
|
-
arguments: typeof tc.parameters === "string" ? tc.parameters : typeof tc.parameters === "object" ? JSON.stringify(tc.parameters) : typeof fn?.arguments === "string" ? String(fn.arguments) :
|
|
1019
|
+
arguments: typeof tc.parameters === "string" ? tc.parameters : typeof tc.parameters === "object" ? JSON.stringify(tc.parameters) : typeof fn?.arguments === "string" ? String(fn.arguments) : toToolCallArguments(fn?.arguments),
|
|
825
1020
|
...tc.id ? { id: String(tc.id) } : {}
|
|
826
1021
|
};
|
|
827
1022
|
});
|
|
828
1023
|
if (hasContent) return {
|
|
829
|
-
content:
|
|
1024
|
+
content: joinedText,
|
|
830
1025
|
toolCalls
|
|
831
1026
|
};
|
|
832
1027
|
return { toolCalls };
|
|
833
1028
|
}
|
|
834
|
-
if (hasContent) return { content:
|
|
1029
|
+
if (hasContent) return { content: joinedText };
|
|
1030
|
+
return { content: "" };
|
|
835
1031
|
}
|
|
836
1032
|
if (obj.message && typeof obj.message === "object") {
|
|
837
1033
|
const msg = obj.message;
|
|
@@ -842,7 +1038,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
842
1038
|
const fn = tc.function;
|
|
843
1039
|
return {
|
|
844
1040
|
name: String(fn.name ?? ""),
|
|
845
|
-
arguments:
|
|
1041
|
+
arguments: toToolCallArguments(fn.arguments)
|
|
846
1042
|
};
|
|
847
1043
|
});
|
|
848
1044
|
if (hasOllamaContent) return {
|
|
@@ -857,7 +1053,8 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
|
|
|
857
1053
|
if (typeof first.text === "string") return { content: first.text };
|
|
858
1054
|
}
|
|
859
1055
|
}
|
|
860
|
-
if (typeof obj.response === "string" &&
|
|
1056
|
+
if (typeof obj.response === "string" && typeof obj.done === "boolean") return { content: obj.response };
|
|
1057
|
+
logger?.warn(`Could not detect response format from upstream (status=${status}) — saving as error fixture; top-level keys: [${Object.keys(obj).join(", ")}]`);
|
|
861
1058
|
return {
|
|
862
1059
|
error: {
|
|
863
1060
|
message: "Could not detect response format from upstream",
|