@copilotkit/aimock 1.32.0 → 1.34.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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +4 -0
  5. package/dist/agui-types.d.cts.map +1 -1
  6. package/dist/agui-types.d.ts.map +1 -1
  7. package/dist/bedrock-converse.cjs +2 -2
  8. package/dist/bedrock-converse.cjs.map +1 -1
  9. package/dist/bedrock-converse.d.cts.map +1 -1
  10. package/dist/bedrock-converse.d.ts.map +1 -1
  11. package/dist/bedrock-converse.js +3 -3
  12. package/dist/bedrock-converse.js.map +1 -1
  13. package/dist/bedrock.cjs +2 -2
  14. package/dist/bedrock.cjs.map +1 -1
  15. package/dist/bedrock.d.cts.map +1 -1
  16. package/dist/bedrock.d.ts.map +1 -1
  17. package/dist/bedrock.js +3 -3
  18. package/dist/bedrock.js.map +1 -1
  19. package/dist/cli.cjs +26 -2
  20. package/dist/cli.cjs.map +1 -1
  21. package/dist/cli.js +26 -2
  22. package/dist/cli.js.map +1 -1
  23. package/dist/cohere.cjs +2 -2
  24. package/dist/cohere.cjs.map +1 -1
  25. package/dist/cohere.js +3 -3
  26. package/dist/cohere.js.map +1 -1
  27. package/dist/config-loader.d.cts.map +1 -1
  28. package/dist/gemini-interactions.cjs +71 -70
  29. package/dist/gemini-interactions.cjs.map +1 -1
  30. package/dist/gemini-interactions.d.cts.map +1 -1
  31. package/dist/gemini-interactions.d.ts.map +1 -1
  32. package/dist/gemini-interactions.js +71 -70
  33. package/dist/gemini-interactions.js.map +1 -1
  34. package/dist/gemini.cjs +1 -1
  35. package/dist/gemini.cjs.map +1 -1
  36. package/dist/gemini.js +2 -2
  37. package/dist/gemini.js.map +1 -1
  38. package/dist/helpers.cjs +19 -2
  39. package/dist/helpers.cjs.map +1 -1
  40. package/dist/helpers.d.cts.map +1 -1
  41. package/dist/helpers.d.ts.map +1 -1
  42. package/dist/helpers.js +19 -3
  43. package/dist/helpers.js.map +1 -1
  44. package/dist/llmock.cjs +23 -10
  45. package/dist/llmock.cjs.map +1 -1
  46. package/dist/llmock.d.cts.map +1 -1
  47. package/dist/llmock.d.ts.map +1 -1
  48. package/dist/llmock.js +23 -10
  49. package/dist/llmock.js.map +1 -1
  50. package/dist/messages.cjs +1 -1
  51. package/dist/messages.cjs.map +1 -1
  52. package/dist/messages.js +2 -2
  53. package/dist/messages.js.map +1 -1
  54. package/dist/ollama.cjs +3 -3
  55. package/dist/ollama.cjs.map +1 -1
  56. package/dist/ollama.d.cts.map +1 -1
  57. package/dist/ollama.d.ts.map +1 -1
  58. package/dist/ollama.js +4 -4
  59. package/dist/ollama.js.map +1 -1
  60. package/dist/recorder.cjs +252 -55
  61. package/dist/recorder.cjs.map +1 -1
  62. package/dist/recorder.d.cts.map +1 -1
  63. package/dist/recorder.d.ts.map +1 -1
  64. package/dist/recorder.js +253 -56
  65. package/dist/recorder.js.map +1 -1
  66. package/dist/responses.cjs +1 -1
  67. package/dist/responses.cjs.map +1 -1
  68. package/dist/responses.js +2 -2
  69. package/dist/responses.js.map +1 -1
  70. package/dist/router.cjs +236 -33
  71. package/dist/router.cjs.map +1 -1
  72. package/dist/router.d.cts +54 -2
  73. package/dist/router.d.cts.map +1 -1
  74. package/dist/router.d.ts +54 -2
  75. package/dist/router.d.ts.map +1 -1
  76. package/dist/router.js +236 -34
  77. package/dist/router.js.map +1 -1
  78. package/dist/server.cjs +1 -1
  79. package/dist/server.cjs.map +1 -1
  80. package/dist/server.d.cts.map +1 -1
  81. package/dist/server.d.ts.map +1 -1
  82. package/dist/server.js +2 -2
  83. package/dist/server.js.map +1 -1
  84. package/dist/stream-collapse.cjs +56 -6
  85. package/dist/stream-collapse.cjs.map +1 -1
  86. package/dist/stream-collapse.d.cts +10 -3
  87. package/dist/stream-collapse.d.cts.map +1 -1
  88. package/dist/stream-collapse.d.ts +10 -3
  89. package/dist/stream-collapse.d.ts.map +1 -1
  90. package/dist/stream-collapse.js +56 -6
  91. package/dist/stream-collapse.js.map +1 -1
  92. package/dist/types.d.cts +22 -0
  93. package/dist/types.d.cts.map +1 -1
  94. package/dist/types.d.ts +22 -0
  95. package/dist/types.d.ts.map +1 -1
  96. package/package.json +1 -1
package/dist/recorder.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_TEST_ID } from "./constants.js";
2
2
  import { normalizeModelName } from "./model-utils.js";
3
- import { getTestId, slugifyTestId } from "./helpers.js";
3
+ import { getTestId, slugifyContext, slugifyTestId } from "./helpers.js";
4
4
  import { getLastMessageByRole, getTextContent } from "./router.js";
5
5
  import { writeErrorResponse } from "./sse-writer.js";
6
6
  import { capturedRedactedData, collapseStreamingResponse } from "./stream-collapse.js";
@@ -14,6 +14,52 @@ import { StringDecoder } from "node:string_decoder";
14
14
 
15
15
  //#region src/recorder.ts
16
16
  /** Headers to strip when proxying — hop-by-hop (RFC 2616 §13.5.1) + client-set. */
17
+ /**
18
+ * Default ceiling (bytes) for the in-memory proxy-path buffer. Chosen well
19
+ * under V8's ~512 MiB max string length so `rawBuffer.toString()` /
20
+ * stream-collapse never throws `RangeError: Invalid string length`, and so a
21
+ * single huge proxied response cannot spike the heap unbounded. Overridable
22
+ * via `RecordConfig.maxProxyBufferBytes` / `--max-proxy-buffer-bytes`.
23
+ */
24
+ const DEFAULT_MAX_PROXY_BUFFER_BYTES = 64 * 1024 * 1024;
25
+ /**
26
+ * Default ceiling for the number of SSE/NDJSON/EventStream frames whose
27
+ * per-frame state (`frameTimestamps`, parse buffers) aimock retains for a
28
+ * single proxied response. Frame state is count-indexed, not byte-sized, so a
29
+ * long-lived / never-ending stream accumulates `frameTimestamps` entries (and,
30
+ * if a frame never completes, parse-buffer bytes) UNBOUNDED even when the byte
31
+ * cap is generous — observed as multi-GB heap growth over many hours from a few
32
+ * long nested-sub-agent streams. Tripping truncation on EITHER bytes OR frame
33
+ * count bounds both. 5M frames is generous for any real response (a normal
34
+ * completion is hundreds-to-thousands of frames) while still bounding a runaway
35
+ * stream to ~tens of MB of frame state. Overridable via
36
+ * `RecordConfig.maxProxyBufferFrames` / `--max-proxy-buffer-frames`.
37
+ */
38
+ const DEFAULT_MAX_PROXY_BUFFER_FRAMES = 5e6;
39
+ /**
40
+ * Absolute hard ceiling (bytes) for any in-memory proxy buffer, independent of
41
+ * the configurable `maxProxyBufferBytes`. V8's maximum STRING length on 64-bit
42
+ * is 2^29 - 1 (~512 MiB of UTF-16 code units), and the proxy buffer is
43
+ * eventually stringified via `rawBuffer.toString()` for collapse/relay — so the
44
+ * BYTE buffer must stay safely under that string limit or the toString throws
45
+ * `RangeError: Invalid string length`. 256 MiB of bytes is well under the
46
+ * ~512 MiB string-length boundary (these are different units — bytes vs UTF-16
47
+ * code units — but 256 MiB of bytes can never decode to more than 256 Mi code
48
+ * units, comfortably below 2^29 - 1). Used to clamp the configurable cap AND to
49
+ * bound the non-progressive relay buffer that must be retained past a cap trip.
50
+ */
51
+ const PROXY_BUFFER_HARD_CEILING = 256 * 1024 * 1024;
52
+ /**
53
+ * Test-only override of the effective hard ceiling. Lets the proxy-buffer
54
+ * enforcement suite exercise the >hard-ceiling fail-loud path with a small
55
+ * body instead of streaming 256 MiB. `undefined` (the default) uses the real
56
+ * `PROXY_BUFFER_HARD_CEILING`. NEVER set from production code.
57
+ */
58
+ let proxyBufferHardCeilingOverride;
59
+ /** Effective hard ceiling, honoring any active test-only override. */
60
+ function effectiveHardCeiling() {
61
+ return proxyBufferHardCeilingOverride ?? PROXY_BUFFER_HARD_CEILING;
62
+ }
17
63
  const STRIP_HEADERS = new Set([
18
64
  "connection",
19
65
  "keep-alive",
@@ -59,6 +105,19 @@ function sanitizeHeaderValue(value) {
59
105
  return value.replace(/[^\t\x20-\x7e\x80-\xff]/g, "?");
60
106
  }
61
107
  /**
108
+ * Resolve a `content-type` header value to a single string. Node's
109
+ * `http.IncomingHttpHeaders` types `content-type` as `string | string[]`, and
110
+ * a constructed/proxied header object CAN carry an array (e.g. duplicated
111
+ * header lines surfaced by some HTTP stacks). `join(", ")`-ing such an array
112
+ * produces a malformed `Content-Type: application/json, text/html` value — pick
113
+ * the FIRST element instead, which is the value the client should act on.
114
+ * Returns `""` when the header is absent or an empty array.
115
+ */
116
+ function pickContentType(contentType) {
117
+ if (Array.isArray(contentType)) return contentType.length > 0 ? contentType[0] : "";
118
+ return contentType ?? "";
119
+ }
120
+ /**
62
121
  * Write a built fixture to disk (snapshot vs. timestamp file layout) and, when
63
122
  * the match is non-empty, register it in the in-memory cache so subsequent
64
123
  * identical requests match. Extracted from `proxyAndRecord` so the fal
@@ -91,7 +150,8 @@ function persistFixture(opts) {
91
150
  }
92
151
  } else {
93
152
  const timestampFile = `${providerKey}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${crypto$1.randomUUID().slice(0, 8)}.json`;
94
- filepath = fixture.match.context ? path.join(fixturePath, fixture.match.context, timestampFile) : path.join(fixturePath, timestampFile);
153
+ const contextSegment = fixture.match.context ? slugifyContext(fixture.match.context) : void 0;
154
+ filepath = contextSegment ? path.join(fixturePath, contextSegment, timestampFile) : path.join(fixturePath, timestampFile);
95
155
  }
96
156
  const fileWarnings = [...isEmptyMatch ? ["Empty match criteria — this fixture will not match any request"] : [], ...warnings];
97
157
  try {
@@ -102,14 +162,19 @@ function persistFixture(opts) {
102
162
  const existingFixtures = Array.isArray(existing.fixtures) ? existing.fixtures : void 0;
103
163
  if (existingFixtures === void 0 && existing.fixtures !== void 0) logger.warn(`Existing fixture file ${filepath} has a non-array "fixtures" — discarding it and starting fresh`);
104
164
  fileContent = { fixtures: [...existingFixtures ?? [], fixture] };
105
- if (typeof existing._warning === "string" && existing._warning) fileWarnings.unshift(...existing._warning.split("; "));
165
+ if (Array.isArray(existing._warnings)) fileWarnings.unshift(...existing._warnings.filter((w) => typeof w === "string"));
166
+ else if (typeof existing._warning === "string" && existing._warning) fileWarnings.unshift(...existing._warning.split("; "));
106
167
  } catch (mergeErr) {
107
168
  const msg = mergeErr instanceof Error ? mergeErr.message : "unknown";
108
169
  logger.warn(`Could not read existing fixture file ${filepath} (${msg}) — overwriting`);
109
170
  fileContent = { fixtures: [fixture] };
110
171
  }
111
172
  else fileContent = { fixtures: [fixture] };
112
- if (fileWarnings.length > 0) fileContent._warning = [...new Set(fileWarnings)].join("; ");
173
+ if (fileWarnings.length > 0) {
174
+ const dedupedWarnings = [...new Set(fileWarnings)];
175
+ fileContent._warnings = dedupedWarnings;
176
+ fileContent._warning = dedupedWarnings.join("; ");
177
+ }
113
178
  const tmpPath = filepath + ".tmp." + process.pid;
114
179
  fs.writeFileSync(tmpPath, JSON.stringify(fileContent, null, 2), "utf-8");
115
180
  fs.renameSync(tmpPath, filepath);
@@ -160,15 +225,21 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
160
225
  let upstreamHeaders;
161
226
  let upstreamBody;
162
227
  let rawBuffer;
228
+ let bufferTruncated = false;
229
+ let truncationCap;
230
+ let totalBytes = 0;
231
+ let hardCeilingExceeded = false;
163
232
  let streamedToClient = false;
164
233
  let clientDisconnected = false;
165
234
  let frameTimestamps = [];
166
235
  let streamStartTime = 0;
236
+ const maxProxyBufferBytes = clampMaxBufferBytes(record.maxProxyBufferBytes);
237
+ const maxProxyBufferFrames = clampMaxBufferFrames(record.maxProxyBufferFrames);
167
238
  try {
168
239
  const result = await makeUpstreamRequest(target, forwardHeaders, requestBody, res, req.method, defaults.logger, {
169
240
  upstreamTimeoutMs: record.upstreamTimeoutMs,
170
241
  bodyTimeoutMs: record.bodyTimeoutMs
171
- });
242
+ }, maxProxyBufferBytes, maxProxyBufferFrames);
172
243
  upstreamStatus = result.status;
173
244
  upstreamHeaders = result.headers;
174
245
  upstreamBody = result.body;
@@ -177,6 +248,10 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
177
248
  clientDisconnected = result.clientDisconnected;
178
249
  frameTimestamps = result.frameTimestamps;
179
250
  streamStartTime = result.streamStartTime;
251
+ bufferTruncated = result.bufferTruncated;
252
+ truncationCap = result.truncationCap;
253
+ totalBytes = result.totalBytes;
254
+ hardCeilingExceeded = result.hardCeilingExceeded;
180
255
  } catch (err) {
181
256
  const msg = err instanceof Error ? err.message : "Unknown proxy error";
182
257
  defaults.logger.error(`Proxy request failed: ${msg}`);
@@ -186,16 +261,40 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
186
261
  message: `Proxy to upstream failed: ${msg}`,
187
262
  type: "proxy_error"
188
263
  } }));
189
- } else res.end();
264
+ } else res.destroy();
265
+ return "relayed";
266
+ }
267
+ if (bufferTruncated) {
268
+ const capDetail = truncationCap === "frame" ? `the ${maxProxyBufferFrames}-frame cap` : `the ${maxProxyBufferBytes}-byte cap`;
269
+ if (!streamedToClient && hardCeilingExceeded && !res.headersSent) {
270
+ const ceilingMiB = Math.round(PROXY_BUFFER_HARD_CEILING / (1024 * 1024));
271
+ 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`);
272
+ res.writeHead(502, { "Content-Type": "application/json" });
273
+ res.end(JSON.stringify({ error: {
274
+ message: `Upstream response exceeds ${ceilingMiB}MiB proxy ceiling`,
275
+ type: "proxy_error"
276
+ } }));
277
+ return "relayed";
278
+ }
279
+ defaults.logger.warn(`Upstream response exceeded ${capDetail} (saw ${totalBytes} bytes) — relayed to client, recording skipped`);
280
+ if (!streamedToClient && !res.headersSent) {
281
+ const clientStatus = upstreamStatus >= 200 && upstreamStatus < 300 ? 200 : 502;
282
+ const relayHeaders = {};
283
+ const ct = upstreamHeaders["content-type"];
284
+ relayHeaders["Content-Type"] = (Array.isArray(ct) ? ct.join(", ") : ct ?? "") || "application/json";
285
+ res.writeHead(clientStatus, relayHeaders);
286
+ res.end(rawBuffer);
287
+ } else if (!res.writableEnded) res.end();
190
288
  return "relayed";
191
289
  }
192
290
  const contentType = upstreamHeaders["content-type"];
193
- const ctString = Array.isArray(contentType) ? contentType.join(", ") : contentType ?? "";
291
+ const ctString = pickContentType(contentType);
194
292
  const isBinaryStream = ctString.toLowerCase().includes("application/vnd.amazon.eventstream");
195
293
  const collapsed = collapseStreamingResponse(ctString, providerKey, isBinaryStream ? rawBuffer : upstreamBody, defaults.logger);
196
294
  let fixtureResponse;
197
- if (ctString.toLowerCase().startsWith("audio/") && rawBuffer.length > 0) {
295
+ if (ctString.toLowerCase().startsWith("audio/") && upstreamStatus >= 200 && upstreamStatus < 300) {
198
296
  const audioFormat = ctString.toLowerCase().replace("audio/", "").replace("mpeg", "mp3").split(";")[0].trim();
297
+ if (rawBuffer.length === 0) defaults.logger.warn("Audio response had a zero-length body — recording an empty audio fixture");
199
298
  fixtureResponse = {
200
299
  audio: rawBuffer.toString("base64"),
201
300
  ...audioFormat && audioFormat !== "mp3" ? { format: audioFormat } : {}
@@ -295,15 +394,7 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
295
394
  json: parsedResponse,
296
395
  status: upstreamStatus
297
396
  };
298
- } else {
299
- let encodingFormat;
300
- try {
301
- encodingFormat = rawBody ? JSON.parse(rawBody).encoding_format : void 0;
302
- } catch (err) {
303
- defaults.logger.debug(`Could not parse encoding_format from raw body: ${err instanceof Error ? err.message : "unknown error"}`);
304
- }
305
- fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, encodingFormat, defaults.logger);
306
- }
397
+ } else fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, defaults.logger);
307
398
  }
308
399
  if (clientDisconnected) {
309
400
  defaults.logger.warn("Client disconnected mid-stream — skipping fixture save to avoid truncated data");
@@ -404,11 +495,31 @@ function clampTimeout(value, fallback) {
404
495
  if (value == null || !Number.isFinite(value) || value <= 0) return fallback;
405
496
  return value;
406
497
  }
407
- function makeUpstreamRequest(target, headers, body, clientRes, method = "POST", logger, timeouts) {
498
+ /**
499
+ * Sanitize the configured proxy-buffer cap: non-finite or non-positive values
500
+ * fall back to the default. Also hard-clamps to just under V8's max string
501
+ * length so a misconfigured large value can never permit an over-string-limit
502
+ * buffer to reach `.toString()`.
503
+ */
504
+ function clampMaxBufferBytes(value) {
505
+ if (value == null || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_PROXY_BUFFER_BYTES;
506
+ return Math.min(value, PROXY_BUFFER_HARD_CEILING);
507
+ }
508
+ /**
509
+ * Sanitize the configured proxy-buffer frame cap: non-finite or non-positive
510
+ * values fall back to the default. Bounds the count-indexed per-frame state
511
+ * (`frameTimestamps` + parse buffers) that the byte cap cannot bound on its own.
512
+ */
513
+ function clampMaxBufferFrames(value) {
514
+ if (value == null || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_PROXY_BUFFER_FRAMES;
515
+ return Math.floor(value);
516
+ }
517
+ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST", logger, timeouts, maxBufferBytes = DEFAULT_MAX_PROXY_BUFFER_BYTES, maxBufferFrames = DEFAULT_MAX_PROXY_BUFFER_FRAMES) {
408
518
  return new Promise((resolve, reject) => {
409
519
  const transport = target.protocol === "https:" ? https : http$1;
410
520
  const UPSTREAM_TIMEOUT_MS = clampTimeout(timeouts?.upstreamTimeoutMs, 3e4);
411
521
  const BODY_TIMEOUT_MS = clampTimeout(timeouts?.bodyTimeoutMs, 3e4);
522
+ let requestSendTime = 0;
412
523
  const req = transport.request(target, {
413
524
  method,
414
525
  timeout: UPSTREAM_TIMEOUT_MS,
@@ -421,14 +532,14 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
421
532
  req.destroy(/* @__PURE__ */ new Error(`Upstream response timed out after ${BODY_TIMEOUT_MS / 1e3}s`));
422
533
  });
423
534
  const ct = res.headers["content-type"];
424
- const ctStr = Array.isArray(ct) ? ct.join(", ") : ct ?? "";
535
+ const ctStr = pickContentType(ct);
425
536
  const ctLower = ctStr.toLowerCase();
426
537
  const isSSE = ctLower.includes("text/event-stream");
427
538
  const isNDJSON = ctLower.includes("application/x-ndjson");
428
539
  const isBinaryEventStream = ctLower.includes("application/vnd.amazon.eventstream");
429
540
  const isProgressiveStream = isSSE || isNDJSON || isBinaryEventStream;
430
541
  const frameTimestamps = [];
431
- const streamStartTime = Date.now();
542
+ const streamStartTime = requestSendTime || Date.now();
432
543
  let frameBuffer = "";
433
544
  const frameDecoder = new StreamingFrameDecoder();
434
545
  let binaryFrameBuffer = Buffer.alloc(0);
@@ -450,22 +561,75 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
450
561
  if (!clientRes.writableFinished) {
451
562
  clientDisconnected = true;
452
563
  req.destroy();
564
+ res.removeListener("data", onUpstreamData);
565
+ chunks.length = 0;
566
+ bufferedBytes = 0;
567
+ frameTimestamps.length = 0;
568
+ frameBuffer = "";
569
+ binaryFrameBuffer = Buffer.alloc(0);
453
570
  }
454
571
  });
455
572
  }
456
573
  const chunks = [];
457
- res.on("data", (chunk) => {
458
- chunks.push(chunk);
459
- if (isSSE || isNDJSON) {
574
+ let bufferedBytes = 0;
575
+ let totalBytes = 0;
576
+ let bufferTruncated = false;
577
+ /** Which cap tripped truncation — surfaced to the caller for an accurate warning. */
578
+ let truncationCap;
579
+ const hardCeiling = effectiveHardCeiling();
580
+ /**
581
+ * True when a NON-progressive upstream body exceeded `hardCeiling`, so
582
+ * the buffered `rawBuffer` is a PARTIAL copy that must NOT be relayed as
583
+ * success. Distinct from `bufferTruncated` (which also covers the
584
+ * under-ceiling over-soft-cap case where the full body IS retained).
585
+ */
586
+ let hardCeilingExceeded = false;
587
+ const tripTruncation = (cap) => {
588
+ bufferTruncated = true;
589
+ truncationCap = cap;
590
+ frameTimestamps.length = 0;
591
+ frameBuffer = "";
592
+ frameDecoder.end();
593
+ binaryFrameBuffer = Buffer.alloc(0);
594
+ if (isProgressiveStream) {
595
+ chunks.length = 0;
596
+ bufferedBytes = 0;
597
+ }
598
+ const detail = cap === "byte" ? `byte cap (${maxBufferBytes} bytes)` : `frame cap (${maxBufferFrames} frames)`;
599
+ logger?.warn(`Upstream response exceeded the proxy buffer ${detail} — relaying full body to client, but skipping in-memory collapse/recording to bound memory`);
600
+ };
601
+ const onUpstreamData = (chunk) => {
602
+ totalBytes += chunk.length;
603
+ if (!bufferTruncated && bufferedBytes + chunk.length > maxBufferBytes) tripTruncation("byte");
604
+ else if (!bufferTruncated && frameTimestamps.length >= maxBufferFrames) tripTruncation("frame");
605
+ if (!bufferTruncated) {
606
+ chunks.push(chunk);
607
+ bufferedBytes += chunk.length;
608
+ } else if (!isProgressiveStream) if (bufferedBytes + chunk.length <= hardCeiling) {
609
+ chunks.push(chunk);
610
+ bufferedBytes += chunk.length;
611
+ } else hardCeilingExceeded = true;
612
+ if (!bufferTruncated && (isSSE || isNDJSON)) {
460
613
  frameBuffer += frameDecoder.write(chunk);
461
614
  const delimiter = isNDJSON ? /\r?\n/ : /\r?\n\r?\n/;
462
615
  const parts = frameBuffer.split(delimiter);
463
- for (let fi = 0; fi < parts.length - 1; fi++) if (parts[fi].trim().length > 0) frameTimestamps.push(Date.now());
464
- frameBuffer = parts[parts.length - 1];
616
+ for (let fi = 0; fi < parts.length - 1; fi++) {
617
+ if (frameTimestamps.length >= maxBufferFrames) {
618
+ tripTruncation("frame");
619
+ break;
620
+ }
621
+ if (parts[fi].trim().length > 0) frameTimestamps.push(Date.now());
622
+ }
623
+ if (!bufferTruncated) frameBuffer = parts[parts.length - 1];
465
624
  }
466
- if (isBinaryEventStream) {
625
+ if (!bufferTruncated && isBinaryEventStream) if (bufferedBytes + binaryFrameBuffer.length > maxBufferBytes) tripTruncation("byte");
626
+ else {
467
627
  binaryFrameBuffer = Buffer.concat([binaryFrameBuffer, chunk]);
468
628
  while (binaryFrameBuffer.length >= 4) {
629
+ if (frameTimestamps.length >= maxBufferFrames) {
630
+ tripTruncation("frame");
631
+ break;
632
+ }
469
633
  const totalLen = binaryFrameBuffer.readUInt32BE(0);
470
634
  if (totalLen < 12 || binaryFrameBuffer.length < totalLen) break;
471
635
  frameTimestamps.push(Date.now());
@@ -478,11 +642,12 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
478
642
  logger?.debug(`Failed to relay chunk to client: ${writeErr instanceof Error ? writeErr.message : "unknown"}`);
479
643
  clientDisconnected = true;
480
644
  }
481
- });
645
+ };
646
+ res.on("data", onUpstreamData);
482
647
  res.on("error", reject);
483
648
  res.on("end", () => {
484
649
  if (res.socket) res.setTimeout(0);
485
- if (isSSE || isNDJSON) {
650
+ if (!bufferTruncated && (isSSE || isNDJSON)) {
486
651
  frameBuffer += frameDecoder.end();
487
652
  if (frameBuffer.trim().length > 0) frameTimestamps.push(Date.now());
488
653
  }
@@ -492,15 +657,20 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
492
657
  } catch (endErr) {
493
658
  logger?.debug(`Failed to end client response: ${endErr instanceof Error ? endErr.message : "unknown"}`);
494
659
  }
660
+ const bodyString = !bufferTruncated || bufferTruncated && !streamedToClient ? rawBuffer.toString() : "";
495
661
  resolve({
496
662
  status: res.statusCode ?? 500,
497
663
  headers: res.headers,
498
- body: rawBuffer.toString(),
664
+ body: bodyString,
499
665
  rawBuffer,
500
666
  streamedToClient,
501
667
  clientDisconnected,
502
668
  frameTimestamps,
503
- streamStartTime
669
+ streamStartTime,
670
+ bufferTruncated,
671
+ truncationCap,
672
+ totalBytes,
673
+ hardCeilingExceeded
504
674
  });
505
675
  });
506
676
  });
@@ -508,6 +678,7 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
508
678
  req.destroy(/* @__PURE__ */ new Error(`Upstream request timed out after ${UPSTREAM_TIMEOUT_MS / 1e3}s: ${target.href}`));
509
679
  });
510
680
  req.on("error", reject);
681
+ requestSendTime = Date.now();
511
682
  req.write(body);
512
683
  req.end();
513
684
  });
@@ -522,10 +693,23 @@ function logDroppedReasoningSignature(logger, reasoning, reasoningSignature) {
522
693
  if (reasoningSignature && !reasoning) logger?.warn("Dropping captured reasoningSignature — no plaintext reasoning to attach it to");
523
694
  }
524
695
  /**
696
+ * Coerce a tool call's `arguments` field into the string the fixture contract
697
+ * requires (types.ts `ToolCall.arguments: string`). When the upstream omits the
698
+ * arguments field entirely, `JSON.stringify(undefined)` yields the JS value
699
+ * `undefined` (not a string), which then gets dropped on disk-write and breaks
700
+ * cross-provider replay through the OpenAI streaming path. Coalesce to "{}" so
701
+ * `arguments` is ALWAYS a string, matching the streaming recorder's behavior.
702
+ */
703
+ function toToolCallArguments(raw) {
704
+ if (typeof raw === "string") return raw;
705
+ if (raw === void 0 || raw === null) return "{}";
706
+ return JSON.stringify(raw);
707
+ }
708
+ /**
525
709
  * Detect the response format from the parsed upstream JSON and convert
526
710
  * it into an aimock FixtureResponse.
527
711
  */
528
- function buildFixtureResponse(parsed, status, encodingFormat, logger) {
712
+ function buildFixtureResponse(parsed, status, logger) {
529
713
  if (parsed === null || parsed === void 0) return {
530
714
  error: {
531
715
  message: "Upstream returned non-JSON response",
@@ -548,19 +732,24 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
548
732
  if (Array.isArray(obj.data) && obj.data.length > 0) {
549
733
  const first = obj.data[0];
550
734
  if (Array.isArray(first.embedding)) return { embedding: first.embedding };
551
- if (typeof first.embedding === "string" && encodingFormat === "base64") {
735
+ if (typeof first.embedding === "string") {
552
736
  const buf = Buffer.from(first.embedding, "base64");
553
- if (buf.byteLength % 4 !== 0) return { embedding: [] };
554
- const copied = new Uint8Array(buf);
555
- const floats = new Float32Array(copied.buffer, 0, buf.byteLength / 4);
556
- return { embedding: Array.from(floats) };
737
+ 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`);
738
+ else {
739
+ const copied = new Uint8Array(buf);
740
+ const floats = new Float32Array(copied.buffer, 0, buf.byteLength / 4);
741
+ return { embedding: Array.from(floats) };
742
+ }
557
743
  }
558
- if (first.url || first.b64_json) {
559
- const images = obj.data.map((item) => ({
744
+ if (obj.data.some((item) => item.url || item.b64_json)) {
745
+ const dataItems = obj.data;
746
+ const images = dataItems.filter((item) => item.url || item.b64_json).map((item) => ({
560
747
  ...item.url ? { url: String(item.url) } : {},
561
748
  ...item.b64_json ? { b64Json: String(item.b64_json) } : {},
562
749
  ...item.revised_prompt ? { revisedPrompt: String(item.revised_prompt) } : {}
563
750
  }));
751
+ const droppedCount = dataItems.length - images.length;
752
+ if (droppedCount > 0) logger?.warn(`Dropped ${droppedCount} image item(s) from batch lacking a non-empty url or b64_json`);
564
753
  if (images.length === 1) return { image: images[0] };
565
754
  return { images };
566
755
  }
@@ -573,7 +762,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
573
762
  if (images.length === 1) return { image: images[0] };
574
763
  return { images };
575
764
  }
576
- if (typeof obj.text === "string" && (obj.task === "transcribe" || obj.language !== void 0 || obj.duration !== void 0)) return { transcription: {
765
+ 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: {
577
766
  text: obj.text,
578
767
  ...obj.language ? { language: String(obj.language) } : {},
579
768
  ...obj.duration !== void 0 ? { duration: Number(obj.duration) } : {},
@@ -590,7 +779,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
590
779
  if (hasToolCalls) {
591
780
  const toolCalls = fnCallOutputs.map((o) => ({
592
781
  name: String(o.name),
593
- arguments: typeof o.arguments === "string" ? o.arguments : JSON.stringify(o.arguments),
782
+ arguments: toToolCallArguments(o.arguments),
594
783
  ...o.id ? { id: String(o.id) } : {}
595
784
  }));
596
785
  if (hasContent) return {
@@ -619,13 +808,13 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
619
808
  if (message) {
620
809
  const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
621
810
  const hasContent = typeof message.content === "string" && message.content.length > 0;
622
- const openaiReasoning = typeof message.reasoning_content === "string" && message.reasoning_content.length > 0 ? message.reasoning_content : void 0;
811
+ 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;
623
812
  if (hasToolCalls) {
624
813
  const toolCalls = message.tool_calls.map((tc) => {
625
814
  const fn = tc.function;
626
815
  return {
627
816
  name: String(fn.name),
628
- arguments: String(fn.arguments),
817
+ arguments: toToolCallArguments(fn.arguments),
629
818
  ...tc.id ? { id: String(tc.id) } : {}
630
819
  };
631
820
  });
@@ -671,7 +860,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
671
860
  if (hasToolCalls) {
672
861
  const toolCalls = toolUseBlocks.map((b) => ({
673
862
  name: String(b.name),
674
- arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input),
863
+ arguments: toToolCallArguments(b.input),
675
864
  ...b.id ? { id: String(b.id) } : {}
676
865
  }));
677
866
  if (hasContent) return {
@@ -705,12 +894,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
705
894
  const content = obj.candidates[0].content;
706
895
  if (content && Array.isArray(content.parts)) {
707
896
  const parts = content.parts;
708
- const audioParts = parts.filter((p) => p.inlineData && typeof p.inlineData.mimeType === "string" && p.inlineData.mimeType.startsWith("audio/"));
897
+ const audioParts = parts.filter((p) => {
898
+ const inline = p.inlineData;
899
+ if (inline === void 0 || inline === null || typeof inline.data !== "string" || inline.data.length === 0) return false;
900
+ const mt = inline.mimeType;
901
+ if (typeof mt === "string" && mt.length > 0) return mt.startsWith("audio/");
902
+ return true;
903
+ });
709
904
  if (audioParts.length > 0) {
710
905
  const inlineData = audioParts[0].inlineData;
711
906
  return { audio: {
712
907
  b64Json: String(inlineData.data ?? ""),
713
- contentType: String(inlineData.mimeType)
908
+ contentType: typeof inlineData.mimeType === "string" && inlineData.mimeType.length > 0 ? inlineData.mimeType : "audio/mpeg"
714
909
  } };
715
910
  }
716
911
  const fnCallParts = parts.filter((p) => p.functionCall);
@@ -725,7 +920,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
725
920
  const fc = p.functionCall;
726
921
  return {
727
922
  name: String(fc.name),
728
- arguments: typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args)
923
+ arguments: toToolCallArguments(fc.args)
729
924
  };
730
925
  });
731
926
  if (hasContent) return {
@@ -767,7 +962,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
767
962
  const tu = b.toolUse;
768
963
  return {
769
964
  name: String(tu.name ?? ""),
770
- arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input),
965
+ arguments: toToolCallArguments(tu.input),
771
966
  ...tu.toolUseId ? { id: String(tu.toolUseId) } : {}
772
967
  };
773
968
  });
@@ -794,18 +989,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
794
989
  if (typeof obj.finish_reason === "string" && obj.message && typeof obj.message === "object" && Array.isArray(obj.message.content)) {
795
990
  const msg = obj.message;
796
991
  const contentBlocks = msg.content;
797
- const textBlock = contentBlocks.find((b) => b.type === "text" && typeof b.text === "string");
798
- const hasContent = textBlock && typeof textBlock.text === "string" && textBlock.text.length > 0;
992
+ const joinedText = contentBlocks.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => String(b.text ?? "")).join("");
993
+ const hasContent = joinedText.length > 0;
799
994
  const toolCallBlocks = contentBlocks.filter((b) => b.type === "tool_call");
800
995
  const msgToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
801
996
  if (toolCallBlocks.length > 0) {
802
997
  const toolCalls = toolCallBlocks.map((b) => ({
803
998
  name: String(b.name ?? b.function?.name ?? ""),
804
- arguments: typeof b.parameters === "string" ? b.parameters : typeof b.parameters === "object" ? JSON.stringify(b.parameters) : typeof b.function?.arguments === "string" ? String(b.function.arguments) : JSON.stringify(b.function?.arguments),
999
+ 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),
805
1000
  ...b.id ? { id: String(b.id) } : {}
806
1001
  }));
807
1002
  if (hasContent) return {
808
- content: textBlock.text,
1003
+ content: joinedText,
809
1004
  toolCalls
810
1005
  };
811
1006
  return { toolCalls };
@@ -815,17 +1010,18 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
815
1010
  const fn = tc.function;
816
1011
  return {
817
1012
  name: String(tc.name ?? fn?.name ?? ""),
818
- arguments: typeof tc.parameters === "string" ? tc.parameters : typeof tc.parameters === "object" ? JSON.stringify(tc.parameters) : typeof fn?.arguments === "string" ? String(fn.arguments) : JSON.stringify(fn?.arguments),
1013
+ 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),
819
1014
  ...tc.id ? { id: String(tc.id) } : {}
820
1015
  };
821
1016
  });
822
1017
  if (hasContent) return {
823
- content: textBlock.text,
1018
+ content: joinedText,
824
1019
  toolCalls
825
1020
  };
826
1021
  return { toolCalls };
827
1022
  }
828
- if (hasContent) return { content: textBlock.text };
1023
+ if (hasContent) return { content: joinedText };
1024
+ return { content: "" };
829
1025
  }
830
1026
  if (obj.message && typeof obj.message === "object") {
831
1027
  const msg = obj.message;
@@ -836,7 +1032,7 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
836
1032
  const fn = tc.function;
837
1033
  return {
838
1034
  name: String(fn.name ?? ""),
839
- arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments)
1035
+ arguments: toToolCallArguments(fn.arguments)
840
1036
  };
841
1037
  });
842
1038
  if (hasOllamaContent) return {
@@ -851,7 +1047,8 @@ function buildFixtureResponse(parsed, status, encodingFormat, logger) {
851
1047
  if (typeof first.text === "string") return { content: first.text };
852
1048
  }
853
1049
  }
854
- if (typeof obj.response === "string" && "done" in obj) return { content: obj.response };
1050
+ if (typeof obj.response === "string" && typeof obj.done === "boolean") return { content: obj.response };
1051
+ logger?.warn(`Could not detect response format from upstream (status=${status}) — saving as error fixture; top-level keys: [${Object.keys(obj).join(", ")}]`);
855
1052
  return {
856
1053
  error: {
857
1054
  message: "Could not detect response format from upstream",