@copilotkit/aimock 1.30.0 → 1.32.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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +2 -2
  5. package/dist/agui-types.d.ts.map +1 -1
  6. package/dist/cli.cjs +18 -1
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +18 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/fal-audio.cjs +12 -6
  11. package/dist/fal-audio.cjs.map +1 -1
  12. package/dist/fal-audio.d.cts.map +1 -1
  13. package/dist/fal-audio.d.ts.map +1 -1
  14. package/dist/fal-audio.js +14 -8
  15. package/dist/fal-audio.js.map +1 -1
  16. package/dist/fal.cjs +100 -54
  17. package/dist/fal.cjs.map +1 -1
  18. package/dist/fal.d.cts +13 -0
  19. package/dist/fal.d.cts.map +1 -1
  20. package/dist/fal.d.ts +13 -0
  21. package/dist/fal.d.ts.map +1 -1
  22. package/dist/fal.js +101 -55
  23. package/dist/fal.js.map +1 -1
  24. package/dist/index.cjs +10 -2
  25. package/dist/index.d.cts +2 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +4 -3
  28. package/dist/llmock.cjs +8 -3
  29. package/dist/llmock.cjs.map +1 -1
  30. package/dist/llmock.d.cts +5 -5
  31. package/dist/llmock.d.cts.map +1 -1
  32. package/dist/llmock.d.ts +5 -5
  33. package/dist/llmock.d.ts.map +1 -1
  34. package/dist/llmock.js +8 -3
  35. package/dist/llmock.js.map +1 -1
  36. package/dist/metrics.cjs +9 -0
  37. package/dist/metrics.cjs.map +1 -1
  38. package/dist/metrics.d.cts.map +1 -1
  39. package/dist/metrics.d.ts.map +1 -1
  40. package/dist/metrics.js +7 -1
  41. package/dist/metrics.js.map +1 -1
  42. package/dist/openrouter-video.cjs +1609 -0
  43. package/dist/openrouter-video.cjs.map +1 -0
  44. package/dist/openrouter-video.d.cts +154 -0
  45. package/dist/openrouter-video.d.cts.map +1 -0
  46. package/dist/openrouter-video.d.ts +154 -0
  47. package/dist/openrouter-video.d.ts.map +1 -0
  48. package/dist/openrouter-video.js +1601 -0
  49. package/dist/openrouter-video.js.map +1 -0
  50. package/dist/recorder.cjs +48 -7
  51. package/dist/recorder.cjs.map +1 -1
  52. package/dist/recorder.d.cts.map +1 -1
  53. package/dist/recorder.d.ts.map +1 -1
  54. package/dist/recorder.js +46 -8
  55. package/dist/recorder.js.map +1 -1
  56. package/dist/server.cjs +158 -10
  57. package/dist/server.cjs.map +1 -1
  58. package/dist/server.d.cts +2 -0
  59. package/dist/server.d.cts.map +1 -1
  60. package/dist/server.d.ts +2 -0
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +159 -11
  63. package/dist/server.js.map +1 -1
  64. package/dist/types.d.cts +100 -9
  65. package/dist/types.d.cts.map +1 -1
  66. package/dist/types.d.ts +100 -9
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/vector-types.d.cts.map +1 -1
  69. package/package.json +2 -2
@@ -0,0 +1,1609 @@
1
+ const require_runtime = require('./_virtual/_rolldown/runtime.cjs');
2
+ const require_constants = require('./constants.cjs');
3
+ const require_helpers = require('./helpers.cjs');
4
+ const require_router = require('./router.cjs');
5
+ const require_sse_writer = require('./sse-writer.cjs');
6
+ const require_chaos = require('./chaos.cjs');
7
+ const require_url = require('./url.cjs');
8
+ const require_recorder = require('./recorder.cjs');
9
+ const require_fal = require('./fal.cjs');
10
+ let node_crypto = require("node:crypto");
11
+ node_crypto = require_runtime.__toESM(node_crypto);
12
+
13
+ //#region src/openrouter-video.ts
14
+ const DEFAULT_OPENROUTER_VIDEO_MODEL = "bytedance/seedance-2.0";
15
+ const OPENROUTER_VIDEO_MAX_ENTRIES = 1e4;
16
+ const OPENROUTER_VIDEO_TTL_MS = 36e5;
17
+ /**
18
+ * Per-testId job state for the OpenRouter video handler. Mirrors
19
+ * FalQueueStateMap (fal.ts): lazy TTL eviction on `get`, FIFO eviction of the
20
+ * oldest entries on `set` when over capacity, no background sweep timer.
21
+ * Deliberate divergences from FalQueueStateMap:
22
+ * - lifetime: this map is per-server-instance (cleared on server
23
+ * close/reset) while FalQueueStateMap is module-global;
24
+ * - `set()` refreshes: delete-before-set moves a re-inserted key to the
25
+ * back of the FIFO order AND resets its createdAt, so a refreshed entry
26
+ * gets a fresh TTL — FalQueueStateMap's plain overwrite keeps the
27
+ * original insertion slot and is never used as a TTL refresh.
28
+ * Keys are `${testId}:${jobId}`.
29
+ */
30
+ var OpenRouterVideoJobMap = class {
31
+ entries = /* @__PURE__ */ new Map();
32
+ worldGeneration = 0;
33
+ /**
34
+ * Monotonic world-generation counter, incremented by `clear()` (a fixtures
35
+ * reset). Identity checks (`jobs.get(key) === job`) can only guard
36
+ * continuations whose job was ALREADY inserted — a continuation that has
37
+ * not inserted yet (the submit proxy's upstream fetch) captures this value
38
+ * before its await and compares after, so it can detect that the world it
39
+ * belongs to was reset mid-flight and skip seeding the new world.
40
+ */
41
+ get generation() {
42
+ return this.worldGeneration;
43
+ }
44
+ get(key) {
45
+ const entry = this.entries.get(key);
46
+ if (!entry) return void 0;
47
+ if (Date.now() - entry.createdAt > OPENROUTER_VIDEO_TTL_MS) {
48
+ this.entries.delete(key);
49
+ return;
50
+ }
51
+ return entry.job;
52
+ }
53
+ set(key, job) {
54
+ this.entries.delete(key);
55
+ this.entries.set(key, {
56
+ job,
57
+ createdAt: Date.now()
58
+ });
59
+ if (this.entries.size > OPENROUTER_VIDEO_MAX_ENTRIES) {
60
+ const excess = this.entries.size - OPENROUTER_VIDEO_MAX_ENTRIES;
61
+ const iter = this.entries.keys();
62
+ for (let i = 0; i < excess; i++) {
63
+ const next = iter.next();
64
+ if (!next.done) this.entries.delete(next.value);
65
+ }
66
+ }
67
+ }
68
+ delete(key) {
69
+ return this.entries.delete(key);
70
+ }
71
+ clear() {
72
+ this.entries.clear();
73
+ this.worldGeneration++;
74
+ }
75
+ get size() {
76
+ return this.entries.size;
77
+ }
78
+ };
79
+ /**
80
+ * Maps the fixture's terminal video status onto the job lifecycle. Anything
81
+ * that is not "failed" — including a fixture authored as "processing" — is
82
+ * treated as completed, since this surface always drives jobs to a terminal
83
+ * state. handleOpenRouterVideoCreate warns when a "processing" fixture is
84
+ * coerced this way.
85
+ */
86
+ function terminalStatus(job) {
87
+ return job.video.status === "failed" ? "failed" : "completed";
88
+ }
89
+ /**
90
+ * Mutates a job in place to advance its state on a status poll.
91
+ * `pending → in_progress → completed | failed` based on poll-count thresholds.
92
+ * No-op once terminal. The in_progress threshold is checked first so a job
93
+ * whose thresholds are equal still spends one poll in in_progress instead of
94
+ * jumping straight to the terminal status (fal advanceJob semantics).
95
+ */
96
+ function advanceJob(job) {
97
+ if (job.status === "completed" || job.status === "failed") return;
98
+ job.pollCount += 1;
99
+ if (job.status === "pending" && job.pollCount >= job.pollsBeforeInProgress) job.status = "in_progress";
100
+ else if (job.pollCount >= job.pollsBeforeCompleted) job.status = terminalStatus(job);
101
+ }
102
+ /**
103
+ * First non-empty value of a possibly array-typed, possibly comma-joined
104
+ * header. An empty header value or a leading-comma list (", host") would
105
+ * otherwise yield "" — triggering a spurious rejection warn and discarding
106
+ * valid later entries — so empty segments are skipped.
107
+ */
108
+ function firstForwardedValue(header) {
109
+ const raw = Array.isArray(header) ? header.join(",") : header;
110
+ if (raw === void 0) return void 0;
111
+ for (const segment of raw.split(",")) {
112
+ const trimmed = segment.trim();
113
+ if (trimmed) return trimmed;
114
+ }
115
+ }
116
+ const FORWARDED_HOST_RE = /^[a-zA-Z0-9._-]+(:\d+)?$/;
117
+ const FORWARDED_HOST_IPV6_RE = /^\[[0-9a-fA-F:.]+\](:\d+)?$/;
118
+ function requestBase(req, logger) {
119
+ const candidate = firstForwardedValue(req.headers["x-forwarded-proto"])?.toLowerCase();
120
+ const proto = candidate === "http" || candidate === "https" ? candidate : "http";
121
+ const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]);
122
+ const rawHost = req.headers.host;
123
+ let host = rawHost !== void 0 && (FORWARDED_HOST_RE.test(rawHost) || FORWARDED_HOST_IPV6_RE.test(rawHost)) ? rawHost : "localhost";
124
+ if (fwdHost !== void 0) if (FORWARDED_HOST_RE.test(fwdHost) || FORWARDED_HOST_IPV6_RE.test(fwdHost)) host = fwdHost;
125
+ else logger.warn(`x-forwarded-host value rejected, falling back to Host header: ${JSON.stringify(fwdHost.slice(0, 100))}`);
126
+ return `${proto}://${host}`;
127
+ }
128
+ /**
129
+ * Query-string suffix embedding the request's testId into generated URLs
130
+ * (polling_url, unsigned_urls). The @openrouter/sdk fetches these URLs with
131
+ * standard Authorization but no aimock-specific headers (no x-test-id) — so
132
+ * the testId must travel in the URL for getTestId's `?testId=` fallback to
133
+ * resolve the right job scope. The default testId is omitted to keep
134
+ * single-tenant URLs clean.
135
+ */
136
+ function testIdSuffix(testId, sep) {
137
+ return testId === require_constants.DEFAULT_TEST_ID ? "" : `${sep}testId=${encodeURIComponent(testId)}`;
138
+ }
139
+ /** Default upstream timeout for the live lifecycle proxy (matches recorder.ts). */
140
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 3e4;
141
+ /**
142
+ * Abort signal for the SMALL-JSON upstream fetches on this surface (submit,
143
+ * status poll, models listing), honoring `record.upstreamTimeoutMs` with the
144
+ * same clamp conventions as recorder.ts (non-finite / non-positive values
145
+ * fall back to the 30s default). Note the nuance (documented on
146
+ * RecordConfig.upstreamTimeoutMs): these envelope-sized fetches use the value
147
+ * as a TOTAL deadline via AbortSignal.timeout — indistinguishable from a
148
+ * socket-idle timeout for small bodies. The byte-bearing content fetches use
149
+ * `fetchHeadersWithTimeout` + `readBodyIdle` instead, which implement true
150
+ * idle semantics. An abort rejects the fetch and surfaces through the
151
+ * caller's existing failure path (502 proxy_error on submit/poll, models
152
+ * synthesis fallback).
153
+ */
154
+ function upstreamTimeoutSignal(record) {
155
+ return AbortSignal.timeout(require_recorder.clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS));
156
+ }
157
+ /**
158
+ * Fetch a byte-bearing upstream URL with `record.upstreamTimeoutMs` gating
159
+ * only the HEADERS: the abort timer is cleared the moment the response head
160
+ * arrives, so a long — but steadily progressing — body download is never
161
+ * killed by a total deadline. Body progress is governed separately by
162
+ * `readBodyIdle` (between-chunk idle semantics).
163
+ */
164
+ async function fetchHeadersWithTimeout(url, headers, record) {
165
+ const timeoutMs = require_recorder.clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS);
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Upstream response headers timed out after ${timeoutMs}ms`)), timeoutMs);
168
+ try {
169
+ return await fetch(url, {
170
+ headers,
171
+ signal: controller.signal
172
+ });
173
+ } finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+ /**
178
+ * Buffer a fetch Response body with IDLE-based timeout semantics: a timer
179
+ * clamped from `record.bodyTimeoutMs` (30s default — same clamp as
180
+ * recorder.ts) is re-armed for every chunk, so a steadily-dripping body of
181
+ * any total duration completes and only a silent mid-body stall rejects.
182
+ * When `cap` > 0 the byte count is enforced DURING the read: on exceed the
183
+ * stream is cancelled and `{ overCap: true }` is returned with NOTHING
184
+ * oversized retained in memory — the cap is a memory guard as much as a
185
+ * disk guard.
186
+ */
187
+ async function readBodyIdle(res, record, cap = 0) {
188
+ const body = res.body;
189
+ if (!body) return {
190
+ overCap: false,
191
+ buf: Buffer.alloc(0)
192
+ };
193
+ const idleMs = require_recorder.clampTimeout(record?.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS);
194
+ const reader = body.getReader();
195
+ const chunks = [];
196
+ let total = 0;
197
+ try {
198
+ for (;;) {
199
+ let idleTimer;
200
+ const readPromise = reader.read();
201
+ readPromise.catch(() => {});
202
+ const result = await Promise.race([readPromise, new Promise((_, reject) => {
203
+ idleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Upstream response body idle for ${idleMs}ms`)), idleMs);
204
+ })]).finally(() => clearTimeout(idleTimer));
205
+ if (result.done) break;
206
+ total += result.value.byteLength;
207
+ if (cap > 0 && total > cap) return {
208
+ overCap: true,
209
+ bytesRead: total
210
+ };
211
+ chunks.push(Buffer.from(result.value));
212
+ }
213
+ } finally {
214
+ reader.cancel().catch(() => {});
215
+ }
216
+ return {
217
+ overCap: false,
218
+ buf: Buffer.concat(chunks)
219
+ };
220
+ }
221
+ /**
222
+ * Cap on an upstream ERROR body (401/403 relays) buffered by the content
223
+ * proxy. Error bodies are envelope-sized JSON in practice — 64 KB is generous
224
+ * — and unlike the video bytes they are buffered in full before the relay, so
225
+ * an unbounded read would be a memory hole on a hostile upstream.
226
+ */
227
+ const OPENROUTER_VIDEO_ERROR_BODY_CAP = 64 * 1024;
228
+ /**
229
+ * Cap on the small-JSON upstream envelope bodies (submit, status poll, models
230
+ * listing) buffered before parse/relay. Envelopes are KB-scale in practice —
231
+ * 1 MB is generous headroom even for a large models listing — and, like the
232
+ * error-body cap above, they are buffered in full, so an unbounded `text()`
233
+ * would be a memory hole on a hostile upstream.
234
+ */
235
+ const OPENROUTER_VIDEO_ENVELOPE_BODY_CAP = 1024 * 1024;
236
+ /**
237
+ * Bounded read of a small-JSON upstream envelope body (submit, status poll,
238
+ * models listing): readBodyIdle's idle semantics plus the envelope cap. An
239
+ * over-cap body is a hard failure — the throw surfaces through each caller's
240
+ * existing failure path (502 proxy_error on submit/poll, models synthesis
241
+ * fallback) without retaining anything oversized in memory.
242
+ */
243
+ async function readEnvelopeText(res, record) {
244
+ const read = await readBodyIdle(res, record, OPENROUTER_VIDEO_ENVELOPE_BODY_CAP);
245
+ if (read.overCap) throw new Error(`Upstream envelope body exceeded ${OPENROUTER_VIDEO_ENVELOPE_BODY_CAP} bytes (read aborted at ${read.bytesRead})`);
246
+ return read.buf.toString("utf8");
247
+ }
248
+ /**
249
+ * Rewrite an upstream poll body for relay to the client. Every field is
250
+ * relayed verbatim EXCEPT the ones that would let the client escape the mock
251
+ * or learn upstream identifiers:
252
+ * - `id` → the mock jobId;
253
+ * - a present `polling_url` → the mock's poll URL (testId embedded);
254
+ * - a present `unsigned_urls` array → an array of the SAME length whose
255
+ * element i is the mock content URL with `?index=i`. On post-capture
256
+ * REPLAYS the mock content endpoint serves the index-0 bytes for every
257
+ * index (only index 0 is captured); during the capture window and under
258
+ * proxyOnly the index selects the live-proxied upstream URL.
259
+ * `usage` passes through untouched: a non-number `usage.cost` warns but is
260
+ * never coerced or dropped, and no usage is ever invented.
261
+ */
262
+ function rewriteRecordPollBody(args) {
263
+ const { upstreamBody, job, req, testId, logger } = args;
264
+ const base = requestBase(req, logger);
265
+ const body = {
266
+ ...upstreamBody,
267
+ id: job.jobId
268
+ };
269
+ if (upstreamBody.polling_url !== void 0) body.polling_url = `${base}/api/v1/videos/${job.jobId}${testIdSuffix(testId, "?")}`;
270
+ if (Array.isArray(upstreamBody.unsigned_urls)) body.unsigned_urls = upstreamBody.unsigned_urls.map((_url, i) => `${base}/api/v1/videos/${job.jobId}/content?index=${i}${testIdSuffix(testId, "&")}`);
271
+ else if (upstreamBody.unsigned_urls !== void 0) {
272
+ delete body.unsigned_urls;
273
+ if (!job.nonArrayUrlsWarned) {
274
+ job.nonArrayUrlsWarned = true;
275
+ logger.warn(`Upstream video job ${job.upstreamJobId} reported a non-array unsigned_urls (${JSON.stringify(upstreamBody.unsigned_urls).slice(0, 100)}) — stripped from the relay`);
276
+ }
277
+ }
278
+ const usage = upstreamBody.usage;
279
+ if (usage !== null && typeof usage === "object" && !Array.isArray(usage)) {
280
+ const cost = usage.cost;
281
+ if (cost !== void 0 && typeof cost !== "number" && !job.badCostWarned) {
282
+ job.badCostWarned = true;
283
+ logger.warn(`Upstream video job ${job.upstreamJobId} reported a non-number usage.cost (${JSON.stringify(cost).slice(0, 50)}) — passing it through untouched`);
284
+ }
285
+ }
286
+ return body;
287
+ }
288
+ /**
289
+ * Synthesizes a structurally valid journal body for field-validation 400s.
290
+ * JournalEntry.body is typed `ChatCompletionRequest | null`, so the raw
291
+ * parsed body cannot be journaled as-is — journal consumers may walk
292
+ * `body.messages`. The result is ChatCompletionRequest-shaped: `model` is a
293
+ * string (a non-string value is JSON-encoded so the field stays a string
294
+ * without dropping what the caller sent) and `messages` is an empty array
295
+ * (there is no validated prompt to wrap). Raw request fields (including
296
+ * `prompt`) are preserved via the index signature only on this validation
297
+ * path — the success path's syntheticReq is built from scratch and does not
298
+ * carry them.
299
+ */
300
+ function validationJournalBody(videoReq) {
301
+ const rawModel = videoReq.model;
302
+ const model = typeof rawModel === "string" ? rawModel : rawModel === void 0 ? "" : JSON.stringify(rawModel);
303
+ return {
304
+ ...Object.fromEntries(Object.entries(videoReq).filter(([key]) => !key.startsWith("_"))),
305
+ model,
306
+ messages: []
307
+ };
308
+ }
309
+ async function handleOpenRouterVideoStatus(req, res, jobId, fixtures, journal, defaults, setCorsHeaders, jobs) {
310
+ setCorsHeaders(res);
311
+ const path = req.url ?? `/api/v1/videos/${jobId}`;
312
+ const method = req.method ?? "GET";
313
+ if (require_chaos.applyChaos(res, null, defaults.chaos, req.headers, journal, {
314
+ method,
315
+ path,
316
+ headers: require_helpers.flattenHeaders(req.headers),
317
+ body: null
318
+ }, "internal", defaults.registry, defaults.logger)) return;
319
+ const testId = require_helpers.getTestId(req);
320
+ const job = jobs.get(`${testId}:${jobId}`);
321
+ if (!job) {
322
+ journal.add({
323
+ method,
324
+ path,
325
+ headers: require_helpers.flattenHeaders(req.headers),
326
+ body: null,
327
+ response: {
328
+ status: 404,
329
+ fixture: null
330
+ }
331
+ });
332
+ require_sse_writer.writeErrorResponse(res, 404, JSON.stringify({ error: {
333
+ message: `Video job ${jobId} not found`,
334
+ code: 404
335
+ } }));
336
+ return;
337
+ }
338
+ if (job.kind === "record") {
339
+ if (require_helpers.resolveStrictMode(defaults.strict, req.headers)) {
340
+ defaults.logger.error(`STRICT: video job ${jobId} is proxied live upstream (record mode) — refusing the upstream poll`);
341
+ journal.add({
342
+ method,
343
+ path,
344
+ headers: require_helpers.flattenHeaders(req.headers),
345
+ body: null,
346
+ response: {
347
+ status: 503,
348
+ fixture: null,
349
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
350
+ }
351
+ });
352
+ require_sse_writer.writeErrorResponse(res, 503, JSON.stringify({ error: {
353
+ message: `Strict mode: video job ${jobId} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`,
354
+ type: "invalid_request_error",
355
+ code: "no_fixture_match"
356
+ } }));
357
+ return;
358
+ }
359
+ await proxyOpenRouterVideoRecordPoll({
360
+ req,
361
+ res,
362
+ job,
363
+ key: `${testId}:${jobId}`,
364
+ testId,
365
+ fixtures,
366
+ journal,
367
+ defaults,
368
+ jobs,
369
+ method,
370
+ path
371
+ });
372
+ return;
373
+ }
374
+ if (res.destroyed || res.writableEnded) return;
375
+ advanceJob(job);
376
+ jobs.set(`${testId}:${jobId}`, job);
377
+ journal.add({
378
+ method,
379
+ path,
380
+ headers: require_helpers.flattenHeaders(req.headers),
381
+ body: null,
382
+ response: {
383
+ status: 200,
384
+ fixture: null
385
+ }
386
+ });
387
+ const body = {
388
+ id: job.jobId,
389
+ status: job.status
390
+ };
391
+ if (job.status === "completed") {
392
+ body.unsigned_urls = [`${requestBase(req, defaults.logger)}/api/v1/videos/${job.jobId}/content?index=0${testIdSuffix(testId, "&")}`];
393
+ body.usage = { cost: job.video.cost ?? 0 };
394
+ } else if (job.status === "failed") {
395
+ if (job.video.error === "" && !job.emptyErrorWarned) {
396
+ job.emptyErrorWarned = true;
397
+ defaults.logger.warn(`Video fixture for job ${job.jobId} has an empty error message — using the default`);
398
+ }
399
+ body.error = job.video.error || "Video generation failed";
400
+ }
401
+ res.writeHead(200, { "Content-Type": "application/json" });
402
+ res.end(JSON.stringify(body));
403
+ }
404
+ const PLACEHOLDER_MP4 = Buffer.from([
405
+ 0,
406
+ 0,
407
+ 0,
408
+ 24,
409
+ 102,
410
+ 116,
411
+ 121,
412
+ 112,
413
+ 105,
414
+ 115,
415
+ 111,
416
+ 109,
417
+ 0,
418
+ 0,
419
+ 2,
420
+ 0,
421
+ 105,
422
+ 115,
423
+ 111,
424
+ 109,
425
+ 109,
426
+ 112,
427
+ 52,
428
+ 50
429
+ ]);
430
+ async function handleOpenRouterVideoContent(req, res, jobId, journal, defaults, setCorsHeaders, jobs) {
431
+ setCorsHeaders(res);
432
+ const path = req.url ?? `/api/v1/videos/${jobId}/content`;
433
+ const method = req.method ?? "GET";
434
+ if (require_chaos.applyChaos(res, null, defaults.chaos, req.headers, journal, {
435
+ method,
436
+ path,
437
+ headers: require_helpers.flattenHeaders(req.headers),
438
+ body: null
439
+ }, "internal", defaults.registry, defaults.logger)) return;
440
+ const authorization = req.headers.authorization;
441
+ if (!authorization || !/^bearer\s+\S/i.test(authorization)) {
442
+ journal.add({
443
+ method,
444
+ path,
445
+ headers: require_helpers.flattenHeaders(req.headers),
446
+ body: null,
447
+ response: {
448
+ status: 401,
449
+ fixture: null
450
+ }
451
+ });
452
+ require_sse_writer.writeErrorResponse(res, 401, JSON.stringify({ error: {
453
+ message: "No auth credentials found",
454
+ code: 401
455
+ } }));
456
+ return;
457
+ }
458
+ const testId = require_helpers.getTestId(req);
459
+ const job = jobs.get(`${testId}:${jobId}`);
460
+ if (!job) {
461
+ journal.add({
462
+ method,
463
+ path,
464
+ headers: require_helpers.flattenHeaders(req.headers),
465
+ body: null,
466
+ response: {
467
+ status: 404,
468
+ fixture: null
469
+ }
470
+ });
471
+ require_sse_writer.writeErrorResponse(res, 404, JSON.stringify({ error: {
472
+ message: `Video job ${jobId} not found`,
473
+ code: 404
474
+ } }));
475
+ return;
476
+ }
477
+ if (job.status !== "completed") {
478
+ journal.add({
479
+ method,
480
+ path,
481
+ headers: require_helpers.flattenHeaders(req.headers),
482
+ body: null,
483
+ response: {
484
+ status: 400,
485
+ fixture: null
486
+ }
487
+ });
488
+ require_sse_writer.writeErrorResponse(res, 400, JSON.stringify({ error: {
489
+ message: `Video job ${jobId} is not completed (status: ${job.status})`,
490
+ code: 400
491
+ } }));
492
+ return;
493
+ }
494
+ const queryIdx = path.indexOf("?");
495
+ const indexParam = queryIdx === -1 ? null : new URLSearchParams(path.slice(queryIdx + 1)).get("index");
496
+ if (job.kind === "record") {
497
+ if (require_helpers.resolveStrictMode(defaults.strict, req.headers)) {
498
+ defaults.logger.error(`STRICT: video job ${jobId} content is proxied live upstream (record mode) — refusing the upstream fetch`);
499
+ journal.add({
500
+ method,
501
+ path,
502
+ headers: require_helpers.flattenHeaders(req.headers),
503
+ body: null,
504
+ response: {
505
+ status: 503,
506
+ fixture: null,
507
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
508
+ }
509
+ });
510
+ require_sse_writer.writeErrorResponse(res, 503, JSON.stringify({ error: {
511
+ message: `Strict mode: video job ${jobId} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`,
512
+ type: "invalid_request_error",
513
+ code: "no_fixture_match"
514
+ } }));
515
+ return;
516
+ }
517
+ await proxyOpenRouterVideoRecordContent({
518
+ req,
519
+ res,
520
+ job,
521
+ key: `${testId}:${jobId}`,
522
+ indexParam,
523
+ journal,
524
+ defaults,
525
+ jobs,
526
+ method,
527
+ path
528
+ });
529
+ return;
530
+ }
531
+ if (indexParam !== null && Number(indexParam) !== 0) defaults.logger.warn(`Video content request for job ${jobId} asked for index=${indexParam} — the index param is ignored (jobs are single-video); serving index 0`);
532
+ let bytes;
533
+ if (job.video.b64) {
534
+ bytes = Buffer.from(job.video.b64, "base64");
535
+ const sanitized = job.video.b64.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/").replace(/=.*$/, "");
536
+ const expectedBytes = Math.floor(sanitized.length * 3 / 4);
537
+ if (bytes.length === 0 && !job.contentWarnsLatched) defaults.logger.warn(`Video fixture b64 for job ${jobId} decoded to zero bytes — the fixture is not controlling the served content`);
538
+ if (sanitized.length % 4 === 1) {
539
+ if (!job.contentWarnsLatched) defaults.logger.warn(`Video fixture b64 for job ${jobId} has length ≡ 1 (mod 4) after sanitization (${sanitized.length} chars) — payload is malformed or contains invalid characters`);
540
+ } else if (bytes.length !== expectedBytes && !job.contentWarnsLatched) defaults.logger.warn(`Video fixture b64 for job ${jobId} decoded to ${bytes.length} bytes where its length implies ${expectedBytes} — likely corrupt base64`);
541
+ } else {
542
+ if (job.video.b64 === "" && !job.contentWarnsLatched) defaults.logger.warn(`Video fixture for job ${jobId} has an empty b64 — serving the placeholder MP4`);
543
+ if (job.video.url && !job.contentWarnsLatched) defaults.logger.warn(`Video fixture for job ${jobId} sets video.url but no b64 — url is ignored on the OpenRouter content endpoint; use b64 to control the served bytes (serving the placeholder MP4)`);
544
+ bytes = PLACEHOLDER_MP4;
545
+ }
546
+ job.contentWarnsLatched = true;
547
+ if (res.destroyed || res.writableEnded) return;
548
+ jobs.set(`${testId}:${jobId}`, job);
549
+ journal.add({
550
+ method,
551
+ path,
552
+ headers: require_helpers.flattenHeaders(req.headers),
553
+ body: null,
554
+ response: {
555
+ status: 200,
556
+ fixture: null
557
+ }
558
+ });
559
+ res.writeHead(200, {
560
+ "Content-Type": "video/mp4",
561
+ "Content-Length": bytes.length
562
+ });
563
+ res.end(bytes);
564
+ }
565
+ /**
566
+ * Live-proxy a content download for a completed record job — reachable under
567
+ * proxyOnly (forever) and during the eager-capture window: the stored
568
+ * upstream `unsigned_urls[index || 0]` is fetched with the same same-origin
569
+ * Bearer-forwarding gate as the eager-capture path (headers gated by
570
+ * `upstreamTimeoutMs`, body by `bodyTimeoutMs` idle semantics), and the bytes
571
+ * are STREAMED to the client as `video/mp4` (matching the real API regardless
572
+ * of the upstream's content-type) — chunks are relayed as they arrive, so the
573
+ * whole video is never buffered in memory. An upstream 401/403 passes through
574
+ * verbatim (real-API fidelity); other pre-relay failures 502 (a mid-stream
575
+ * stall can only abort the already-committed 200). No state is mutated and
576
+ * nothing is cached — repeated downloads hit the upstream every time.
577
+ * Journaled source:"proxy" when the relay commits. Refuses with 502 before
578
+ * contacting the upstream when record mode was disabled mid-flight. A
579
+ * committed relay refreshes the job's TTL (identity-guarded) — a client
580
+ * working through a long download list must not lose the job mid-way.
581
+ */
582
+ async function proxyOpenRouterVideoRecordContent(args) {
583
+ const { req, res, job, key, indexParam, journal, defaults, jobs, method, path } = args;
584
+ const logger = defaults.logger;
585
+ const proxyError = (msg) => {
586
+ logger.error(`OpenRouter video content proxy failed: ${msg}`);
587
+ if (res.destroyed || res.writableEnded) return;
588
+ journal.add({
589
+ method,
590
+ path,
591
+ headers: require_helpers.flattenHeaders(req.headers),
592
+ body: null,
593
+ response: {
594
+ status: 502,
595
+ fixture: null,
596
+ source: "proxy",
597
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
598
+ }
599
+ });
600
+ require_sse_writer.writeErrorResponse(res, 502, JSON.stringify({ error: {
601
+ message: `Proxy to upstream failed: ${msg}`,
602
+ type: "proxy_error"
603
+ } }));
604
+ };
605
+ const record = defaults.record;
606
+ if (!record) {
607
+ proxyError("record mode is no longer configured for an in-flight record job");
608
+ return;
609
+ }
610
+ const urls = job.upstreamUnsignedUrls ?? [];
611
+ const parsedIndex = indexParam === null ? 0 : Number(indexParam);
612
+ const index = Number.isInteger(parsedIndex) && parsedIndex >= 0 ? parsedIndex : 0;
613
+ if (indexParam !== null && index !== parsedIndex) logger.warn(`Video content request for job ${job.jobId} has an unusable index=${indexParam} — serving index 0`);
614
+ const addressed = urls[index];
615
+ let target = typeof addressed === "string" && addressed ? addressed : void 0;
616
+ if (target === void 0 && index !== 0) {
617
+ if (index >= urls.length) logger.warn(`Video content request for job ${job.jobId} asked for index=${index} but the upstream reported only ${urls.length} unsigned_urls — serving index 0`);
618
+ else logger.warn(`Video content request for job ${job.jobId} asked for index=${index} but the upstream's unsigned_urls[${index}] is unusable (non-string or empty) — serving index 0`);
619
+ const first = urls[0];
620
+ target = typeof first === "string" && first ? first : void 0;
621
+ }
622
+ if (!target) {
623
+ proxyError(urls.length === 0 ? `Upstream job ${job.upstreamJobId} completed without usable unsigned_urls` : `Upstream job ${job.upstreamJobId} completed but its unsigned_urls[0] is unusable (non-string or empty)`);
624
+ return;
625
+ }
626
+ let contentUrl;
627
+ try {
628
+ contentUrl = new URL(target);
629
+ } catch {
630
+ proxyError(`Upstream unsigned_urls[${index}] is not a valid URL (${target.slice(0, 100)})`);
631
+ return;
632
+ }
633
+ const providerBase = record.providers.openrouter;
634
+ let providerOrigin;
635
+ try {
636
+ providerOrigin = new URL(providerBase ?? job.upstreamPollingUrl).origin;
637
+ } catch {
638
+ try {
639
+ providerOrigin = new URL(job.upstreamPollingUrl).origin;
640
+ } catch {
641
+ proxyError(`Cannot determine the provider origin (invalid provider URL and polling URL) — refusing to forward credentials`);
642
+ return;
643
+ }
644
+ }
645
+ const headers = require_recorder.buildForwardHeaders(req);
646
+ if (contentUrl.origin !== providerOrigin) {
647
+ delete headers.authorization;
648
+ logger.warn(`Upstream unsigned_urls[${index}] origin ${contentUrl.origin} differs from the provider origin ${providerOrigin} — fetching content WITHOUT the client's Authorization header`);
649
+ }
650
+ let contentRes;
651
+ try {
652
+ contentRes = await fetchHeadersWithTimeout(contentUrl, headers, record);
653
+ } catch (err) {
654
+ proxyError(err instanceof Error ? err.message : "Unknown proxy error");
655
+ return;
656
+ }
657
+ if (contentRes.status === 401 || contentRes.status === 403) {
658
+ let errBody;
659
+ try {
660
+ const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP);
661
+ if (read.overCap) {
662
+ logger.warn(`Upstream ${contentRes.status} error body for job ${job.upstreamJobId} exceeded ${OPENROUTER_VIDEO_ERROR_BODY_CAP} bytes — relaying the status with an empty body`);
663
+ errBody = Buffer.alloc(0);
664
+ } else errBody = read.buf;
665
+ } catch (err) {
666
+ proxyError(err instanceof Error ? err.message : "Unknown proxy error");
667
+ return;
668
+ }
669
+ logger.warn(`Upstream content host rejected the download for job ${job.upstreamJobId} (${contentRes.status}) — relaying the upstream status`);
670
+ if (res.destroyed || res.writableEnded) return;
671
+ journal.add({
672
+ method,
673
+ path,
674
+ headers: require_helpers.flattenHeaders(req.headers),
675
+ body: null,
676
+ response: {
677
+ status: contentRes.status,
678
+ fixture: null,
679
+ source: "proxy",
680
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
681
+ }
682
+ });
683
+ res.writeHead(contentRes.status, { "Content-Type": contentRes.headers.get("content-type") ?? "application/json" });
684
+ res.end(errBody);
685
+ return;
686
+ }
687
+ if (!contentRes.ok) {
688
+ let sample = "";
689
+ try {
690
+ const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP);
691
+ if (!read.overCap) sample = read.buf.toString("utf8").slice(0, 200);
692
+ } catch {}
693
+ proxyError(`Content ${contentRes.status}${sample ? `: ${sample}` : ""}`);
694
+ return;
695
+ }
696
+ if (res.destroyed || res.writableEnded) {
697
+ contentRes.body?.cancel().catch(() => {});
698
+ return;
699
+ }
700
+ journal.add({
701
+ method,
702
+ path,
703
+ headers: require_helpers.flattenHeaders(req.headers),
704
+ body: null,
705
+ response: {
706
+ status: 200,
707
+ fixture: null,
708
+ source: "proxy",
709
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
710
+ }
711
+ });
712
+ if (jobs.get(key) === job) jobs.set(key, job);
713
+ res.writeHead(200, { "Content-Type": "video/mp4" });
714
+ const body = contentRes.body;
715
+ if (!body) {
716
+ res.end();
717
+ return;
718
+ }
719
+ const idleMs = require_recorder.clampTimeout(record.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS);
720
+ const reader = body.getReader();
721
+ try {
722
+ for (;;) {
723
+ let idleTimer;
724
+ const readPromise = reader.read();
725
+ readPromise.catch(() => {});
726
+ const result = await Promise.race([readPromise, new Promise((_, reject) => {
727
+ idleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Upstream response body idle for ${idleMs}ms`)), idleMs);
728
+ })]).finally(() => clearTimeout(idleTimer));
729
+ if (result.done) break;
730
+ if (res.destroyed) return;
731
+ if (!res.write(Buffer.from(result.value))) {
732
+ if (!await new Promise((resolve) => {
733
+ const cleanup = () => {
734
+ res.off("drain", onDrain);
735
+ res.off("close", onClose);
736
+ clearTimeout(drainTimer);
737
+ };
738
+ const onDrain = () => {
739
+ cleanup();
740
+ resolve(true);
741
+ };
742
+ const onClose = () => {
743
+ cleanup();
744
+ resolve(true);
745
+ };
746
+ const drainTimer = setTimeout(() => {
747
+ cleanup();
748
+ resolve(false);
749
+ }, idleMs);
750
+ res.once("drain", onDrain);
751
+ res.once("close", onClose);
752
+ })) {
753
+ logger.warn(`OpenRouter video content relay for job ${job.upstreamJobId} aborted: the client stopped reading for ${idleMs}ms — destroying the response and releasing the upstream`);
754
+ res.destroy();
755
+ return;
756
+ }
757
+ }
758
+ }
759
+ res.end();
760
+ } catch (err) {
761
+ logger.error(`OpenRouter video content relay aborted mid-stream: ${err instanceof Error ? err.message : String(err)}`);
762
+ res.destroy();
763
+ } finally {
764
+ reader.cancel().catch(() => {});
765
+ }
766
+ }
767
+ const DEFAULT_OPENROUTER_VIDEO_MODELS = [DEFAULT_OPENROUTER_VIDEO_MODEL, "openai/sora-2"];
768
+ function modelEntry(id) {
769
+ return {
770
+ id,
771
+ name: id,
772
+ supported_durations: [4, 8],
773
+ supported_resolutions: ["720p", "1080p"],
774
+ supported_aspect_ratios: [
775
+ "16:9",
776
+ "9:16",
777
+ "1:1"
778
+ ],
779
+ supported_frame_images: [],
780
+ supported_sizes: [],
781
+ generate_audio: false,
782
+ seed: true,
783
+ pricing_skus: []
784
+ };
785
+ }
786
+ /**
787
+ * Synthesizes the OpenRouter video model listing from loaded fixtures —
788
+ * video-endpoint fixtures with a string `match.model` (mirrors the Ollama
789
+ * `/api/tags` synthesis in server.ts). Falls back to a default model set when
790
+ * no video fixtures are loaded. Note video models do not appear in the plain
791
+ * `/api/v1/models` listing on the real API, hence the dedicated route.
792
+ *
793
+ * With `record.providers.openrouter` configured the listing is proxied
794
+ * upstream instead and relayed verbatim on success (journaled
795
+ * source:"proxy", never recorded as a fixture); an upstream failure warns
796
+ * and falls back to the synthesis below. Strict mode disables the proxy —
797
+ * a strict request is always served the synthesized listing (strict means
798
+ * "nothing reaches an upstream").
799
+ */
800
+ async function handleOpenRouterVideoModels(req, res, fixtures, journal, defaults, setCorsHeaders) {
801
+ setCorsHeaders(res);
802
+ const path = req.url ?? "/api/v1/videos/models";
803
+ const method = req.method ?? "GET";
804
+ if (require_chaos.applyChaos(res, null, defaults.chaos, req.headers, journal, {
805
+ method,
806
+ path,
807
+ headers: require_helpers.flattenHeaders(req.headers),
808
+ body: null
809
+ }, "internal", defaults.registry, defaults.logger)) return;
810
+ const record = defaults.record;
811
+ const upstreamBase = record?.providers.openrouter;
812
+ let proxyAttemptFailed = false;
813
+ if (upstreamBase && !require_helpers.resolveStrictMode(defaults.strict, req.headers)) {
814
+ let relay;
815
+ try {
816
+ const target = require_url.resolveUpstreamUrl(upstreamBase, "/api/v1/videos/models");
817
+ const upstreamRes = await fetch(target, {
818
+ headers: require_recorder.buildForwardHeaders(req),
819
+ signal: upstreamTimeoutSignal(record)
820
+ });
821
+ const text = await readEnvelopeText(upstreamRes, record);
822
+ if (!upstreamRes.ok) throw new Error(`Models ${upstreamRes.status}: ${text.slice(0, 200)}`);
823
+ relay = {
824
+ text,
825
+ contentType: upstreamRes.headers.get("content-type") ?? "application/json"
826
+ };
827
+ } catch (err) {
828
+ proxyAttemptFailed = true;
829
+ const msg = err instanceof Error ? err.message : "Unknown proxy error";
830
+ defaults.logger.warn(`OpenRouter video models proxy failed (${msg}) — falling back to the synthesized listing`);
831
+ }
832
+ if (relay) {
833
+ if (res.destroyed || res.writableEnded) return;
834
+ journal.add({
835
+ method,
836
+ path,
837
+ headers: require_helpers.flattenHeaders(req.headers),
838
+ body: null,
839
+ response: {
840
+ status: 200,
841
+ fixture: null,
842
+ source: "proxy",
843
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
844
+ }
845
+ });
846
+ res.writeHead(200, { "Content-Type": relay.contentType });
847
+ res.end(relay.text);
848
+ return;
849
+ }
850
+ }
851
+ const modelIds = /* @__PURE__ */ new Set();
852
+ let sawVideoFixture = false;
853
+ for (const f of fixtures) if (f.match.endpoint === "video") {
854
+ sawVideoFixture = true;
855
+ if (f.match.model && typeof f.match.model === "string") modelIds.add(f.match.model);
856
+ }
857
+ if (modelIds.size === 0 && sawVideoFixture) defaults.logger.warn("No video fixture contributes a string model — serving the default video model set");
858
+ const ids = modelIds.size > 0 ? [...modelIds] : DEFAULT_OPENROUTER_VIDEO_MODELS;
859
+ if (res.destroyed || res.writableEnded) return;
860
+ journal.add({
861
+ method,
862
+ path,
863
+ headers: require_helpers.flattenHeaders(req.headers),
864
+ body: null,
865
+ response: {
866
+ status: 200,
867
+ fixture: null,
868
+ ...proxyAttemptFailed ? { source: "internal" } : {},
869
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
870
+ }
871
+ });
872
+ res.writeHead(200, { "Content-Type": "application/json" });
873
+ res.end(JSON.stringify({ data: ids.map((id) => modelEntry(id)) }));
874
+ }
875
+ async function handleOpenRouterVideoCreate(req, res, raw, fixtures, journal, defaults, setCorsHeaders, jobs) {
876
+ setCorsHeaders(res);
877
+ const path = req.url ?? "/api/v1/videos";
878
+ const method = req.method ?? "POST";
879
+ let videoReq;
880
+ try {
881
+ videoReq = JSON.parse(raw);
882
+ } catch (parseErr) {
883
+ const detail = parseErr instanceof Error ? parseErr.message : "unknown";
884
+ journal.add({
885
+ method,
886
+ path,
887
+ headers: require_helpers.flattenHeaders(req.headers),
888
+ body: null,
889
+ response: {
890
+ status: 400,
891
+ fixture: null
892
+ }
893
+ });
894
+ require_sse_writer.writeErrorResponse(res, 400, JSON.stringify({ error: {
895
+ message: `Malformed JSON: ${detail}`,
896
+ type: "invalid_request_error",
897
+ code: "invalid_json"
898
+ } }));
899
+ return;
900
+ }
901
+ if (videoReq === null || typeof videoReq !== "object" || Array.isArray(videoReq)) {
902
+ journal.add({
903
+ method,
904
+ path,
905
+ headers: require_helpers.flattenHeaders(req.headers),
906
+ body: null,
907
+ response: {
908
+ status: 400,
909
+ fixture: null
910
+ }
911
+ });
912
+ require_sse_writer.writeErrorResponse(res, 400, JSON.stringify({ error: {
913
+ message: "Request body must be a JSON object",
914
+ type: "invalid_request_error"
915
+ } }));
916
+ return;
917
+ }
918
+ const parsedBody = validationJournalBody(videoReq);
919
+ if (typeof videoReq.prompt !== "string" || !videoReq.prompt) {
920
+ journal.add({
921
+ method,
922
+ path,
923
+ headers: require_helpers.flattenHeaders(req.headers),
924
+ body: parsedBody,
925
+ response: {
926
+ status: 400,
927
+ fixture: null
928
+ }
929
+ });
930
+ const message = videoReq.prompt === void 0 ? "Missing required parameter: 'prompt'" : "Invalid type for parameter: 'prompt' must be a non-empty string";
931
+ require_sse_writer.writeErrorResponse(res, 400, JSON.stringify({ error: {
932
+ message,
933
+ type: "invalid_request_error"
934
+ } }));
935
+ return;
936
+ }
937
+ if (videoReq.model !== void 0 && (typeof videoReq.model !== "string" || !videoReq.model)) {
938
+ journal.add({
939
+ method,
940
+ path,
941
+ headers: require_helpers.flattenHeaders(req.headers),
942
+ body: parsedBody,
943
+ response: {
944
+ status: 400,
945
+ fixture: null
946
+ }
947
+ });
948
+ require_sse_writer.writeErrorResponse(res, 400, JSON.stringify({ error: {
949
+ message: "Invalid type for parameter: 'model' must be a non-empty string",
950
+ type: "invalid_request_error"
951
+ } }));
952
+ return;
953
+ }
954
+ const syntheticReq = {
955
+ model: videoReq.model ?? DEFAULT_OPENROUTER_VIDEO_MODEL,
956
+ messages: [{
957
+ role: "user",
958
+ content: videoReq.prompt
959
+ }],
960
+ _endpointType: "video",
961
+ _context: require_helpers.getContext(req)
962
+ };
963
+ const testId = require_helpers.getTestId(req);
964
+ const { fixture, skippedBySequenceOrTurn } = require_router.matchFixtureDiagnostic(fixtures, syntheticReq, journal.getFixtureMatchCountsForTest(testId), defaults.requestTransform);
965
+ if (fixture) {
966
+ journal.incrementFixtureMatchCount(fixture, fixtures, testId);
967
+ defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`);
968
+ } else {
969
+ const snippet = videoReq.prompt.slice(0, 80);
970
+ defaults.logger.debug(`No fixture matched for request (model=${syntheticReq.model}, msg="${snippet}")`);
971
+ }
972
+ if (require_chaos.applyChaos(res, fixture, defaults.chaos, req.headers, journal, {
973
+ method,
974
+ path,
975
+ headers: require_helpers.flattenHeaders(req.headers),
976
+ body: syntheticReq
977
+ }, fixture ? "fixture" : require_helpers.resolveStrictMode(defaults.strict, req.headers) ? "internal" : defaults.record?.providers.openrouter ? "proxy" : "internal", defaults.registry, defaults.logger)) return;
978
+ if (!fixture) {
979
+ if (require_helpers.resolveStrictMode(defaults.strict, req.headers)) {
980
+ const strictMessage = require_helpers.strictNoMatchMessage(skippedBySequenceOrTurn);
981
+ defaults.logger.error(require_helpers.strictNoMatchLogLine(method, path, skippedBySequenceOrTurn));
982
+ journal.add({
983
+ method,
984
+ path,
985
+ headers: require_helpers.flattenHeaders(req.headers),
986
+ body: syntheticReq,
987
+ response: {
988
+ status: 503,
989
+ fixture: null,
990
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
991
+ }
992
+ });
993
+ require_sse_writer.writeErrorResponse(res, 503, JSON.stringify({ error: {
994
+ message: strictMessage,
995
+ type: "invalid_request_error",
996
+ code: "no_fixture_match"
997
+ } }));
998
+ return;
999
+ }
1000
+ if (defaults.record) {
1001
+ if (await proxyOpenRouterVideoSubmit({
1002
+ req,
1003
+ res,
1004
+ raw,
1005
+ syntheticReq,
1006
+ record: defaults.record,
1007
+ journal,
1008
+ defaults,
1009
+ jobs,
1010
+ method,
1011
+ path
1012
+ }) === "handled") return;
1013
+ }
1014
+ journal.add({
1015
+ method,
1016
+ path,
1017
+ headers: require_helpers.flattenHeaders(req.headers),
1018
+ body: syntheticReq,
1019
+ response: {
1020
+ status: 404,
1021
+ fixture: null,
1022
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
1023
+ }
1024
+ });
1025
+ require_sse_writer.writeErrorResponse(res, 404, JSON.stringify({ error: {
1026
+ message: "No fixture matched",
1027
+ code: 404
1028
+ } }));
1029
+ return;
1030
+ }
1031
+ const worldGeneration = jobs.generation;
1032
+ const response = await require_helpers.resolveResponse(fixture, syntheticReq);
1033
+ if (require_helpers.isErrorResponse(response)) {
1034
+ if (res.destroyed || res.writableEnded) return;
1035
+ const status = response.status ?? 500;
1036
+ journal.add({
1037
+ method,
1038
+ path,
1039
+ headers: require_helpers.flattenHeaders(req.headers),
1040
+ body: syntheticReq,
1041
+ response: {
1042
+ status,
1043
+ fixture
1044
+ }
1045
+ });
1046
+ require_sse_writer.writeErrorResponse(res, status, require_helpers.serializeErrorResponse(response), { retryAfter: response.retryAfter });
1047
+ return;
1048
+ }
1049
+ if (!require_helpers.isVideoResponse(response)) {
1050
+ if (res.destroyed || res.writableEnded) return;
1051
+ journal.add({
1052
+ method,
1053
+ path,
1054
+ headers: require_helpers.flattenHeaders(req.headers),
1055
+ body: syntheticReq,
1056
+ response: {
1057
+ status: 500,
1058
+ fixture
1059
+ }
1060
+ });
1061
+ require_sse_writer.writeErrorResponse(res, 500, JSON.stringify({ error: {
1062
+ message: "Fixture response is not a video type",
1063
+ type: "server_error"
1064
+ } }));
1065
+ return;
1066
+ }
1067
+ if (res.destroyed || res.writableEnded) return;
1068
+ journal.add({
1069
+ method,
1070
+ path,
1071
+ headers: require_helpers.flattenHeaders(req.headers),
1072
+ body: syntheticReq,
1073
+ response: {
1074
+ status: 200,
1075
+ fixture
1076
+ }
1077
+ });
1078
+ const fixtureStatus = response.video.status;
1079
+ if (fixtureStatus === "processing") defaults.logger.warn(`Video fixture has status "processing" — treated as completed for /api/v1/videos jobs`);
1080
+ else if (fixtureStatus !== "completed" && fixtureStatus !== "failed") defaults.logger.warn(`Video fixture has unknown status "${fixtureStatus}" — treating as completed for /api/v1/videos jobs`);
1081
+ const jobId = node_crypto.default.randomUUID();
1082
+ const progression = require_fal.resolveProgression(defaults.openRouterVideo);
1083
+ const job = {
1084
+ kind: "replay",
1085
+ jobId,
1086
+ status: "pending",
1087
+ pollCount: 0,
1088
+ pollsBeforeInProgress: progression.pollsBeforeInProgress,
1089
+ pollsBeforeCompleted: progression.pollsBeforeCompleted,
1090
+ video: { ...response.video }
1091
+ };
1092
+ if (progression.pollsBeforeCompleted === 0) job.status = terminalStatus(job);
1093
+ if (jobs.generation === worldGeneration) jobs.set(`${testId}:${jobId}`, job);
1094
+ else defaults.logger.warn(`OpenRouter video submit resolved after a fixtures reset — not inserting job ${jobId} into the new world (its polls will 404)`);
1095
+ res.writeHead(200, { "Content-Type": "application/json" });
1096
+ res.end(JSON.stringify({
1097
+ id: jobId,
1098
+ polling_url: `${requestBase(req, defaults.logger)}/api/v1/videos/${jobId}${testIdSuffix(testId, "?")}`,
1099
+ status: "pending"
1100
+ }));
1101
+ }
1102
+ const OPENROUTER_VIDEOS_PATH = "/api/v1/videos";
1103
+ /**
1104
+ * Proxy an unmatched video submit to the configured OpenRouter upstream and
1105
+ * answer the client with a mock-rewritten envelope: a fresh aimock jobId and
1106
+ * a polling_url pointing back at the mock (testId embedded). The upstream
1107
+ * lifecycle is then driven interactively by the client's own polls — unlike
1108
+ * fal's synchronous queue walk, nothing is polled server-side at submit.
1109
+ *
1110
+ * Returns "no_upstream" when record mode has no openrouter provider URL —
1111
+ * the caller falls through to its 404 branch (fal convention).
1112
+ */
1113
+ async function proxyOpenRouterVideoSubmit(args) {
1114
+ const { req, res, raw, syntheticReq, record, journal, defaults, jobs, method, path } = args;
1115
+ const upstreamBase = record.providers.openrouter;
1116
+ if (!upstreamBase) {
1117
+ defaults.logger.warn(`No upstream URL configured for provider "openrouter" — cannot proxy`);
1118
+ return "no_upstream";
1119
+ }
1120
+ const proxyError = (msg) => {
1121
+ defaults.logger.error(`OpenRouter video submit proxy failed: ${msg}`);
1122
+ if (res.destroyed || res.writableEnded) return "handled";
1123
+ journal.add({
1124
+ method,
1125
+ path,
1126
+ headers: require_helpers.flattenHeaders(req.headers),
1127
+ body: syntheticReq,
1128
+ response: {
1129
+ status: 502,
1130
+ fixture: null,
1131
+ source: "proxy",
1132
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
1133
+ }
1134
+ });
1135
+ require_sse_writer.writeErrorResponse(res, 502, JSON.stringify({ error: {
1136
+ message: `Proxy to upstream failed: ${msg}`,
1137
+ type: "proxy_error"
1138
+ } }));
1139
+ return "handled";
1140
+ };
1141
+ let submitUrl;
1142
+ let upstreamOrigin;
1143
+ try {
1144
+ submitUrl = require_url.resolveUpstreamUrl(upstreamBase, OPENROUTER_VIDEOS_PATH);
1145
+ upstreamOrigin = new URL(upstreamBase).origin;
1146
+ } catch (err) {
1147
+ return proxyError(`Invalid upstream URL: ${upstreamBase} (${err instanceof Error ? err.message : String(err)})`);
1148
+ }
1149
+ defaults.logger.warn(`NO FIXTURE MATCH — proxying video submit to ${upstreamBase}${OPENROUTER_VIDEOS_PATH}`);
1150
+ const worldGeneration = jobs.generation;
1151
+ let fetched;
1152
+ try {
1153
+ const upstreamRes = await fetch(submitUrl, {
1154
+ method: "POST",
1155
+ headers: require_recorder.buildForwardHeaders(req),
1156
+ body: raw,
1157
+ signal: upstreamTimeoutSignal(record)
1158
+ });
1159
+ fetched = {
1160
+ status: upstreamRes.status,
1161
+ contentType: upstreamRes.headers.get("content-type"),
1162
+ text: await readEnvelopeText(upstreamRes, record)
1163
+ };
1164
+ } catch (err) {
1165
+ return proxyError(err instanceof Error ? err.message : "Unknown proxy error");
1166
+ }
1167
+ if (fetched.status === 401 || fetched.status === 403) {
1168
+ defaults.logger.warn(`Upstream rejected the video submit (${fetched.status}) — relaying the upstream status`);
1169
+ if (res.destroyed || res.writableEnded) return "handled";
1170
+ journal.add({
1171
+ method,
1172
+ path,
1173
+ headers: require_helpers.flattenHeaders(req.headers),
1174
+ body: syntheticReq,
1175
+ response: {
1176
+ status: fetched.status,
1177
+ fixture: null,
1178
+ source: "proxy",
1179
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
1180
+ }
1181
+ });
1182
+ res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" });
1183
+ res.end(fetched.text);
1184
+ return "handled";
1185
+ }
1186
+ let upstreamJobId;
1187
+ let envelope;
1188
+ {
1189
+ if (fetched.status < 200 || fetched.status >= 300) return proxyError(`Submit ${fetched.status}: ${fetched.text.slice(0, 200)}`);
1190
+ let parsed;
1191
+ try {
1192
+ parsed = JSON.parse(fetched.text);
1193
+ } catch {
1194
+ return proxyError(`Submit returned non-JSON: ${fetched.text.slice(0, 200)}`);
1195
+ }
1196
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return proxyError("Submit response is not a JSON object");
1197
+ envelope = parsed;
1198
+ upstreamJobId = String(envelope.id ?? "").trim();
1199
+ if (!upstreamJobId) return proxyError("Submit response missing id");
1200
+ }
1201
+ let upstreamPollingUrl = require_url.resolveUpstreamUrl(upstreamBase, `${OPENROUTER_VIDEOS_PATH}/${encodeURIComponent(upstreamJobId)}`).toString();
1202
+ const envPolling = envelope.polling_url;
1203
+ if (typeof envPolling === "string" && envPolling) try {
1204
+ const parsedPolling = new URL(envPolling);
1205
+ if (parsedPolling.origin === upstreamOrigin) upstreamPollingUrl = parsedPolling.toString();
1206
+ else defaults.logger.warn(`Upstream polling_url origin ${parsedPolling.origin} differs from the configured provider origin ${upstreamOrigin} — using the constructed poll URL instead`);
1207
+ } catch {
1208
+ defaults.logger.warn(`Upstream polling_url is not a valid URL (${String(envPolling).slice(0, 100)}) — using the constructed poll URL instead`);
1209
+ }
1210
+ const testId = require_helpers.getTestId(req);
1211
+ const matchRequest = defaults.requestTransform ? defaults.requestTransform(syntheticReq) : syntheticReq;
1212
+ const jobId = node_crypto.default.randomUUID();
1213
+ const job = {
1214
+ kind: "record",
1215
+ jobId,
1216
+ status: "pending",
1217
+ upstreamJobId,
1218
+ upstreamPollingUrl,
1219
+ match: require_recorder.buildFixtureMatch(matchRequest, record)
1220
+ };
1221
+ if (jobs.generation === worldGeneration) jobs.set(`${testId}:${jobId}`, job);
1222
+ else defaults.logger.warn(`OpenRouter video submit for upstream job ${upstreamJobId} completed after a fixtures reset — not inserting the job into the new world (its polls will 404)`);
1223
+ if (res.destroyed || res.writableEnded) return "handled";
1224
+ journal.add({
1225
+ method,
1226
+ path,
1227
+ headers: require_helpers.flattenHeaders(req.headers),
1228
+ body: syntheticReq,
1229
+ response: {
1230
+ status: 200,
1231
+ fixture: null,
1232
+ source: "proxy",
1233
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
1234
+ }
1235
+ });
1236
+ res.writeHead(200, { "Content-Type": "application/json" });
1237
+ res.end(JSON.stringify({
1238
+ id: jobId,
1239
+ polling_url: `${requestBase(req, defaults.logger)}/api/v1/videos/${jobId}${testIdSuffix(testId, "?")}`,
1240
+ status: "pending"
1241
+ }));
1242
+ return "handled";
1243
+ }
1244
+ /**
1245
+ * Default cap on the DECODED byte size embedded as `b64` in a recorded
1246
+ * fixture (32 MB). Override per-server via
1247
+ * `record.openRouterVideo.maxContentBytes` (0 = unlimited; negative or
1248
+ * non-integer values are treated as the default, with a createServer warn).
1249
+ * The cap protects disk AND memory: a capture whose upstream response
1250
+ * DECLARES an over-cap Content-Length is skipped without downloading, while
1251
+ * a response with no declared length is read as a stream with the byte count
1252
+ * enforced during the read — on exceed the download aborts with nothing
1253
+ * oversized retained in memory. In both cases the fixture is persisted
1254
+ * without `b64` and the same-session job serves the placeholder MP4.
1255
+ */
1256
+ const OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES = 32 * 1024 * 1024;
1257
+ /**
1258
+ * Proxy a status poll for a record-mode job 1:1 to the upstream and relay
1259
+ * the result with mock-rewritten identifiers (rewriteRecordPollBody: id,
1260
+ * polling_url, unsigned_urls; everything else verbatim). When the upstream
1261
+ * reports `completed`, the rewritten body is relayed IMMEDIATELY and the
1262
+ * eager capture (server-side fetch of unsigned_urls[0], forwarding the
1263
+ * polling client's Bearer ONLY same-origin; persist; replay mutation) runs
1264
+ * DETACHED in the background — see captureOpenRouterVideoRecordFixture — so
1265
+ * an SDK poller is never blocked on a multi-minute download; the content
1266
+ * handler (and every later poll) serves the job locally once the capture
1267
+ * lands. `failed` persists a failed fixture synchronously on the relaying
1268
+ * poll. Every post-await map mutation is identity-guarded
1269
+ * (`jobs.get(key) === job`) so a stale response resolving after a concurrent
1270
+ * poll's capture replaced the entry can never resurrect the detached record
1271
+ * object over the terminal replay job.
1272
+ * `cancelled`/`expired` (not representable in VideoResponse.status) pass
1273
+ * through with a warn and are never recorded. Under `record.proxyOnly`
1274
+ * nothing is captured, persisted, or mutated: terminal polls relay the
1275
+ * rewritten upstream body, the upstream unsigned_urls are stored on the
1276
+ * record job for the content endpoint to live-proxy, and every later poll
1277
+ * keeps proxying upstream. Each successful proxied poll of a record job
1278
+ * refreshes its TTL so a long generation cannot be evicted mid-recording.
1279
+ */
1280
+ async function proxyOpenRouterVideoRecordPoll(args) {
1281
+ const { req, res, job, key, testId, fixtures, journal, defaults, jobs, method, path } = args;
1282
+ const logger = defaults.logger;
1283
+ const journalProxy = (status) => {
1284
+ journal.add({
1285
+ method,
1286
+ path,
1287
+ headers: require_helpers.flattenHeaders(req.headers),
1288
+ body: null,
1289
+ response: {
1290
+ status,
1291
+ fixture: null,
1292
+ source: "proxy",
1293
+ ...require_helpers.strictOverrideField(defaults.strict, req.headers)
1294
+ }
1295
+ });
1296
+ };
1297
+ const proxyError = (msg) => {
1298
+ logger.error(`OpenRouter video poll proxy failed: ${msg}`);
1299
+ if (res.destroyed || res.writableEnded) return;
1300
+ journalProxy(502);
1301
+ require_sse_writer.writeErrorResponse(res, 502, JSON.stringify({ error: {
1302
+ message: `Proxy to upstream failed: ${msg}`,
1303
+ type: "proxy_error"
1304
+ } }));
1305
+ };
1306
+ const record = defaults.record;
1307
+ if (!record) {
1308
+ proxyError("record mode is no longer configured for an in-flight record job");
1309
+ return;
1310
+ }
1311
+ let fetched;
1312
+ try {
1313
+ const upstreamRes = await fetch(job.upstreamPollingUrl, {
1314
+ headers: require_recorder.buildForwardHeaders(req),
1315
+ signal: upstreamTimeoutSignal(record)
1316
+ });
1317
+ fetched = {
1318
+ status: upstreamRes.status,
1319
+ contentType: upstreamRes.headers.get("content-type"),
1320
+ text: await readEnvelopeText(upstreamRes, record)
1321
+ };
1322
+ } catch (err) {
1323
+ proxyError(err instanceof Error ? err.message : "Unknown proxy error");
1324
+ return;
1325
+ }
1326
+ if (fetched.status === 401 || fetched.status === 403) {
1327
+ logger.warn(`Upstream rejected the status poll for job ${job.upstreamJobId} (${fetched.status}) — relaying the upstream status`);
1328
+ if (res.destroyed || res.writableEnded) return;
1329
+ journalProxy(fetched.status);
1330
+ res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" });
1331
+ res.end(fetched.text);
1332
+ return;
1333
+ }
1334
+ let upstreamBody;
1335
+ {
1336
+ if (fetched.status < 200 || fetched.status >= 300) {
1337
+ proxyError(`Status ${fetched.status}: ${fetched.text.slice(0, 200)}`);
1338
+ return;
1339
+ }
1340
+ let parsed;
1341
+ try {
1342
+ parsed = JSON.parse(fetched.text);
1343
+ } catch {
1344
+ proxyError(`Status returned non-JSON: ${fetched.text.slice(0, 200)}`);
1345
+ return;
1346
+ }
1347
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1348
+ proxyError("Status response is not a JSON object");
1349
+ return;
1350
+ }
1351
+ upstreamBody = parsed;
1352
+ }
1353
+ const relayJson = (body) => {
1354
+ if (res.destroyed || res.writableEnded) return;
1355
+ journalProxy(200);
1356
+ res.writeHead(200, { "Content-Type": "application/json" });
1357
+ res.end(JSON.stringify(body));
1358
+ };
1359
+ const relayBody = rewriteRecordPollBody({
1360
+ upstreamBody,
1361
+ job,
1362
+ req,
1363
+ testId,
1364
+ logger
1365
+ });
1366
+ const upstreamStatus = String(upstreamBody.status ?? "");
1367
+ if (upstreamStatus === "pending" || upstreamStatus === "in_progress") {
1368
+ if (jobs.get(key) === job) {
1369
+ if (!job.capturing && job.status !== "completed" && job.status !== "failed") job.status = upstreamStatus;
1370
+ jobs.set(key, job);
1371
+ }
1372
+ relayJson(relayBody);
1373
+ return;
1374
+ }
1375
+ if (upstreamStatus === "completed") {
1376
+ if (record.proxyOnly) {
1377
+ if (jobs.get(key) === job) {
1378
+ const urls = upstreamBody.unsigned_urls;
1379
+ job.status = "completed";
1380
+ if (Array.isArray(urls)) job.upstreamUnsignedUrls = [...urls];
1381
+ jobs.set(key, job);
1382
+ }
1383
+ relayJson(relayBody);
1384
+ return;
1385
+ }
1386
+ if (job.capturing || jobs.get(key) !== job) {
1387
+ if (jobs.get(key) === job) {
1388
+ const freshUrls = upstreamBody.unsigned_urls;
1389
+ if (Array.isArray(freshUrls)) job.upstreamUnsignedUrls = [...freshUrls];
1390
+ jobs.set(key, job);
1391
+ }
1392
+ relayJson(relayBody);
1393
+ return;
1394
+ }
1395
+ const urls = upstreamBody.unsigned_urls;
1396
+ job.capturing = true;
1397
+ job.status = "completed";
1398
+ if (Array.isArray(urls)) job.upstreamUnsignedUrls = [...urls];
1399
+ jobs.set(key, job);
1400
+ relayJson(relayBody);
1401
+ captureOpenRouterVideoRecordFixture({
1402
+ req,
1403
+ job,
1404
+ key,
1405
+ testId,
1406
+ fixtures,
1407
+ defaults,
1408
+ jobs,
1409
+ record,
1410
+ upstreamBody
1411
+ });
1412
+ return;
1413
+ }
1414
+ if (upstreamStatus === "failed") {
1415
+ if (record.proxyOnly) {
1416
+ if (jobs.get(key) === job) {
1417
+ job.status = "failed";
1418
+ jobs.set(key, job);
1419
+ }
1420
+ relayJson(relayBody);
1421
+ return;
1422
+ }
1423
+ if (jobs.get(key) !== job) {
1424
+ relayJson(relayBody);
1425
+ return;
1426
+ }
1427
+ const rawError = upstreamBody.error;
1428
+ let error;
1429
+ if (typeof rawError === "string" && rawError) error = rawError;
1430
+ else if (rawError !== null && typeof rawError === "object" && !Array.isArray(rawError)) {
1431
+ const message = rawError.message;
1432
+ if (typeof message === "string" && message) error = message;
1433
+ }
1434
+ if (error === void 0 && rawError !== void 0 && rawError !== null) logger.warn(`Upstream video job ${job.upstreamJobId} failed with an unusable error value (${JSON.stringify(rawError).slice(0, 100)}) — recording the fixture without an error message (replay serves the default)`);
1435
+ const video = {
1436
+ id: job.upstreamJobId,
1437
+ status: "failed",
1438
+ ...error !== void 0 ? { error } : {}
1439
+ };
1440
+ const persistResult = require_recorder.persistFixture({
1441
+ record,
1442
+ providerKey: "openrouter",
1443
+ testId,
1444
+ fixture: {
1445
+ match: job.match,
1446
+ response: { video }
1447
+ },
1448
+ fixtures,
1449
+ logger
1450
+ });
1451
+ if (persistResult.kind === "failed" && !res.headersSent) res.setHeader("X-AIMock-Record-Error", require_recorder.sanitizeHeaderValue(persistResult.error));
1452
+ jobs.set(key, {
1453
+ kind: "replay",
1454
+ jobId: job.jobId,
1455
+ status: "failed",
1456
+ pollCount: 0,
1457
+ pollsBeforeInProgress: 0,
1458
+ pollsBeforeCompleted: 0,
1459
+ video: { ...video }
1460
+ });
1461
+ relayJson(relayBody);
1462
+ return;
1463
+ }
1464
+ logger.warn(`Upstream video job ${job.upstreamJobId} reported status "${upstreamStatus}" — not representable in fixture video.status; passing through without recording`);
1465
+ if (jobs.get(key) === job) jobs.set(key, job);
1466
+ relayJson(relayBody);
1467
+ }
1468
+ /**
1469
+ * Detached eager-capture sequence for a completed record job. Runs AFTER the
1470
+ * triggering completed poll has been relayed (the client is never blocked on
1471
+ * a potentially multi-minute video download): fetches `unsigned_urls[0]`
1472
+ * server-side (the polling client's Bearer is forwarded ONLY same-origin),
1473
+ * persists the fixture, and mutates the map entry into a terminal replay job.
1474
+ * Every failure is handled internally and the returned promise NEVER
1475
+ * rejects. Failure semantics: a capture FAILURE — no usable
1476
+ * unsigned_urls, an invalid content URL, or a content fetch that errors on
1477
+ * headers, status, or body — persists NOTHING and leaves the job a live
1478
+ * proxy, so the next completed poll retries the capture (each failure
1479
+ * warns). The ONLY degraded persist is the over-cap path (declared or
1480
+ * streamed): retrying would always re-exceed the cap, so the b64-less
1481
+ * fixture + placeholder is the deliberate, permanent outcome. The capturing
1482
+ * window opened by the caller is closed in the finally when the map still
1483
+ * holds this record job; on the success path the entry was just replaced and
1484
+ * the detached record object deliberately KEEPS capturing=true so a
1485
+ * concurrent poll that read it before the replacement still early-relays
1486
+ * instead of starting a second capture.
1487
+ */
1488
+ async function captureOpenRouterVideoRecordFixture(args) {
1489
+ const { req, job, key, testId, fixtures, defaults, jobs, record, upstreamBody } = args;
1490
+ const logger = defaults.logger;
1491
+ const urls = upstreamBody.unsigned_urls;
1492
+ const warnings = [];
1493
+ let capturedB64;
1494
+ try {
1495
+ const usage = upstreamBody.usage;
1496
+ const rawCost = usage !== null && typeof usage === "object" && !Array.isArray(usage) ? usage.cost : void 0;
1497
+ const cost = typeof rawCost === "number" ? rawCost : void 0;
1498
+ const rawCap = record.openRouterVideo?.maxContentBytes;
1499
+ const cap = rawCap !== void 0 && Number.isInteger(rawCap) && rawCap >= 0 ? rawCap : OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES;
1500
+ const urlArray = Array.isArray(urls) ? urls : void 0;
1501
+ if (urlArray && urlArray.length > 1) logger.warn(`Upstream video job ${job.upstreamJobId} reported ${urlArray.length} unsigned_urls — only index 0 is captured; post-capture replays serve the index-0 bytes for every index`);
1502
+ const firstRaw = urlArray?.[0];
1503
+ const firstUrl = typeof firstRaw === "string" && firstRaw ? firstRaw : void 0;
1504
+ if (!firstUrl) {
1505
+ if (urlArray && urlArray.length > 0) logger.warn(`Upstream video job ${job.upstreamJobId} completed with an unusable unsigned_urls[0] (${JSON.stringify(firstRaw).slice(0, 100)}) — capture skipped, nothing persisted; the next completed poll retries`);
1506
+ else logger.warn(`Upstream video job ${job.upstreamJobId} completed without unsigned_urls — capture skipped, nothing persisted; the next completed poll retries`);
1507
+ return;
1508
+ }
1509
+ let contentUrl;
1510
+ try {
1511
+ contentUrl = new URL(firstUrl);
1512
+ } catch {
1513
+ logger.warn(`Upstream unsigned_urls[0] is not a valid URL (${firstUrl.slice(0, 100)}) — capture skipped, nothing persisted; the next completed poll retries`);
1514
+ return;
1515
+ }
1516
+ {
1517
+ const providerBase = record.providers.openrouter;
1518
+ let providerOrigin;
1519
+ try {
1520
+ providerOrigin = new URL(providerBase ?? job.upstreamPollingUrl).origin;
1521
+ } catch {
1522
+ try {
1523
+ providerOrigin = new URL(job.upstreamPollingUrl).origin;
1524
+ } catch {
1525
+ logger.error(`OpenRouter video capture for job ${job.upstreamJobId} aborted: cannot determine the provider origin (invalid provider URL and polling URL) — refusing to forward credentials`);
1526
+ return;
1527
+ }
1528
+ }
1529
+ const headers = require_recorder.buildForwardHeaders(req);
1530
+ if (contentUrl.origin !== providerOrigin) {
1531
+ delete headers.authorization;
1532
+ logger.warn(`Upstream unsigned_urls[0] origin ${contentUrl.origin} differs from the provider origin ${providerOrigin} — fetching content WITHOUT the client's Authorization header`);
1533
+ }
1534
+ try {
1535
+ const contentRes = await fetchHeadersWithTimeout(contentUrl, headers, record);
1536
+ if (!contentRes.ok) {
1537
+ let sample = "";
1538
+ try {
1539
+ const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP);
1540
+ if (!read.overCap) sample = read.buf.toString("utf8").slice(0, 200);
1541
+ } catch {}
1542
+ throw new Error(`Content ${contentRes.status}${sample ? `: ${sample}` : ""}`);
1543
+ }
1544
+ const declaredLength = Number(contentRes.headers.get("content-length") ?? NaN);
1545
+ if (cap > 0 && Number.isFinite(declaredLength) && declaredLength > cap) {
1546
+ contentRes.body?.cancel().catch(() => {});
1547
+ logger.warn(`Captured video for job ${job.upstreamJobId} declares Content-Length ${declaredLength} — over maxContentBytes (${cap}); skipping the download; fixture persisted WITHOUT b64 (content serves the placeholder MP4)`);
1548
+ warnings.push(`Declared content length (${declaredLength} bytes) exceeded maxContentBytes (${cap}) — download skipped, b64 omitted`);
1549
+ } else {
1550
+ const read = await readBodyIdle(contentRes, record, cap);
1551
+ if (read.overCap) {
1552
+ logger.warn(`Captured video for job ${job.upstreamJobId} streamed past maxContentBytes (${cap}) — download aborted at ${read.bytesRead} bytes; fixture persisted WITHOUT b64 (content serves the placeholder MP4)`);
1553
+ warnings.push(`Captured video exceeded maxContentBytes (${cap}) — download aborted, b64 omitted`);
1554
+ } else capturedB64 = read.buf.toString("base64");
1555
+ }
1556
+ } catch (err) {
1557
+ const msg = err instanceof Error ? err.message : "Unknown capture error";
1558
+ logger.warn(`OpenRouter video content capture failed (${msg}) — nothing persisted; the job keeps proxying live and the next completed poll retries`);
1559
+ return;
1560
+ }
1561
+ }
1562
+ const video = {
1563
+ id: job.upstreamJobId,
1564
+ status: "completed",
1565
+ ...capturedB64 !== void 0 ? { b64: capturedB64 } : {},
1566
+ ...cost !== void 0 ? { cost } : {}
1567
+ };
1568
+ if (jobs.get(key) !== job) {
1569
+ logger.warn(`OpenRouter video capture for job ${job.upstreamJobId} discarded: the job map no longer holds this job (fixtures reset or TTL eviction mid-capture) — nothing persisted`);
1570
+ return;
1571
+ }
1572
+ require_recorder.persistFixture({
1573
+ record,
1574
+ providerKey: "openrouter",
1575
+ testId,
1576
+ fixture: {
1577
+ match: job.match,
1578
+ response: { video }
1579
+ },
1580
+ fixtures,
1581
+ warnings,
1582
+ logger
1583
+ });
1584
+ if (jobs.get(key) === job) jobs.set(key, {
1585
+ kind: "replay",
1586
+ jobId: job.jobId,
1587
+ status: "completed",
1588
+ pollCount: 0,
1589
+ pollsBeforeInProgress: 0,
1590
+ pollsBeforeCompleted: 0,
1591
+ video: { ...video }
1592
+ });
1593
+ } catch (err) {
1594
+ const msg = err instanceof Error ? err.message : String(err);
1595
+ logger.error(`OpenRouter video capture for job ${job.upstreamJobId} failed unexpectedly (${msg}) — fixture not persisted; the job keeps proxying live`);
1596
+ } finally {
1597
+ if (jobs.get(key) === job) job.capturing = false;
1598
+ }
1599
+ }
1600
+
1601
+ //#endregion
1602
+ exports.OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES = OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES;
1603
+ exports.OPENROUTER_VIDEO_MAX_ENTRIES = OPENROUTER_VIDEO_MAX_ENTRIES;
1604
+ exports.OpenRouterVideoJobMap = OpenRouterVideoJobMap;
1605
+ exports.handleOpenRouterVideoContent = handleOpenRouterVideoContent;
1606
+ exports.handleOpenRouterVideoCreate = handleOpenRouterVideoCreate;
1607
+ exports.handleOpenRouterVideoModels = handleOpenRouterVideoModels;
1608
+ exports.handleOpenRouterVideoStatus = handleOpenRouterVideoStatus;
1609
+ //# sourceMappingURL=openrouter-video.cjs.map