@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.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
|
-
|
|
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 (
|
|
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)
|
|
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.
|
|
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 =
|
|
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/") &&
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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++)
|
|
464
|
-
|
|
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:
|
|
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,
|
|
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"
|
|
735
|
+
if (typeof first.embedding === "string") {
|
|
552
736
|
const buf = Buffer.from(first.embedding, "base64");
|
|
553
|
-
if (buf.byteLength % 4 !== 0)
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 (
|
|
559
|
-
const
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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) =>
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
798
|
-
const hasContent =
|
|
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) :
|
|
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:
|
|
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) :
|
|
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:
|
|
1018
|
+
content: joinedText,
|
|
824
1019
|
toolCalls
|
|
825
1020
|
};
|
|
826
1021
|
return { toolCalls };
|
|
827
1022
|
}
|
|
828
|
-
if (hasContent) return { content:
|
|
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:
|
|
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" &&
|
|
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",
|