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