@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +2 -2
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/cli.cjs +18 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/fal-audio.cjs +12 -6
- package/dist/fal-audio.cjs.map +1 -1
- package/dist/fal-audio.d.cts.map +1 -1
- package/dist/fal-audio.d.ts.map +1 -1
- package/dist/fal-audio.js +14 -8
- package/dist/fal-audio.js.map +1 -1
- package/dist/fal.cjs +100 -54
- package/dist/fal.cjs.map +1 -1
- package/dist/fal.d.cts +13 -0
- package/dist/fal.d.cts.map +1 -1
- package/dist/fal.d.ts +13 -0
- package/dist/fal.d.ts.map +1 -1
- package/dist/fal.js +101 -55
- package/dist/fal.js.map +1 -1
- package/dist/index.cjs +10 -2
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -3
- package/dist/llmock.cjs +8 -3
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts +5 -5
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts +5 -5
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +8 -3
- package/dist/llmock.js.map +1 -1
- package/dist/metrics.cjs +9 -0
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.cts.map +1 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +7 -1
- package/dist/metrics.js.map +1 -1
- package/dist/openrouter-video.cjs +1609 -0
- package/dist/openrouter-video.cjs.map +1 -0
- package/dist/openrouter-video.d.cts +154 -0
- package/dist/openrouter-video.d.cts.map +1 -0
- package/dist/openrouter-video.d.ts +154 -0
- package/dist/openrouter-video.d.ts.map +1 -0
- package/dist/openrouter-video.js +1601 -0
- package/dist/openrouter-video.js.map +1 -0
- package/dist/recorder.cjs +48 -7
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +46 -8
- package/dist/recorder.js.map +1 -1
- package/dist/server.cjs +158 -10
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +2 -0
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +159 -11
- package/dist/server.js.map +1 -1
- package/dist/types.d.cts +100 -9
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +100 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/vector-types.d.cts.map +1 -1
- 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
|