@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/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
- filepath = fixture.match.context ? node_path.join(fixturePath, fixture.match.context, timestampFile) : node_path.join(fixturePath, timestampFile);
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 (typeof existing._warning === "string" && existing._warning) fileWarnings.unshift(...existing._warning.split("; "));
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) fileContent._warning = [...new Set(fileWarnings)].join("; ");
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.end();
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 = Array.isArray(contentType) ? contentType.join(", ") : contentType ?? "";
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/") && rawBuffer.length > 0) {
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
- function makeUpstreamRequest(target, headers, body, clientRes, method = "POST", logger, timeouts) {
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 = Array.isArray(ct) ? ct.join(", ") : ct ?? "";
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
- res.on("data", (chunk) => {
464
- chunks.push(chunk);
465
- if (isSSE || isNDJSON) {
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++) if (parts[fi].trim().length > 0) frameTimestamps.push(Date.now());
470
- frameBuffer = parts[parts.length - 1];
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: rawBuffer.toString(),
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, encodingFormat, logger) {
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" && encodingFormat === "base64") {
741
+ if (typeof first.embedding === "string") {
558
742
  const buf = Buffer.from(first.embedding, "base64");
559
- if (buf.byteLength % 4 !== 0) return { embedding: [] };
560
- const copied = new Uint8Array(buf);
561
- const floats = new Float32Array(copied.buffer, 0, buf.byteLength / 4);
562
- return { embedding: Array.from(floats) };
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 (first.url || first.b64_json) {
565
- const images = obj.data.map((item) => ({
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 || obj.duration !== void 0)) return { transcription: {
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: typeof o.arguments === "string" ? o.arguments : JSON.stringify(o.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: String(fn.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: typeof b.input === "string" ? b.input : JSON.stringify(b.input),
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) => p.inlineData && typeof p.inlineData.mimeType === "string" && p.inlineData.mimeType.startsWith("audio/"));
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: String(inlineData.mimeType)
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: typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args)
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: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input),
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 textBlock = contentBlocks.find((b) => b.type === "text" && typeof b.text === "string");
804
- const hasContent = textBlock && typeof textBlock.text === "string" && textBlock.text.length > 0;
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) : JSON.stringify(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: textBlock.text,
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) : JSON.stringify(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: textBlock.text,
1024
+ content: joinedText,
830
1025
  toolCalls
831
1026
  };
832
1027
  return { toolCalls };
833
1028
  }
834
- if (hasContent) return { content: textBlock.text };
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: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.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" && "done" in obj) return { content: obj.response };
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",