diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2ecc30..a067fdb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,109 @@ always as `Content-Type: video/mp4` (matching production even when the client sends `Accept: application/octet-stream`). Status polls and the models listing are served without auth — only the content endpoint enforces Bearer (a deliberate, documented divergence). The `index` - query param is accepted but ignored (jobs are single-video), and the content endpoint does not - advance job state — content URLs are only learned from a completed status poll (API fidelity; - diverges from fal's advance-on-result). `GET /api/v1/videos/models` synthesizes the video-model + query param is accepted but ignored (jobs are single-video) — except when the content endpoint + live-proxies an upstream (record mode's proxy-only operation, or the in-flight capture window + below), where it selects the position-aligned upstream `unsigned_urls` entry — and the content + endpoint does not advance job state — content URLs are only learned from a completed status poll + (API fidelity; diverges from fal's advance-on-result). `GET /api/v1/videos/models` synthesizes + the video-model listing from loaded video fixtures that specify a string `match.model` (falling back to a built-in default set otherwise). Video fixtures gain optional `error`, `b64`, and `cost` fields. - Replay-only for now (no record/proxy mode yet — lenient mode 404s on a miss instead of - proxying); record-mode proxying for this surface is a follow-up. + Record mode is supported via the `"openrouter"` provider key + (`record.providers.openrouter` / `--provider-openrouter `) as a live interactive proxy: an + unmatched submit is forwarded upstream and answered with a mock-rewritten envelope (fresh aimock + jobId, `polling_url` pointing back at the mock with the testId embedded), and each client poll + is proxied upstream and relayed with the mock identifiers substituted — `id`, a present + `polling_url`, and a present `unsigned_urls` array (same length, one mock content URL per index) + are rewritten (a non-array `unsigned_urls` cannot be index-rewritten and is stripped from the + relay with a warning instead of leaking the upstream value); every other field (including + `usage`, untouched — a non-number `usage.cost` warns but passes through) is relayed verbatim. + When the upstream reports `completed`, the poll is relayed immediately and the eager capture + runs in the background: `unsigned_urls[0]` is origin-checked against the configured provider + and fetched server-side after the first completed poll has been answered — an SDK poller is + never blocked on a multi-minute video download; the polling client's Bearer is forwarded only + same-origin (off-origin content hosts are fetched without auth, with a warning) — and the + bytes are persisted as a normal video fixture (`match.userMessage` = prompt, `match.model` = + the submitted model as recorded by the standard model-normalization rules — date suffixes + stripped unless `recordFullModelVersion`; model-less submits record the assumed default model + — `video.id` = the upstream job id) that replays in-session and across sessions. While the + capture is in flight, the job is already observable as completed: status polls relay the + upstream body and the content endpoint live-proxies downloads (no 400 window). A capture + FAILURE — no usable `unsigned_urls`, an invalid content URL, or a content fetch that errors on + headers, status, or body — persists NOTHING and leaves the job a live proxy, so the next + completed poll retries the capture (each failure warns); the only degraded persist (fixture + written without `b64`) is the over-cap path below, where a retry would always re-exceed the cap. + `failed` jobs persist `{ status: "failed", error }` fixtures; `cancelled`/`expired` upstream + statuses (not representable in `video.status`) pass through verbatim with a warning and are + never recorded; an upstream `failed` body whose `error` is the canonical + `{ message, code }` object records the extracted message (an unusable non-null `error` value + warns and is omitted from the fixture; an explicit `null` is omitted without the warn). + Recorded `b64` is capped at `record.openRouterVideo.maxContentBytes` + (default 32 MB decoded, `0` = unlimited, negative/non-integer values are treated as the default + with a `createServer` warning; exported as `OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES`): the + cap guards memory as well as disk — a capture whose upstream response declares an over-cap + `Content-Length` is skipped without downloading, an undeclared-length capture is streamed with + the byte count enforced during the read (on exceed the download aborts and nothing oversized is + retained), and in both cases the fixture is persisted without `b64` (with a `_warning` in the + fixture file) and the placeholder is served even same-session. `GET /api/v1/videos/models` is + relayed verbatim from the upstream in record mode (journaled `source: "proxy"`, never recorded + as a fixture), falling back to the fixture-driven synthesis on upstream failure; strict mode + disables the models proxy entirely. Strict mode wins over record (503 on a no-match, nothing + proxied — a chaos roll on a strict no-fixture submit is labeled `source: "internal"`), and an + effective-strict request also 503s a record job's status polls and live-proxied content + downloads ("strict means nothing reaches an upstream", honoring per-request `X-AIMock-Strict` + overrides). A missing provider URL warns and falls through to 404; upstream failures return 502 + `proxy_error` (a hung models fetch instead falls back 200 to the synthesized listing). The + small-JSON upstream fetches (submit, poll, models) honor `record.upstreamTimeoutMs` (default + 30s) as a total deadline; the byte-bearing content fetches (eager capture, content relay) gate + only the response headers on `upstreamTimeoutMs` and stream the body under + `record.bodyTimeoutMs` idle semantics (re-armed per chunk), so a steadily-downloading long + render never times out — only a mid-body stall aborts. Every proxied journal entry carries + `source: "proxy"` (the synthesized models fallback after a failed proxy attempt is labeled + `source: "internal"`), a fixture-write failure surfaces as an `X-AIMock-Record-Error` header on + the relayed poll on the failed branch (the completed branch's capture is detached from the + relay, so its persist failures are logged instead), and each successful (2xx) proxied poll, each + replay-job poll, AND each successful content serve/relay refreshes the job's 1-hour TTL so long + generations — and long download sessions — are not evicted mid-recording (or mid-polling); a + relayed upstream 401/403 does NOT refresh the TTL. + Upstream `401`/`403` rejections pass through to the client verbatim on all three proxied + surfaces — submit, status poll, and content download — for real-API fidelity (the relayed + 401/403 bodies deliberately bypass the mock's identifier/URL rewriting: auth-error envelopes + carry no job URLs, and fidelity of the provider's error body wins). Disabling recording + mid-flight (`disableRecording()`) makes + every later poll of an orphaned record job fail loudly with 502 before contacting the upstream — + and content downloads too, when the job is completed (a pending/in_progress orphan's download + still 400s as not-completed first; the 502 gate sits behind the completed check). Under + `record.proxyOnly` nothing is persisted or cached: jobs stay + live proxies after terminal polls and content downloads are live-proxied from the stored + upstream `unsigned_urls[index]` on every fetch, with the bytes STREAMED to the client as they + arrive — the video is never buffered in memory and the first bytes reach the client while the + upstream is still sending (the same streamed relay serves capture-window downloads; + position-aligned with the relayed array; an out-of-range or unusable index warns and falls back + to index 0; same same-origin Bearer gate as the capture path; an upstream 401/403 on the + content fetch passes through to the client for real-API fidelity). The four + `handleOpenRouterVideo*` handlers, + `OpenRouterVideoJobMap`, and `OPENROUTER_VIDEO_MAX_ENTRIES` are exported from the package root + (matching the sibling video/fal surfaces). The CLI warns when + `--upstream-timeout-ms`/`--body-timeout-ms` — or any `--provider-*` flag — are passed without + `--record`/`--proxy-only` (previously parsed and silently dropped). + +### Changed + +- **Proxy header forwarding (all providers)** — requests forwarded to a recording upstream now + strip the mock-internal control headers `x-test-id`, `x-aimock-strict`, `x-aimock-context`, and + the `x-aimock-chaos-*` prefix family on every provider proxy path (not just the new OpenRouter + video surface). These headers are meaningless — and potentially confusing or leaky — on a real + provider's wire; everything else still passes through as before. Note that because `x-test-id` + is now stripped upstream, recording THROUGH a second aimock instance loses testId scoping on + the inner instance. +- **fal queue walk: same-origin envelope URLs** — the recording queue walk (fal and the legacy + fal-audio path) now adopts the `status_url`/`response_url` an upstream submit envelope nominates + only when they are same-origin with the configured upstream (the gate the OpenRouter video proxy + applies to envelope `polling_url`s). Every walk fetch forwards the client's headers — including + `Authorization` — so an envelope nominating a foreign host must never receive them; off-origin, + unparseable, or absent envelope URLs fall back to the constructed canonical paths on the + upstream origin, with a warning. ### Fixed @@ -38,6 +134,17 @@ of stranding jobs short of a terminal status, negative/fractional values are floored and clamped to non-negative integers, and `createServer` now warns on invalid `falQueue`/`openRouterVideo` threshold values. +- **fal queue walk: per-fetch timeout** — each of the walk's submit/status/result fetches is now + bounded by `record.upstreamTimeoutMs` (30s default, additionally clamped to the walk's remaining + `record.fal.timeoutMs` budget). Previously the walk-level timeout only fired BETWEEN polls, so a + hung upstream socket could pin the walk indefinitely; it now surfaces through the existing + 502/no-fixture failure handling. +- **fal queue walk: `pollIntervalMs: 0`** — a zero poll interval no longer produces a spurious + "Queue walk timed out" on the first non-terminal poll; the walk only times out when the + `timeoutMs` budget is actually exhausted ("poll as fast as possible" is a valid configuration). +- **fal/fal-audio queue-walk persist failures** — a fixture-write failure on the queue-walk record + paths now surfaces as an `X-AIMock-Record-Error` header on the synthesized envelope (parity with + the generic recorder relay and the OpenRouter video failed branch). ## [1.30.0] - 2026-06-09 diff --git a/docs/openrouter-video/index.html b/docs/openrouter-video/index.html index 1f510b38..958209f0 100644 --- a/docs/openrouter-video/index.html +++ b/docs/openrouter-video/index.html @@ -88,8 +88,9 @@

Endpoints

GET /api/v1/videos/{jobId}/content - The video bytes as video/mp4 (Bearer auth required; 400 before the job - completes) + The video bytes as video/mp4 (Bearer auth required — 401 without + it; 404 for an unknown or TTL-expired job; 400 before the job completes — and + permanently for a failed job, whose content is never downloadable) @@ -114,12 +115,13 @@

Fixture Authoring

openrouter-video.test.ts ts
mock.onVideo("a cat playing piano", {
-  video: { status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
+  // `id` is required by the type but ignored on this surface (see below)
+  video: { id: "vid_1", status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
 });
 
 // A failed job:
 mock.onVideo("impossible prompt", {
-  video: { status: "failed", error: "content policy violation" },
+  video: { id: "vid_2", status: "failed", error: "content policy violation" },
 });
@@ -175,12 +177,16 @@

Polling Realism

Unset and an explicit 0 differ: with both fields unset the job is terminal at submit, but explicitly setting pollsBeforeInProgress — even to - 0 — enables progression, with + 0 — enables progression + when pollsBeforeCompleted is unset, with pollsBeforeCompleted defaulting to pollsBeforeInProgress + 1 so the job passes through - in_progress. An explicit pollsBeforeCompleted lower than + in_progress. An explicit + { pollsBeforeInProgress: 0, pollsBeforeCompleted: 0 } still seeds the job + terminal at submit. An explicit pollsBeforeCompleted lower than pollsBeforeInProgress is clamped up so in_progress is never - skipped. + skipped — once a job enters the progression at all (the explicit + {0, 0} case above seeds terminal and never polls through it).

@@ -205,9 +211,12 @@

Content Serving

built-in minimal MP4 ftyp placeholder otherwise — always as Content-Type: video/mp4, even when the client sends Accept: application/octet-stream (matching production). The - index query param is accepted but ignored (jobs are single-video), and - fetching content never advances job state — clients learn the content URL only from - a completed status poll. + index query param is accepted but ignored (jobs are single-video) — + except when the content endpoint live-proxies an upstream (under + record mode's proxy-only operation, or during the brief capture + window while an eager capture is in flight), where the index selects the position-aligned + upstream unsigned_urls entry. Fetching content never advances job state + — clients learn the content URL only from a completed status poll.

Test Isolation

@@ -224,23 +233,146 @@

Models Listing

GET /api/v1/videos/models synthesizes the listing from loaded video fixtures that specify a string match.model. When no video fixture contributes a string model, a built-in default set is served instead (with a warning if video fixtures are - loaded but none has a string model). + loaded but none has a string model). In record mode the listing is relayed verbatim from + the upstream instead (journaled source: "proxy", never recorded as a + fixture), falling back to the synthesis if the upstream is unreachable. Strict mode + disables the proxy — a strict request is always served the synthesized listing.

Chaos & Metrics

- Chaos injection applies to all four routes; journal entries - for chaos served without a matched fixture carry source: "internal". In + Chaos injection applies to all four routes. Journal entries + for no-fixture submit chaos carry source: "proxy" when a record-mode + openrouter upstream is configured and strict mode would not win (the request would have + been proxied); a strict no-match — which 503s before any proxy attempt — and + the no-record case stay source: "internal"; chaos on the status and content + routes rolls before the job lookup (the models route has no lookup) and chaos on all three + GET routes always stays source: "internal". In Prometheus metrics, per-job paths are templated as /api/v1/videos/{jobId} and /api/v1/videos/{jobId}/content to keep label cardinality bounded.

+

Record Mode

+

+ With record mode and the openrouter provider + configured, an unmatched submit becomes a live interactive proxy: the submit is + forwarded to the real API and answered with a mock-rewritten envelope — a fresh + aimock jobId and a polling_url pointing back at the mock (testId embedded) + — and each client status poll is proxied upstream 1:1 with the mock jobId + substituted. Unlike the fal recorder there is no server-side queue walk; the client's own + polling drives the upstream lifecycle. +

+ +
+
aimock.config.json json
+
{
+  "llm": {
+    "fixtures": "./fixtures",
+    "record": {
+      "providers": { "openrouter": "https://openrouter.ai" }
+    }
+  }
+}
+
+ +
+
CLI usage shell
+
$ npx -p @copilotkit/aimock aimock -c aimock.config.json
+
+# or with the legacy llmock bin's flag interface:
+$ npx -p @copilotkit/aimock llmock -f ./fixtures \
+  --record \
+  --provider-openrouter https://openrouter.ai
+
+ +
+
Programmatic config ts
+
const mock = new LLMock({
+  record: {
+    providers: { openrouter: "https://openrouter.ai" },
+    // optional: cap recorded b64 (decoded bytes; default 32 MB, 0 = unlimited)
+    openRouterVideo: { maxContentBytes: 8 * 1024 * 1024 },
+  },
+});
+
+ +

+ When the upstream reports completed, the poll is relayed immediately and the + eager capture runs in the background: aimock fetches + unsigned_urls[0] server-side after the first completed poll has been answered + — a poller is never blocked on a multi-minute video download — and persists + the bytes as a normal video fixture (match.userMessage = the prompt, + match.model = the submitted model as recorded by the standard + model-normalization rules — date suffixes stripped unless + recordFullModelVersion; model-less submits record the assumed default model + — video.id = the upstream job id, plus b64 and + cost), so the same submit replays in-session and across sessions. While the + capture is in flight the job is already observable as completed: status polls relay the + upstream body and the content endpoint live-proxies downloads. Relayed poll bodies are + otherwise faithful: every upstream field passes through verbatim (including + usage, untouched) with only the identifiers rewritten — + id, a present polling_url, and a present + unsigned_urls array (same length, one mock content URL per index; only index + 0 is captured, and the mock content endpoint serves the index-0 bytes for every index on + post-capture replays — during the capture window and under proxy-only the index + selects the live-proxied upstream URL). A non-array unsigned_urls cannot be + index-rewritten and is stripped from the relay with a warning rather than passed through + verbatim. The maxContentBytes cap protects disk and memory: a + capture whose upstream response declares an over-cap Content-Length is + skipped without downloading, and a response with no declared length is streamed with the + byte count enforced during the read — on exceed the download is aborted and nothing + oversized is retained. In both cases the fixture is persisted without + b64 (with a _warning in the fixture file) and the placeholder is + served even same-session. The small-JSON upstream fetches (submit, poll, models) honor + record.upstreamTimeoutMs (default 30s) as a total deadline — a hung + upstream surfaces as a 502 proxy_error on submit and poll, while the models + listing instead falls back to the fixture-driven synthesis with a 200 (and a warning). The + byte-bearing content fetches (eager capture, proxy-only relay) gate only the response + headers on upstreamTimeoutMs and stream the body under + record.bodyTimeoutMs idle semantics (default 30s, re-armed per + chunk) — a steadily-downloading long render never times out. A capture-fetch failure + (connection error, error status, or a mid-body stall) persists nothing: the job + stays a live proxy and the next completed poll retries the capture — only the + over-cap path persists the b64-less fixture described above. + failed jobs persist { status: "failed", error }; + cancelled and expired upstream statuses are not representable in + video.status — they pass through verbatim with a warning and are never + recorded. +

+ +

+ The polling client's Bearer credential is forwarded only to the configured provider + origin: the upstream's polling_url is adopted only when same-origin (an + off-origin URL falls back to the constructed path on the provider origin), and an + off-origin unsigned_urls[0] (e.g. a CDN host) is fetched without + the Authorization header, with a warning. Strict mode wins over record: a strict no-match + returns 503 and nothing is proxied. Without a configured + openrouter provider URL, --record warns and serves the normal + no-match 404. +

+ +

+ Under proxy-only mode (record.proxyOnly / --proxy-only) nothing + is persisted and nothing is cached: no fixture file is written, no in-memory fixture is + registered, and a completed job is never converted to a local replay job — every + status poll keeps proxying upstream, and every content download live-proxies the stored + upstream unsigned_urls[index] (same same-origin Bearer gate as the capture + path; the bytes are streamed to the client as they arrive — never buffered, never + cached — so repeated downloads hit the upstream each time). The + jobId ↔ upstream mapping itself is inherently stateful — the mock + still tracks it in memory so the rewritten URLs resolve. +

+

- Replay-only: this surface does not support record/proxy mode yet - — with --record, an unmatched submit warns and returns the normal - no-match response instead of proxying. Record-mode support is a follow-up. + TTL caveat: record-mode jobs live in the same bounded job map as replay + jobs (1-hour TTL, 10,000 entries). Each successful proxied poll refreshes a record job's + TTL, and successful content downloads refresh it too (replay and record jobs alike), so + an actively-polled or actively-downloading long generation is never evicted + mid-recording — but a poll arriving more than an hour after the last + successful poll or download finds the job evicted and returns 404 (the upstream + lifecycle is then unreachable through the mock).

diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index 7c1c76c0..d35c3afa 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -201,8 +201,8 @@

Record & Replay CLI Flags

--log-level <level> - Log verbosity: silent, info, debug (default: - info) + Log verbosity: silent, warn, info, + debug (default: info) @@ -257,12 +257,23 @@

Record & Replay CLI Flags

--provider-cohere <url> Upstream URL for Cohere + + --provider-openrouter <url> + + Upstream URL for OpenRouter (the video lifecycle + record proxy) + + --upstream-timeout-ms <ms> Connection idle timeout (ms) on the upstream request socket before the response body begins (default: 30000). Increase for upstreams with slow initial - responses (reasoning models, queue-backed providers). + responses (reasoning models, queue-backed providers). Nuance: the OpenRouter video + surface's small-JSON lifecycle fetches (submit, status poll, models listing) apply + this value as a total deadline rather than a socket-idle timeout — + indistinguishable for envelope-sized bodies; the byte-bearing content fetches gate + only the response headers on it. @@ -420,12 +431,12 @@

Stream Collapsing

Header Forwarding

- When proxying to upstream providers, aimock forwards every header from - the original request except hop-by-hop headers (per RFC 2616 §13.5.1) and headers the - upstream HTTP client must set from the target URL or body. This pass-through behavior (in - place since v1.6.1) means custom gateway headers, openai-organization, - anthropic-version, and any other provider-specific auth or routing header all - reach the upstream as-is. + When proxying to upstream providers, aimock forwards the original request's headers except + for a small, fixed strip list: hop-by-hop headers (per RFC 2616 §13.5.1), headers the + upstream HTTP client must set from the target URL or body, and aimock's own mock-internal + control headers. Everything else passes through — custom gateway headers, + openai-organization, anthropic-version, and any other + provider-specific auth or routing header all reach the upstream as-is.

The following headers are stripped before proxying:

- Auth headers are never saved in recorded fixtures. The fixture only - contains the match criteria (derived from the last user message) and the response content. + Auth headers are never saved in recorded fixtures. The fixture contains + the match criteria (derived from the last user message) and the response content — plus, + when applicable, a metadata block (drift-detection hashes, below) and a + recordedTimings block (streaming frame timings) — never the request headers.

Strict Mode

- When --strict is enabled, unmatched requests that cannot be proxied (no - upstream configured for that provider) return 503 Service Unavailable - instead of the default 404. This is useful for CI environments where you want to catch - unexpected API calls. + When --strict is enabled, an unmatched request returns + 503 Service Unavailable before any proxy attempt — even + when an upstream is configured for that provider, nothing is forwarded and nothing is + recorded. Strict mode wins over record mode. This is useful for CI environments where you + want to catch unexpected API calls instead of silently recording new fixtures.

Fixture Auto-Generation

@@ -464,11 +486,17 @@

Fixture Auto-Generation

Recorded fixture file json
-
// fixtures/recorded/openai-<YYYY-MM-DD>T<HH-MM-SS>-000Z-0.json
+          
// fixtures/recorded/openai-<YYYY-MM-DD>T<HH-MM-SS>-<ms>Z-<8-hex>.json (e.g. openai-2026-05-18T10-30-00-000Z-a1b2c3d4.json)
 {
   "fixtures": [
     {
-      "match": { "userMessage": "What is the weather?" },
+      "match": {
+        "userMessage": "What is the weather?",
+        "model": "gpt-4o",
+        "turnIndex": 0,
+        "hasToolResult": false
+      },
+      "metadata": { "systemHash": "a7f3c291" },
       "response": { "content": "I don't have real-time weather data..." }
     }
   ]
@@ -477,9 +505,13 @@ 

Fixture Auto-Generation

Match criteria are derived from the original request: the last user message becomes - userMessage, or for embedding requests, the input becomes - inputText. If no match criteria can be derived (e.g., empty messages), the - fixture is saved to disk with a warning but not registered in memory. + userMessage (or, for embedding requests, the input becomes + inputText), the normalized model becomes model, and for chat + requests the recorder also writes the multi-turn disambiguators + turnIndex (the number of assistant messages already in the conversation) and + hasToolResult (whether a tool-result message is present). If no match + criteria can be derived (e.g., empty messages), the fixture is saved to disk with a + warning but not registered in memory.

Model-Aware Recording

@@ -538,7 +570,7 @@

Model-Aware Recording

{
   "llm": {
     "record": {
-      "providers": { "openai": "sk-..." },
+      "providers": { "openai": "https://api.openai.com" },
       "recordFullModelVersion": true
     }
   }
@@ -691,7 +723,7 @@ 

Merge behavior on re-run

When you re-run a test, the new fixture is appended to the existing <provider>.json file rather than overwriting it. This preserves multi-turn conversations in a single file. If the existing file is corrupted (invalid - JSON), it is silently replaced. + JSON, or a non-array fixtures field), it is replaced with a logged warning.

Sending X-Test-Id from test frameworks

@@ -728,7 +760,8 @@

Fallback behavior

When no X-Test-Id header is present (or the value is __default__), recording falls back to the standard timestamp-based filename: - <provider>-<timestamp>-<uuid>.json. + <provider>-<timestamp>-<8-hex>.json (the suffix is the + first 8 hex characters of a random UUID).

Fixture Lifecycle

@@ -876,18 +909,15 @@

Recording Multi-Turn Conversationsstateless across turns. Every incoming request is treated as an independent unit: aimock derives fixture match criteria from a single request at a time, and it doesn’t remember or hash prior turns of - the same test session. Two post-record remedies handle the two flavors of collision: add - toolCallId for tool-round follow-ups (covered on - the Multi-Turn Conversations page), or add - sequenceIndex for the same user prompt repeating (covered below). -

- -

- Specifically, for chat requests the recorder reads only the - last user message of the request and saves it as - match.userMessage. For embedding requests it uses the input text. There is no - history-based keying — unlike VCR-style recorders that fingerprint the full request - body, aimock keys fixtures purely on the last user message. + the same test session. For chat requests the derived key combines the + last user message (match.userMessage), the normalized + model, and two shape disambiguators read off the request itself: + turnIndex (how many assistant messages the conversation already contains) and + hasToolResult (whether a tool-result message is present). Embedding requests + key on the input text instead. There is no history-based fingerprinting of the full + request body — but because a growing conversation carries a growing + turnIndex, successive turns that happen to end with the same user message + still record as distinct fixtures.

@@ -899,41 +929,49 @@

Recording Multi-Turn Conversationsif (request.embeddingInput) { return { inputText: request.embeddingInput }; } - // Chat/multimedia — key on the LAST user message only + // Chat — key on the LAST user message, the normalized model, and the + // multi-turn disambiguators derived from the request shape const lastUser = getLastMessageByRole(request.messages, "user"); - const match = { userMessage: getTextContent(lastUser.content) }; + const match = { + userMessage: getTextContent(lastUser.content), + model: normalizeModelName(request.model), + turnIndex: request.messages.filter((m) => m.role === "assistant").length, + hasToolResult: request.messages.some((m) => m.role === "tool"), + }; // Capture context from X-AIMock-Context header if present if (request._context) match.context = request._context; return match; }

-

Consequence: identical final prompts collide

+

What still collides: byte-identical repeats

- Two turns of the same conversation that happen to end with the same user message produce - two fixture entries that share the same match.userMessage. On replay, the + Because turnIndex and hasToolResult come from the request shape, + shadowing only happens when the same request shape repeats — e.g. a test + that replays the identical conversation twice (retries, re-runs, or a loop that re-sends + the same turn). Those produce fixture entries with identical match keys; on replay, the router picks the first fixture that matches (first-wins by file load - order), and the second fixture is effectively shadowed. This matters whenever a test says - “continue”, “yes”, “retry”, or any other short prompt - twice in a row. + order) and the later entries are shadowed. For such true repeats, add + sequenceIndex (0, + 1, …) post-record to differentiate them by call order.

Recommended workflow

  1. Run the test once under --record and let aimock capture one fixture per - turn as usual. + turn — the recorded turnIndex/hasToolResult keys keep + ordinary multi-turn flows (including tool rounds) apart automatically.
  2. - Review the recorded fixtures. For turns whose purpose is answering a tool call, rewrite - the match to key on - toolCallId instead of - userMessage — this is the canonical tool-round idiom. + Review the recorded fixtures. For turns whose purpose is answering a tool call, you can + additionally key on + toolCallId — the canonical tool-round idiom + when you want the match tied to a specific call rather than a turn position.
  3. - For genuine repeats of the same user prompt, add - sequenceIndex (0, - 1, …) to each fixture to differentiate them by call order. + For genuine byte-identical repeats of the same turn, add + sequenceIndex to each fixture.
  4. Move the hand-edited fixtures into your organized @@ -942,17 +980,6 @@

    Recommended workflow

-

Gotcha: shadowed repeats

-

- If you record a conversation that sends “continue” twice, you will get two - fixtures with identical match.userMessage: "continue". On replay, the second - one will never fire until you add sequenceIndex post-record. See - Sequential / Stateful Responses for the mechanics. For - tool-round conversations where each post-tool turn carries a distinct - tool_call_id, prefer keying by toolCallId — see - Multi-Turn Conversations. -

-

Cross-Language Testing

The Docker image serves any language that speaks HTTP. Point your client at the mock diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 00032498..800a9350 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -762,3 +762,149 @@ describe.skipIf(!CLI_AVAILABLE)("CLI: --proxy-only with URL-only --fixtures", () } }); }); + +/* ================================================================== */ +/* --provider-openrouter (OpenRouter video record upstream) */ +/* ================================================================== */ + +describe.skipIf(!CLI_AVAILABLE)("CLI: --provider-openrouter", () => { + it("--help lists --provider-openrouter", async () => { + const { stdout, code } = await runCli(["--help"]); + expect(stdout).toContain("--provider-openrouter"); + expect(code).toBe(0); + }); + + it("--record --provider-openrouter satisfies the provider gate and boots", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + const child = spawnCli([ + "--record", + "--provider-openrouter", + "http://127.0.0.1:59997", + "--fixtures", + tmp, + "--port", + "0", + ]); + await child.waitForOutput(/listening on/i, 8000); + expect(child.stderr()).not.toMatch(/requires at least one --provider-\* flag/); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe.skipIf(!CLI_AVAILABLE)("CLI: timeout flags without record/proxy-only", () => { + it("warns when --upstream-timeout-ms is passed without --record/--proxy-only", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + const child = spawnCli(["--upstream-timeout-ms", "5000", "--fixtures", tmp, "--port", "0"]); + await child.waitForOutput(/upstream-timeout-ms.*--record/i, 8000); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("warns when --body-timeout-ms is passed without --record/--proxy-only", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + const child = spawnCli(["--body-timeout-ms", "5000", "--fixtures", tmp, "--port", "0"]); + await child.waitForOutput(/body-timeout-ms.*--record/i, 8000); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("warns when a --provider-* flag is passed without --record/--proxy-only", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + // Same parsed-then-dropped class as the timeout flags: a provider URL + // without --record/--proxy-only configures nothing. + const child = spawnCli([ + "--provider-openrouter", + "http://127.0.0.1:59995", + "--fixtures", + tmp, + "--port", + "0", + ]); + await child.waitForOutput(/provider-openrouter.*--record/i, 8000); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("does not warn when a --provider-* flag accompanies --record", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + const child = spawnCli([ + "--record", + "--provider-openrouter", + "http://127.0.0.1:59994", + "--fixtures", + tmp, + "--port", + "0", + ]); + await child.waitForOutput(/listening on/i, 8000); + // Absence asserted with the SAME pattern the positive test uses. + expect(child.stderr() + child.stdout()).not.toMatch(/provider-openrouter.*--record/i); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("does not warn when the timeout flags accompany --record", async () => { + const tmp = makeTmpDir(); + try { + writeFixture(tmp, "fx.json"); + const child = spawnCli([ + "--record", + "--provider-openrouter", + "http://127.0.0.1:59996", + "--upstream-timeout-ms", + "5000", + "--fixtures", + tmp, + "--port", + "0", + ]); + await child.waitForOutput(/listening on/i, 8000); + // Absence asserted with the SAME pattern family the positive tests + // use, so a reworded warn cannot silently pass this negative check. + expect(child.stderr() + child.stdout()).not.toMatch(/upstream-timeout-ms.*--record/i); + expect(child.stderr() + child.stdout()).not.toMatch(/body-timeout-ms.*--record/i); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.cp.on("close", () => resolve()); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/fal-audio.test.ts b/src/__tests__/fal-audio.test.ts index cc33aeb5..73300bda 100644 --- a/src/__tests__/fal-audio.test.ts +++ b/src/__tests__/fal-audio.test.ts @@ -1,4 +1,8 @@ import { describe, test, expect, afterEach } from "vitest"; +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { LLMock } from "../llmock.js"; describe("fal.ai audio queue", () => { @@ -270,3 +274,85 @@ describe("fal.ai audio queue", () => { expect(bLookup.status).toBe(200); }); }); + +describe("fal.ai audio queue — record walk (round 5)", () => { + let mock: LLMock; + let upstream: { url: string; close: () => Promise } | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("a persist failure on the legacy audio queue-walk sets X-AIMock-Record-Error", async () => { + // Parity with fal.ts's queue-walk record path (and the generic recorder): + // the synthesized envelope's headers have not been sent when + // persistFixture fails, so the failure can ride the response. + let selfUrl = "http://stub"; + upstream = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url ?? "/", selfUrl); + const send = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (req.method === "POST") { + send(200, { + request_id: "aud-1", + status_url: `${selfUrl}/fal/queue/requests/aud-1/status`, + response_url: `${selfUrl}/fal/queue/requests/aud-1`, + }); + return; + } + if (url.pathname.endsWith("/status")) { + send(200, { status: "COMPLETED", request_id: "aud-1" }); + return; + } + send(200, { audio: { url: "https://example.com/unsaveable.mp3" } }); + }); + }); + // A listen failure must reject instead of leaving the returned promise + // pending forever. + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-audio-persistfail-")); + const blockerFile = path.join(tmpDir, "not-a-dir"); + fs.writeFileSync(blockerFile, "in the way"); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: upstream.url }, + fixturePath: blockerFile, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/queue/submit/fal-ai/stable-audio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "unsaveable loop" }), + }); + expect(submit.status).toBe(200); + expect(submit.headers.get("x-aimock-record-error")).toBeTruthy(); + await submit.arrayBuffer(); + }); +}); diff --git a/src/__tests__/fal.test.ts b/src/__tests__/fal.test.ts index 0788d35c..6aa40e0b 100644 --- a/src/__tests__/fal.test.ts +++ b/src/__tests__/fal.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect, afterEach } from "vitest"; +import { describe, test, expect, afterEach, vi } from "vitest"; import * as http from "node:http"; +import * as net from "node:net"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -214,15 +215,17 @@ function startFalQueueUpstream(opts: { url: string; close: () => Promise; counts: { submit: number; status: number; result: number }; + lastHeaders: { submit?: http.IncomingHttpHeaders }; }> { const upstreamRequestId = opts.upstreamRequestId ?? "upstream-req-id"; const pollsBeforeCompleted = opts.pollsBeforeCompleted ?? 2; const counts = { submit: 0, status: 0, result: 0 }; + const lastHeaders: { submit?: http.IncomingHttpHeaders } = {}; const statusPolls = new Map(); const statusRe = /^\/(.+)\/requests\/([^/]+)\/status$/; const resultRe = /^\/(.+)\/requests\/([^/]+)$/; - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let selfUrl = "http://stub"; const server = http.createServer((req, res) => { const chunks: Buffer[] = []; @@ -257,6 +260,7 @@ function startFalQueueUpstream(opts: { } if (req.method === "POST") { counts.submit++; + lastHeaders.submit = req.headers; const modelPath = url.pathname.replace(/^\/+/, ""); const base = `${selfUrl}/${modelPath}/requests/${upstreamRequestId}`; send(200, { @@ -272,12 +276,16 @@ function startFalQueueUpstream(opts: { send(404, { error: { message: "stub: unhandled", path: url.pathname } }); }); }); + // A listen failure must reject instead of leaving the returned promise + // pending forever. + server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as { port: number }; selfUrl = `http://127.0.0.1:${port}`; resolve({ url: selfUrl, counts, + lastHeaders, close: () => new Promise((r) => { server.close(() => r()); @@ -290,13 +298,7 @@ function startFalQueueUpstream(opts: { describe("fal.ai general handler — record and replay", () => { let mock: LLMock; let upstream: { url: string; close: () => Promise } | undefined; - let queueUpstream: - | { - url: string; - close: () => Promise; - counts: { submit: number; status: number; result: number }; - } - | undefined; + let queueUpstream: Awaited> | undefined; let tmpDir: string | undefined; afterEach(async () => { @@ -376,6 +378,50 @@ describe("fal.ai general handler — record and replay", () => { expect(recorded.fixtures[0].response.json).toEqual(FINAL_BODY); }); + test("mock-internal headers never reach the upstream on the recorded queue walk", async () => { + // CHANGELOG/docs claim x-test-id / x-aimock-strict / x-aimock-context / + // x-aimock-chaos-* are stripped "on every provider proxy path" — pin the + // fal queue walk (buildForwardHeaders) to that claim. + queueUpstream = await startFalQueueUpstream({ + finalBody: { images: [{ url: "https://example.com/hdr.png" }] }, + pollsBeforeCompleted: 1, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-queue-hdrs-")); + mock = new LLMock({ + port: 0, + record: { + providers: { fal: queueUpstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-fal-target-host": "queue.fal.run", + Authorization: "Key fal-secret", + "X-Test-Id": "fal-hdr-strip", + "X-AIMock-Strict": "false", + "X-AIMock-Context": "ctx-strip", + "X-AIMock-Chaos-Drop": "0", + }, + body: JSON.stringify({ input: { prompt: "a cat" } }), + }); + expect(submit.status).toBe(200); + + const captured = queueUpstream.lastHeaders.submit; + expect(captured).toBeDefined(); + expect(captured!["x-test-id"]).toBeUndefined(); + expect(captured!["x-aimock-strict"]).toBeUndefined(); + expect(captured!["x-aimock-context"]).toBeUndefined(); + expect(captured!["x-aimock-chaos-drop"]).toBeUndefined(); + // Auth still forwarded. + expect(captured!.authorization).toBe("Key fal-secret"); + }); + test("replays from in-memory fixture on second identical request without a second queue walk", async () => { queueUpstream = await startFalQueueUpstream({ finalBody: { images: [{ url: "https://example.com/replay.png" }] }, @@ -428,19 +474,24 @@ describe("fal.ai general handler — record and replay", () => { // Upstream returns a submit envelope, but status calls 500. Recorder must // give up cleanly: client sees 502, no fixture is persisted (a partial // fixture would shadow real requests on the next run). - upstream = await new Promise((resolve) => { + // The envelope URLs must use the server's REAL origin (selfUrl), not a + // literal placeholder host: walkFalQueue adopts same-origin envelope URLs, + // so a bogus host would fail on DNS/connect instead of exercising the + // intended status-500 branch (round-4 B8). + let selfUrl = "http://stub"; + upstream = await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => { - const url = new URL(req.url ?? "/", "http://stub"); + const url = new URL(req.url ?? "/", selfUrl); if (req.method === "POST" && !url.pathname.includes("/requests/")) { res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ request_id: "x", - status_url: `http://stub${url.pathname}/requests/x/status`, - response_url: `http://stub${url.pathname}/requests/x`, + status_url: `${selfUrl}${url.pathname}/requests/x/status`, + response_url: `${selfUrl}${url.pathname}/requests/x`, }), ); return; @@ -449,10 +500,12 @@ describe("fal.ai general handler — record and replay", () => { res.end(JSON.stringify({ error: { message: "upstream broke" } })); }); }); + server.once("error", reject); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; resolve({ - url: `http://127.0.0.1:${port}`, + url: selfUrl, close: () => new Promise((r) => { server.close(() => r()); @@ -485,6 +538,350 @@ describe("fal.ai general handler — record and replay", () => { const falFixtures = files.filter((f) => f.startsWith("fal-") && f.endsWith(".json")); expect(falFixtures.length).toBe(0); }); + + test("A6: envelope-nominated off-origin queue URLs are never followed with the client's credentials", async () => { + // Round-4 A6 (red-green): the same-origin policy the OpenRouter video + // proxy applies to envelope polling_urls now gates the fal queue walk — + // an envelope nominating a foreign host must not receive the client's + // Authorization; the walk falls back to the constructed canonical paths + // on the configured upstream origin (with a warn). + const FINAL_BODY = { images: [{ url: "https://example.com/canonical.png" }] }; + const evilRequests: { path: string; auth?: string }[] = []; + const evil = await new Promise<{ url: string; close: () => Promise }>( + (resolve, reject) => { + const server = http.createServer((req, res) => { + evilRequests.push({ path: req.url ?? "", auth: req.headers.authorization }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "COMPLETED", request_id: "evil" })); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }, + ); + let warnSpy: ReturnType | undefined; + try { + const evilUrl = evil.url; + // Real upstream: the submit envelope nominates the EVIL host; the + // constructed canonical paths on this origin serve the true lifecycle. + let selfUrl = "http://stub"; + upstream = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url ?? "/", selfUrl); + const send = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (req.method === "POST") { + send(200, { + request_id: "r-a6", + status_url: `${evilUrl}/hijacked/requests/r-a6/status`, + response_url: `${evilUrl}/hijacked/requests/r-a6`, + status: "IN_QUEUE", + }); + return; + } + if (url.pathname.endsWith("/status")) { + send(200, { status: "COMPLETED", request_id: "r-a6" }); + return; + } + send(200, FINAL_BODY); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-a6-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { fal: upstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-fal-target-host": "queue.fal.run", + Authorization: "Key fal-secret", + }, + body: JSON.stringify({ input: { prompt: "a hijack" } }), + }); + expect(submit.status).toBe(200); + + // The evil host never saw a single request — let alone the credential. + expect(evilRequests).toHaveLength(0); + // The walk warned about the off-origin envelope URLs... + expect( + warnSpy.mock.calls.some((c) => c.join(" ").includes("differs from the upstream origin")), + ).toBe(true); + // ...and recorded the CANONICAL final body from the real upstream. + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const recorded = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect(recorded.fixtures[0].response.json).toEqual(FINAL_BODY); + } finally { + // Restore in the finally: a failing assertion above must not leak the + // console.warn stub into later tests. + warnSpy?.mockRestore(); + await evil.close(); + } + }); + + test("B5: a persist failure on the queue-walk record sets X-AIMock-Record-Error on the envelope", async () => { + // Parity with the generic recorder and the OpenRouter failed branch: the + // envelope's headers have not been sent when persistFixture fails, so the + // failure can (and now does) ride the response. + queueUpstream = await startFalQueueUpstream({ + finalBody: { images: [{ url: "https://example.com/unsaveable.png" }] }, + pollsBeforeCompleted: 1, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-persistfail-")); + const blockerFile = path.join(tmpDir, "not-a-dir"); + fs.writeFileSync(blockerFile, "in the way"); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: queueUpstream.url }, + fixturePath: blockerFile, + fal: { pollIntervalMs: 5, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "unsaveable" } }), + }); + expect(submit.status).toBe(200); + expect(submit.headers.get("x-aimock-record-error")).toBeTruthy(); + await submit.arrayBuffer(); + }); + + test("a hung upstream status poll is bounded by record.upstreamTimeoutMs (502, no hang)", async () => { + // The walk-level timeoutMs only fires BETWEEN polls — a status fetch + // whose upstream accepts the request and never responds must be bounded + // by the per-fetch upstreamTimeoutMs, surfacing through the walk's + // existing 502/no-fixture failure handling. + let selfUrl = "http://stub"; + const sockets = new Set(); + upstream = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url ?? "/", selfUrl); + if (req.method === "POST" && !url.pathname.includes("/requests/")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + request_id: "x", + status_url: `${selfUrl}${url.pathname}/requests/x/status`, + response_url: `${selfUrl}${url.pathname}/requests/x`, + }), + ); + return; + } + // status/result: accept the request and never respond. + }); + }); + server.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => + new Promise((r) => { + for (const s of sockets) s.destroy(); + server.close(() => r()); + }), + }); + }); + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-hung-status-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: upstream.url }, + fixturePath: tmpDir, + upstreamTimeoutMs: 300, + // Walk budget far above the per-fetch timeout: only the AbortSignal + // on the status fetch can produce the bounded 502 below. + fal: { pollIntervalMs: 5, timeoutMs: 60_000 }, + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "a hung poll" } }), + }); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error.type).toBe("proxy_error"); + const files = fs.existsSync(tmpDir) ? fs.readdirSync(tmpDir) : []; + expect(files.filter((f) => f.endsWith(".json"))).toHaveLength(0); + }, 10_000); + + test("pollIntervalMs: 0 completes the walk instead of timing out immediately", async () => { + // A zero poll interval used to satisfy the `sleep <= 0` timeout check on + // the FIRST non-terminal poll, producing a spurious "Queue walk timed + // out" with the whole budget left — the walk must only time out when the + // deadline is actually exhausted. + const FINAL_BODY = { images: [{ url: "https://mock.fal.media/files/zero-interval.png" }] }; + queueUpstream = await startFalQueueUpstream({ + finalBody: FINAL_BODY, + pollsBeforeCompleted: 2, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-zero-interval-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: queueUpstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 0, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "zero interval" } }), + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const recorded = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect(recorded.fixtures[0].response.json).toEqual(FINAL_BODY); + }); + + test("a null submit body fails the walk with a clean message, not a TypeError (round 6)", async () => { + // JSON.parse("null") passes parseJsonOrThrow — the walk must reject the + // non-object shape itself instead of TypeError-ing on env.request_id. + let selfUrl = "http://stub"; + upstream = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end("null"); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-null-submit-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: upstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 5, timeoutMs: 2000 }, + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "null envelope" } }), + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: { message: string; type: string } }; + expect(body.error.type).toBe("proxy_error"); + expect(body.error.message).toContain("Submit response is not a JSON object"); + }); + + test("a client that disconnects during the queue walk leaves no journal entry (round 6)", async () => { + // The walk is a multi-second await — a client gone by the time it + // finishes must get neither a write nor a journal entry (the + // openrouter-video file convention). The fixture itself still persists: + // the captured upstream response is valuable regardless. + queueUpstream = await startFalQueueUpstream({ + finalBody: { images: [{ url: "https://example.com/gone-client.png" }] }, + pollsBeforeCompleted: 3, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-fal-disconnect-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { fal: queueUpstream.url }, + fixturePath: tmpDir, + fal: { pollIntervalMs: 200, timeoutMs: 5000 }, + }, + }); + await mock.start(); + + const ac = new AbortController(); + const pending = fetch(`${mock.url}/fal/fal-ai/flux/dev`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-fal-target-host": "queue.fal.run" }, + body: JSON.stringify({ input: { prompt: "client walks away" } }), + signal: ac.signal, + }); + setTimeout(() => ac.abort(), 100); + await expect(pending).rejects.toThrow(); + + // The walk continues server-side — wait for it to fetch the result, then + // give the handler's post-walk steps a beat to run. + const deadline = Date.now() + 5000; + while (queueUpstream.counts.result < 1) { + if (Date.now() > deadline) throw new Error("queue walk never completed"); + await new Promise((r) => setTimeout(r, 20)); + } + await new Promise((r) => setTimeout(r, 150)); + + // No journal entry for the aborted submit — the 200 never left. + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/fal/fal-ai/flux/dev"); + expect(entry).toBeUndefined(); + // The fixture still landed (deliberate: the walk completed upstream-side). + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + }); }); describe("fal.ai general handler — typed helpers + polling progression", () => { diff --git a/src/__tests__/helpers/refusing-upstream.ts b/src/__tests__/helpers/refusing-upstream.ts new file mode 100644 index 00000000..03a302cb --- /dev/null +++ b/src/__tests__/helpers/refusing-upstream.ts @@ -0,0 +1,27 @@ +import * as net from "node:net"; + +/** + * An upstream URL that deterministically refuses every request: the port is + * HELD for the lifetime of the suite by a live server that destroys each + * connection the moment it arrives. Unlike the old bind-then-release pattern + * (reserve a port, close the listener, hand out the URL), there is no TOCTOU + * window in which another process — or a parallel test file doing the same + * dance — could re-bind the port and start answering. Callers must close the + * returned server in `afterAll`. + */ +export function startRefusingUpstream(): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => socket.destroy()); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((r) => { + server.close(() => r()); + }), + }); + }); + }); +} diff --git a/src/__tests__/openrouter-video-record.test.ts b/src/__tests__/openrouter-video-record.test.ts new file mode 100644 index 00000000..02159be2 --- /dev/null +++ b/src/__tests__/openrouter-video-record.test.ts @@ -0,0 +1,4647 @@ +import { describe, test, expect, afterAll, afterEach, vi } from "vitest"; +import * as http from "node:http"; +import * as net from "node:net"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { LLMock } from "../llmock.js"; +import type { RecordConfig } from "../types.js"; +import { startRefusingUpstream } from "./helpers/refusing-upstream.js"; + +interface OpenRouterVideoUpstreamOptions { + /** Terminal status the poll endpoint converges on. Default: "completed". */ + finalStatus?: "completed" | "failed" | "cancelled" | "expired"; + /** Non-terminal polls served before the terminal status. Default: 0. */ + pollsBeforeCompleted?: number; + /** Bytes served by the content endpoint. Default: "stub video bytes". */ + videoBytes?: Buffer; + /** usage.cost reported on completion. Default: 0.25. */ + cost?: number; + /** error reported on failure. Default: "upstream generation failed". */ + error?: string; + /** Origin used in unsigned_urls (off-origin capture tests). Default: self. */ + unsignedUrlOrigin?: string; + /** Force the content endpoint to reply with this status (after auth). */ + contentStatus?: number; + /** With a non-200 contentStatus: write the head + a partial body, never end. */ + stallContentBody?: boolean; + /** Force the STATUS endpoint to reply with this HTTP status (JSON error body). */ + statusHttpStatus?: number; + /** Force the SUBMIT endpoint to reply with this HTTP status (JSON error body). */ + submitHttpStatus?: number; + /** Delay (ms) before the submit endpoint responds (mid-submit reset tests). */ + submitDelayMs?: number; + /** Accept the submit request and never respond (socket hang). */ + hangOnSubmit?: boolean; + /** Accept the status poll and never respond (socket hang). */ + hangOnStatus?: boolean; + /** Accept the content request and never respond (socket hang). */ + hangOnContent?: boolean; + /** Accept the models request and never respond (socket hang). */ + hangOnModels?: boolean; + /** Serve content chunked, without a Content-Length header. */ + omitContentLength?: boolean; + /** Delay (ms) before the content endpoint responds (capture-window tests). */ + contentDelayMs?: number; + /** Delay (ms) before the status endpoint responds (client-abort tests). */ + statusDelayMs?: number; + /** Full override of the TERMINAL status poll body. */ + statusBody?: (selfUrl: string, jobId: string) => Record; +} + +interface OpenRouterVideoUpstream { + url: string; + close: () => Promise; + /** `contentServed` counts content responses fully WRITTEN (vs. `content`, + * which counts arrivals) — capture-window tests use the gap between the + * two to prove a content fetch is still in flight. */ + counts: { + submit: number; + status: number; + content: number; + models: number; + contentServed: number; + }; + lastHeaders: { + submit?: http.IncomingHttpHeaders; + status?: http.IncomingHttpHeaders; + content?: http.IncomingHttpHeaders; + models?: http.IncomingHttpHeaders; + }; +} + +const UPSTREAM_JOB_ID = "or-upstream-job-1"; + +function startOpenRouterVideoUpstream( + opts: OpenRouterVideoUpstreamOptions, +): Promise { + const finalStatus = opts.finalStatus ?? "completed"; + const pollsBeforeCompleted = opts.pollsBeforeCompleted ?? 0; + const videoBytes = opts.videoBytes ?? Buffer.from("stub video bytes"); + const cost = opts.cost ?? 0.25; + const error = opts.error ?? "upstream generation failed"; + const counts = { submit: 0, status: 0, content: 0, models: 0, contentServed: 0 }; + const lastHeaders: OpenRouterVideoUpstream["lastHeaders"] = {}; + const statusPolls = new Map(); + const contentRe = /^\/api\/v1\/videos\/([^/]+)\/content$/; + const statusRe = /^\/api\/v1\/videos\/([^/]+)$/; + + return new Promise((resolve, reject) => { + let selfUrl = "http://stub"; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url ?? "/", selfUrl); + const sendJson = (status: number, body: unknown) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + if (req.method === "POST" && url.pathname === "/api/v1/videos") { + counts.submit++; + lastHeaders.submit = req.headers; + if (opts.hangOnSubmit) return; // accept, never respond + const serveSubmit = (): void => { + if (opts.submitHttpStatus !== undefined && opts.submitHttpStatus !== 200) { + sendJson(opts.submitHttpStatus, { + error: { message: "stub submit rejected", code: opts.submitHttpStatus }, + }); + return; + } + sendJson(200, { + id: UPSTREAM_JOB_ID, + polling_url: `${selfUrl}/api/v1/videos/${UPSTREAM_JOB_ID}`, + status: "pending", + }); + }; + if (opts.submitDelayMs) { + setTimeout(serveSubmit, opts.submitDelayMs); + } else { + serveSubmit(); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/v1/videos/models") { + counts.models++; + lastHeaders.models = req.headers; + if (opts.hangOnModels) return; // accept, never respond + // "upstream/model-1" can never come out of aimock's fixture-driven + // synthesis — a verbatim relay is unambiguous. + sendJson(200, { data: [{ id: "upstream/model-1", name: "upstream/model-1" }] }); + return; + } + + const contentMatch = url.pathname.match(contentRe); + if (req.method === "GET" && contentMatch) { + counts.content++; + lastHeaders.content = req.headers; + if (opts.hangOnContent) return; // accept, never respond + // Returns true when the response was fully WRITTEN (res.end) — the + // stallContentBody branch never ends, so it must not count as + // served (contentServed's invariant). + const serveContent = (): boolean => { + const auth = req.headers.authorization; + if (!auth || !/^bearer\s+\S/i.test(auth)) { + sendJson(401, { error: { message: "No auth credentials found", code: 401 } }); + return true; + } + if (opts.contentStatus !== undefined && opts.contentStatus !== 200) { + res.writeHead(opts.contentStatus, { "Content-Type": "text/plain" }); + if (opts.stallContentBody) { + res.write("partial error body"); // never end — mid-body stall + return false; + } + res.end("stub content error"); + return true; + } + if (opts.omitContentLength) { + // Explicit chunked transfer keeps Node from auto-computing a + // Content-Length for the single-buffer body. + res.writeHead(200, { "Content-Type": "video/mp4", "Transfer-Encoding": "chunked" }); + res.end(videoBytes); + return true; + } + res.writeHead(200, { + "Content-Type": "video/mp4", + "Content-Length": videoBytes.length, + }); + res.end(videoBytes); + return true; + }; + const runContent = (): void => { + if (serveContent()) counts.contentServed++; + }; + if (opts.contentDelayMs) { + setTimeout(runContent, opts.contentDelayMs); + } else { + runContent(); + } + return; + } + + const statusMatch = url.pathname.match(statusRe); + if (req.method === "GET" && statusMatch) { + counts.status++; + lastHeaders.status = req.headers; + if (opts.hangOnStatus) return; // accept, never respond + const serveStatus = (): void => { + if (opts.statusHttpStatus !== undefined && opts.statusHttpStatus !== 200) { + sendJson(opts.statusHttpStatus, { + error: { message: "stub poll rejected", code: opts.statusHttpStatus }, + }); + return; + } + const jobId = statusMatch[1]; + const n = (statusPolls.get(jobId) ?? 0) + 1; + statusPolls.set(jobId, n); + if (n <= pollsBeforeCompleted) { + sendJson(200, { id: jobId, status: n === 1 ? "pending" : "in_progress" }); + return; + } + if (opts.statusBody) { + sendJson(200, opts.statusBody(selfUrl, jobId)); + return; + } + if (finalStatus === "completed") { + const origin = opts.unsignedUrlOrigin ?? selfUrl; + sendJson(200, { + id: jobId, + status: "completed", + unsigned_urls: [`${origin}/api/v1/videos/${jobId}/content?index=0`], + usage: { cost }, + }); + return; + } + if (finalStatus === "failed") { + sendJson(200, { id: jobId, status: "failed", error }); + return; + } + sendJson(200, { id: jobId, status: finalStatus }); + }; + if (opts.statusDelayMs) { + setTimeout(serveStatus, opts.statusDelayMs); + } else { + serveStatus(); + } + return; + } + + sendJson(404, { error: { message: "stub: unhandled", path: url.pathname } }); + }); + }); + // A listen failure (port exhaustion, EPERM sandboxes) must reject instead + // of leaving the returned promise pending forever (mirrors the + // refusing-upstream helper). + server.once("error", reject); + // Track sockets so close() can tear down hung (never-responded) requests + // instead of waiting on them forever. + const sockets = new Set(); + server.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + counts, + lastHeaders, + close: () => + new Promise((r) => { + for (const s of sockets) s.destroy(); + server.close(() => r()); + }), + }); + }); + }); +} + +// ─── Task 2: protocol-aware stub upstream ─────────────────────────────────── +// Mirrors startFalQueueUpstream (fal.test.ts): a real http.createServer on +// 127.0.0.1:0 implementing the four OpenRouter video endpoints — submit, +// status poll (with configurable progression), content download (Bearer- +// gated), and the models listing. Tracks per-endpoint call counts and the +// last-received headers so tests can assert what hit the wire (and with +// which auth) vs. what was served from aimock's own state. + +describe("startOpenRouterVideoUpstream (stub self-test)", () => { + let upstream: Awaited> | undefined; + + afterEach(async () => { + await upstream?.close(); + upstream = undefined; + }); + + test("submit returns a pending envelope, counts the call, captures headers", async () => { + upstream = await startOpenRouterVideoUpstream({}); + const res = await fetch(`${upstream.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-upstream" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "a sunset" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.id).toBe("or-upstream-job-1"); + expect(data.status).toBe("pending"); + expect(data.polling_url).toBe(`${upstream.url}/api/v1/videos/or-upstream-job-1`); + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-upstream"); + }); + + test("status polls advance pending → in_progress → completed with urls and cost", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 2, cost: 0.42 }); + const url = `${upstream.url}/api/v1/videos/or-upstream-job-1`; + expect((await (await fetch(url)).json()).status).toBe("pending"); + expect((await (await fetch(url)).json()).status).toBe("in_progress"); + const done = await (await fetch(url)).json(); + expect(done.status).toBe("completed"); + expect(done.unsigned_urls).toEqual([ + `${upstream.url}/api/v1/videos/or-upstream-job-1/content?index=0`, + ]); + expect(done.usage).toEqual({ cost: 0.42 }); + expect(upstream.counts.status).toBe(3); + }); + + test("failed final status carries the error", async () => { + upstream = await startOpenRouterVideoUpstream({ finalStatus: "failed", error: "nsfw" }); + const data = await (await fetch(`${upstream.url}/api/v1/videos/j1`)).json(); + expect(data.status).toBe("failed"); + expect(data.error).toBe("nsfw"); + expect(data.unsigned_urls).toBeUndefined(); + }); + + test("content 401s without Bearer, serves bytes with it, captures headers", async () => { + const bytes = Buffer.from("upstream stub video"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + const noAuth = await fetch(`${upstream.url}/api/v1/videos/j1/content?index=0`); + expect(noAuth.status).toBe(401); + await noAuth.arrayBuffer(); // consume — avoid leaking the undici body stream + const ok = await fetch(`${upstream.url}/api/v1/videos/j1/content?index=0`, { + headers: { Authorization: "Bearer sk-x" }, + }); + expect(ok.status).toBe(200); + expect(ok.headers.get("content-type")).toBe("video/mp4"); + expect(Buffer.from(await ok.arrayBuffer()).equals(bytes)).toBe(true); + expect(upstream.counts.content).toBe(2); + expect(upstream.lastHeaders.content?.authorization).toBe("Bearer sk-x"); + }); + + test("contentStatus forces a content-endpoint failure", async () => { + upstream = await startOpenRouterVideoUpstream({ contentStatus: 500 }); + const res = await fetch(`${upstream.url}/api/v1/videos/j1/content?index=0`, { + headers: { Authorization: "Bearer sk-x" }, + }); + expect(res.status).toBe(500); + await res.arrayBuffer(); // consume — avoid leaking the undici body stream + }); + + test("models endpoint serves a distinguishable listing and counts calls", async () => { + upstream = await startOpenRouterVideoUpstream({}); + const res = await fetch(`${upstream.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.map((m: { id: string }) => m.id)).toEqual(["upstream/model-1"]); + expect(upstream.counts.models).toBe(1); + }); + + test("unsignedUrlOrigin overrides the origin of unsigned_urls", async () => { + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: "http://cdn.example" }); + const done = await (await fetch(`${upstream.url}/api/v1/videos/j1`)).json(); + expect(done.status).toBe("completed"); + expect(done.unsigned_urls[0].startsWith("http://cdn.example/api/v1/videos/j1/content")).toBe( + true, + ); + }); +}); + +// ─── Task 1: record config surface ────────────────────────────────────────── + +describe("OpenRouter video record — config acceptance", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("LLMock accepts record.providers.openrouter and record.openRouterVideo", async () => { + mock = new LLMock({ + port: 0, + record: { + providers: { openrouter: "https://openrouter.ai" }, + openRouterVideo: { maxContentBytes: 1024 }, + }, + }); + await mock.start(); + expect(mock.url).toMatch(/^http:/); + }); + + test("record.openRouterVideo is optional alongside the provider", async () => { + mock = new LLMock({ + port: 0, + record: { providers: { openrouter: "https://openrouter.ai" } }, + }); + await mock.start(); + expect(mock.url).toMatch(/^http:/); + }); +}); + +// ─── Task 3: submit record path (live interactive proxy) ──────────────────── + +// Deterministically-failing upstream: the port is held by a live +// connection-destroying server for the whole suite (no TOCTOU re-bind window). +const refusingUpstream = await startRefusingUpstream(); +const UPSTREAM_DOWN_URL = refusingUpstream.url; +afterAll(() => refusingUpstream.close()); + +describe("OpenRouter video record — submit proxy", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-record-")); + } + + test("proxies an unmatched submit and returns a mock-rewritten envelope", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-test", + "X-Test-Id": "rec-a", + }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "record me" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + // Mock-rewritten: a fresh aimock jobId, never the upstream one. + expect(typeof data.id).toBe("string"); + expect(data.id.length).toBeGreaterThan(0); + expect(data.id).not.toBe("or-upstream-job-1"); + // polling_url points at the mock and carries the testId for header-less polls. + expect(data.polling_url).toBe(`${mock.url}/api/v1/videos/${data.id}?testId=rec-a`); + expect(data.status).toBe("pending"); + // Exactly one upstream submit; auth was forwarded. + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-test"); + + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/api/v1/videos"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(200); + expect(entry!.response.fixture).toBeNull(); + expect(entry!.response.source).toBe("proxy"); + }); + + test("strict mode wins over record: 503, nothing proxied upstream", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + strict: true, + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "no fixture" }), + }); + expect(res.status).toBe(503); + expect((await res.json()).error.code).toBe("no_fixture_match"); + expect(upstream.counts.submit).toBe(0); + }); + + test("record without an openrouter provider warns and falls through to 404", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openai: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "unrecorded prompt" }), + }); + expect(res.status).toBe(404); + expect( + warnSpy.mock.calls.some((c) => + c.join(" ").includes('No upstream URL configured for provider "openrouter"'), + ), + ).toBe(true); + }); + + test("upstream connection failure returns 502 proxy_error and journals source proxy", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "dead upstream" }), + }); + expect(res.status).toBe(502); + const data = await res.json(); + expect(data.error.type).toBe("proxy_error"); + + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/api/v1/videos"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(502); + expect(entry!.response.source).toBe("proxy"); + }); + + test("a matching fixture still replays without touching the upstream", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + mock.addFixture({ + match: { userMessage: "already recorded", endpoint: "video" }, + response: { video: { id: "vid_r", status: "completed", b64: "AAAA" } }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "already recorded" }), + }); + expect(res.status).toBe(200); + expect(upstream.counts.submit).toBe(0); + }); +}); + +// ─── Task 4: poll record path + eager capture ─────────────────────────────── + +interface PlainContentHostOptions { + /** Drip the body in chunks of this size (forces chunked transfer, no Content-Length). */ + chunkSize?: number; + /** Delay (ms) between dripped chunks. Default: 0 (immediate). */ + chunkDelayMs?: number; + /** Stop writing after this many bytes and go silent (mid-body stall). */ + stallAfterBytes?: number; +} + +/** Plain off-origin content host: serves bytes WITHOUT requiring auth and + * records the headers it received, so tests can assert the capture fetch + * dropped the client's Authorization for a foreign origin. With `chunkSize` + * set it drips the body progressively (chunked, no Content-Length) and can + * stall mid-body — the timeout-semantics tests drive both shapes. Tracks + * sockets so close() tears down stalled (never-ended) responses instead of + * waiting on them forever. */ +function startPlainContentHost( + bytes: Buffer, + opts: PlainContentHostOptions = {}, +): Promise<{ + url: string; + close: () => Promise; + lastHeaders: () => http.IncomingHttpHeaders | undefined; + /** Timestamp of the moment the LAST response finished writing (res.end), + * or undefined while one is still dripping — streaming-relay tests use it + * to prove the client saw headers before the upstream finished. */ + finishedAt: () => number | undefined; + /** Live connection count — relay-release tests assert it drops to zero. */ + openSockets: () => number; +}> { + let last: http.IncomingHttpHeaders | undefined; + let finished: number | undefined; + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + last = req.headers; + finished = undefined; + if (opts.chunkSize === undefined) { + res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": bytes.length }); + res.end(bytes); + finished = Date.now(); + return; + } + // Drip mode: progressive chunked writes, optionally stalling mid-body. + res.writeHead(200, { "Content-Type": "video/mp4", "Transfer-Encoding": "chunked" }); + let offset = 0; + const writeNext = (): void => { + if (res.destroyed) return; + if (opts.stallAfterBytes !== undefined && offset >= opts.stallAfterBytes) { + return; // go silent — never end the response + } + if (offset >= bytes.length) { + res.end(); + finished = Date.now(); + return; + } + const end = Math.min(offset + opts.chunkSize!, bytes.length); + res.write(bytes.subarray(offset, end)); + offset = end; + setTimeout(writeNext, opts.chunkDelayMs ?? 0); + }; + writeNext(); + }); + // A listen failure must reject instead of leaving the returned promise + // pending forever (mirrors startOpenRouterVideoUpstream). + server.once("error", reject); + const sockets = new Set(); + server.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + resolve({ + url: `http://127.0.0.1:${port}`, + lastHeaders: () => last, + finishedAt: () => finished, + openSockets: () => sockets.size, + close: () => + new Promise((r) => { + for (const s of sockets) s.destroy(); + server.close(() => r()); + }), + }); + }); + }); +} + +function readRecordedFixtureFiles(dir: string): { file: string; content: unknown }[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + file: f, + content: JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")), + })); +} + +/** + * Bounded retry until `cond` is truthy. The eager capture runs DETACHED from + * the completed poll (round-3 A5), so tests await fixture persistence — and + * any other capture side effect — through this deterministic signal instead + * of assuming it completed synchronously with the relay. + */ +async function waitUntil(cond: () => boolean, timeoutMs = 5000, intervalMs = 20): Promise { + // Deadline computed via performance.now, NOT Date.now: several suites fake + // Date (vi.useFakeTimers({ toFake: ["Date"] })) and jump the clock by tens + // of minutes — a Date-based deadline would expire instantly (or never). + // Those suites leave timers real, so the setTimeout interval below is safe. + const deadline = performance.now() + timeoutMs; + for (;;) { + if (cond()) return; + if (performance.now() > deadline) { + throw new Error(`waitUntil: condition not met within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +describe("OpenRouter video record — poll proxy and eager capture", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let contentHost: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await contentHost?.close(); + contentHost = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-poll-")); + } + + async function startRecordingMock(upstreamUrl: string): Promise { + tmpDir = makeTmpDir(); + const m = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir }, + }); + await m.start(); + return m; + } + + async function submitRecordJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + test("non-terminal polls are proxied 1:1 and relayed with the mock jobId", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 2 }); + mock = await startRecordingMock(upstream.url); + const envelope = await submitRecordJob(mock, "slow record"); + + const poll1 = await (await fetch(envelope.polling_url)).json(); + expect(poll1).toMatchObject({ id: envelope.id, status: "pending" }); + expect(upstream.counts.status).toBe(1); + + const poll2 = await (await fetch(envelope.polling_url)).json(); + expect(poll2).toMatchObject({ id: envelope.id, status: "in_progress" }); + expect(upstream.counts.status).toBe(2); + + // Proxied polls journal source "proxy". + const pollEntries = mock.journal + .getAll() + .filter((e) => e.method === "GET" && e.path.startsWith(`/api/v1/videos/${envelope.id}`)); + expect(pollEntries).toHaveLength(2); + for (const e of pollEntries) { + expect(e.response.status).toBe(200); + expect(e.response.source).toBe("proxy"); + } + }); + + test("completed poll captures the fixture eagerly and rewrites unsigned_urls", async () => { + const bytes = Buffer.from("recorded video payload"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, cost: 0.42 }); + mock = await startRecordingMock(upstream.url); + const envelope = await submitRecordJob(mock, "capture me"); + + const pollRes = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer poll-key" }, + }); + expect(pollRes.status).toBe(200); + const poll = await pollRes.json(); + expect(poll.id).toBe(envelope.id); + expect(poll.status).toBe("completed"); + // unsigned_urls rewritten to the mock; usage.cost passed through. + expect(poll.unsigned_urls).toEqual([ + `${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, + ]); + expect(poll.usage).toEqual({ cost: 0.42 }); + + // The capture runs detached from the relayed poll — await its + // deterministic completion signal (the persisted fixture file). + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + // Eager capture fetched the content server-side with the poller's Bearer + // (same-origin), without waiting for the client to download. + expect(upstream.counts.content).toBe(1); + expect(upstream.lastHeaders.content?.authorization).toBe("Bearer poll-key"); + + // Fixture file on disk with the EXACT documented shape. + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { fixtures: unknown[] }; + expect(saved.fixtures).toHaveLength(1); + expect(saved.fixtures[0]).toEqual({ + match: { + endpoint: "video", + userMessage: "capture me", + model: "bytedance/seedance-2.0", + }, + response: { + video: { + id: "or-upstream-job-1", + status: "completed", + b64: bytes.toString("base64"), + cost: 0.42, + }, + }, + }); + + // The mutated in-memory job serves the real bytes via the mock content URL. + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer poll-key" }, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + + // In-memory fixtures updated: a second identical submit replays without + // touching the upstream again. + const second = await submitRecordJob(mock!, "capture me"); + expect(upstream.counts.submit).toBe(1); + const secondPoll = await (await fetch(second.polling_url)).json(); + expect(secondPoll.status).toBe("completed"); + expect(upstream.counts.status).toBe(1); + }); + + test("post-capture polls of the captured terminal job no longer hit the upstream", async () => { + const bytes = Buffer.from("poll after done bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + mock = await startRecordingMock(upstream.url); + const envelope = await submitRecordJob(mock, "poll after done"); + + // Authed poll: the eager capture forwards the Bearer and succeeds. + await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).arrayBuffer(); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(upstream.counts.status).toBe(1); + expect(upstream.counts.content).toBe(1); + const files = readRecordedFixtureFiles(tmpDir!); + const saved = files[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + + const again = await (await fetch(envelope.polling_url)).json(); + expect(again.status).toBe("completed"); + expect(upstream.counts.status).toBe(1); // served from the mutated replay job + }); + + test("an auth-less completed poll fails the capture (upstream 401) without persisting — an authed retry captures", async () => { + // Round-4 B3 pin update: a capture-fetch failure used to persist a + // b64-less fixture and terminalize (placeholder forever). It now persists + // NOTHING and leaves the job a live proxy so the next completed poll + // retries the capture. + const bytes = Buffer.from("retry after auth bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const envelope = await submitRecordJob(mock, "auth-less poll"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // No Authorization on the poll → the capture fetch carries no Bearer and + // the upstream content endpoint 401s — the capture-failure path. + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => warnSpy.mock.calls.some((c) => c.join(" ").includes("capture failed"))); + // The failure warn names the upstream status AND carries a bounded body + // sample (round-6 pin) — a bare "Content 401" is not diagnosable. + const failLine = warnSpy.mock.calls + .map((c) => c.join(" ")) + .find((l) => l.includes("capture failed")); + expect(failLine).toContain("Content 401"); + expect(failLine).toContain("No auth credentials found"); + + // NOTHING persisted; the job stayed a live proxy (not terminalized). + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + + // The next completed poll — this time with auth — retries the capture + // and succeeds with the real bytes. + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-retry" } }) + ).json(); + expect(again.status).toBe("completed"); + expect(upstream.counts.status).toBe(2); // still proxying — job was never terminalized + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + const saved = readRecordedFixtureFiles(tmpDir!)[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + }); + + test("failed upstream job persists a failed fixture and relays the error", async () => { + upstream = await startOpenRouterVideoUpstream({ + finalStatus: "failed", + error: "content policy violation", + }); + mock = await startRecordingMock(upstream.url); + const envelope = await submitRecordJob(mock, "doomed record"); + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.id).toBe(envelope.id); + expect(poll.status).toBe("failed"); + expect(poll.error).toBe("content policy violation"); + + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { + fixtures: { response: { video: Record } }[]; + }; + expect(saved.fixtures[0].response).toEqual({ + video: { id: "or-upstream-job-1", status: "failed", error: "content policy violation" }, + }); + + // Terminal conversion: the next poll is served from memory. + const again = await (await fetch(envelope.polling_url)).json(); + expect(again.status).toBe("failed"); + expect(again.error).toBe("content policy violation"); + expect(upstream.counts.status).toBe(1); + }); + + test("off-origin unsigned_urls are fetched WITHOUT the client's auth, with a warn", async () => { + const bytes = Buffer.from("cdn-hosted video"); + contentHost = await startPlainContentHost(bytes); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const envelope = await submitRecordJob(mock, "cdn capture"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + // The capture fetch went to the off-origin host WITHOUT Authorization. + expect(contentHost.lastHeaders()).toBeDefined(); + expect(contentHost.lastHeaders()!.authorization).toBeUndefined(); + expect( + warnSpy.mock.calls.some((c) => { + const line = c.join(" "); + return line.includes("differs from the provider origin") && line.includes("Authorization"); + }), + ).toBe(true); + + // The bytes were still captured into the fixture. + const files = readRecordedFixtureFiles(tmpDir!); + const saved = files[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + }); + + test("a content-fetch 500 fails the capture without persisting; the next poll retries and captures", async () => { + // Round-4 B3 (red-green): a transient content-fetch failure must not + // poison the fixture set with a permanent b64-less placeholder. The job + // stays a live proxy; once the upstream serves bytes, the next completed + // poll captures the real fixture. + const bytes = Buffer.from("eventually served bytes"); + const stubOpts: Parameters[0] = { + contentStatus: 500, + videoBytes: bytes, + }; + upstream = await startOpenRouterVideoUpstream(stubOpts); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const envelope = await submitRecordJob(mock, "capture fails"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).json(); + expect(poll.status).toBe("completed"); + expect(poll.unsigned_urls).toEqual([ + `${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, + ]); + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("content capture failed")), + ); + + // NO fixture persisted (was: b64-less fixture + terminalized job). + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + + // The upstream recovers; the next completed poll retries the capture. + stubOpts.contentStatus = 200; + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).json(); + expect(again.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + const saved = readRecordedFixtureFiles(tmpDir!)[0].content as { + fixtures: { response: { video: { b64?: string; status: string } } }[]; + }; + expect(saved.fixtures[0].response.video.status).toBe("completed"); + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + + // The captured job now serves the REAL bytes (not a placeholder). + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer poll-key" }, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + }); + + test("a completed re-poll without unsigned_urls preserves the stash from the prior poll", async () => { + // Capture-entry stash guard (red-green): poll #1 completes WITH + // unsigned_urls but its capture fails (content 500), leaving the job a + // live proxy with a usable stash. Poll #2 completes WITHOUT unsigned_urls + // and re-enters the capture branch — that entry must not clobber the + // poll-#1 stash with undefined (the proxyOnly and capture-window siblings + // already guard this), or the content live-proxy 502s despite a + // known-good upstream URL. + const bytes = Buffer.from("stash survives the defective re-poll"); + const stubOpts: Parameters[0] = { + contentStatus: 500, + videoBytes: bytes, + }; + upstream = await startOpenRouterVideoUpstream(stubOpts); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const envelope = await submitRecordJob(mock, "stash survives re-poll"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("content capture failed")), + ); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + + // The upstream's content endpoint recovers, but the NEXT completed poll + // body is defective: completed with no unsigned_urls at all. The capture + // re-entry skips (nothing to fetch) and persists nothing. + stubOpts.contentStatus = 200; + stubOpts.statusBody = (_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + usage: { cost: 0.25 }, + }); + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer poll-key" } }) + ).json(); + expect(again.status).toBe("completed"); + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("completed without unsigned_urls")), + ); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + + // The content download must still live-proxy via the poll-#1 stash + // (today it 502s: the capture re-entry clobbered the stash with + // undefined). + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer poll-key" }, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + }); + + test("cancelled upstream status passes through, warns, and records no fixture", async () => { + upstream = await startOpenRouterVideoUpstream({ finalStatus: "cancelled" }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const envelope = await submitRecordJob(mock, "cancelled record"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.id).toBe(envelope.id); + expect(poll.status).toBe("cancelled"); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("not representable"))).toBe(true); + + // No fixture written; subsequent polls keep proxying live (1:1). + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const again = await (await fetch(envelope.polling_url)).json(); + expect(again.status).toBe("cancelled"); + expect(upstream.counts.status).toBe(2); + }); + + test("upstream poll failure returns 502 proxy_error journaled as proxy", async () => { + upstream = await startOpenRouterVideoUpstream({}); + mock = await startRecordingMock(upstream.url); + const envelope = await submitRecordJob(mock, "upstream dies"); + await upstream.close(); + upstream = undefined; + + const poll = await fetch(envelope.polling_url); + expect(poll.status).toBe(502); + expect((await poll.json()).error.type).toBe("proxy_error"); + const entry = mock.journal + .getAll() + .find((e) => e.method === "GET" && e.path.startsWith(`/api/v1/videos/${envelope.id}`)); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(502); + expect(entry!.response.source).toBe("proxy"); + }); +}); + +// ─── Task 5: full record → replay round trip ──────────────────────────────── + +describe("OpenRouter video record — round trip (record session then replay session)", () => { + let recordMock: LLMock | undefined; + let replayMock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await recordMock?.stop(); + recordMock = undefined; + await replayMock?.stop(); + replayMock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function recordLifecycle(prompt: string): Promise { + if (!recordMock) throw new Error("record mock not started"); + const submit = await fetch(`${recordMock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-rec" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { polling_url: string }; + // Poll (with auth, like a real client) until the proxied job terminates. + for (let i = 0; i < 10; i++) { + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-rec" } }) + ).json(); + if (poll.status === "completed" || poll.status === "failed") return; + } + throw new Error("record lifecycle did not terminate"); + } + + test("completed lifecycle round-trips: byte-equal content, equal cost, Bearer still enforced", async () => { + const bytes = Buffer.from([0x00, 0x01, 0x02, 0xfe, 0xff, 0x41, 0x42, 0x43, 0x00]); + upstream = await startOpenRouterVideoUpstream({ + videoBytes: bytes, + cost: 1.23, + pollsBeforeCompleted: 1, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-roundtrip-")); + + // ── Session 1: record ── + recordMock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await recordMock.start(); + await recordLifecycle("round trip render"); + // The capture is detached from the completed poll — wait for the fixture + // to land on disk before tearing the recording session down. + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + await recordMock.stop(); + recordMock = undefined; + await upstream.close(); + upstream = undefined; + + // ── Session 2: replay from the recorded fixture file only ── + replayMock = new LLMock({ port: 0 }); + replayMock.loadFixtureDir(tmpDir); + await replayMock.start(); + + const submit = await fetch(`${replayMock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "round trip render" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.status).toBe("completed"); + expect(poll.usage).toEqual({ cost: 1.23 }); + expect(poll.unsigned_urls).toHaveLength(1); + + // Replayed content still requires Bearer auth. + const unauthorized = await fetch(poll.unsigned_urls[0]); + expect(unauthorized.status).toBe(401); + + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-replay" }, + }); + expect(download.status).toBe(200); + expect(download.headers.get("content-type")).toBe("video/mp4"); + const replayed = Buffer.from(await download.arrayBuffer()); + expect(replayed.equals(bytes)).toBe(true); + }); + + test("failed lifecycle round-trips with the same error", async () => { + upstream = await startOpenRouterVideoUpstream({ + finalStatus: "failed", + error: "model exploded", + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-roundtrip-")); + + recordMock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await recordMock.start(); + await recordLifecycle("round trip failure"); + await recordMock.stop(); + recordMock = undefined; + await upstream.close(); + upstream = undefined; + + replayMock = new LLMock({ port: 0 }); + replayMock.loadFixtureDir(tmpDir); + await replayMock.start(); + + const submit = await fetch(`${replayMock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "round trip failure" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { polling_url: string }; + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.status).toBe("failed"); + expect(poll.error).toBe("model exploded"); + expect(poll.unsigned_urls).toBeUndefined(); + }); +}); + +// ─── Task 6: b64 capture cap ──────────────────────────────────────────────── + +describe("OpenRouter video record — maxContentBytes cap", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("default cap is 32 MB decoded and exported from the package root", async () => { + // The CHANGELOG promises this constant on the public surface — import it + // from the package root, not the internal module. + const { OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES } = await import("../index.js"); + expect(OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES).toBe(32 * 1024 * 1024); + }); + + test("undeclared-length over-cap capture aborts the streamed read at the cap — placeholder even same-session", async () => { + const bytes = Buffer.alloc(24, 0x61); // 24 bytes > 16-byte cap + // No Content-Length declared: the capture streams the body and counts + // bytes as they arrive, aborting at the cap. DELIBERATE behavior change + // (round-2 CR B10): the over-cap bytes are no longer retained in memory + // for the recording session — the cap is a memory guard as well as a disk + // guard, so the same-session job serves the placeholder like the + // declared-length skip path. + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, omitContentLength: true }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-cap-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + openRouterVideo: { maxContentBytes: 16 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-cap" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "oversized render" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-cap" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("maxContentBytes"))).toBe(true); + + // Fixture persisted WITHOUT b64, with a _warning in the file. + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const saved = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")) as { + fixtures: { response: { video: { b64?: string } } }[]; + _warning?: string; + }; + expect(saved.fixtures[0].response.video.b64).toBeUndefined(); + expect(saved._warning).toContain("maxContentBytes"); + + // SAME-SESSION content serves the placeholder: the streamed read aborted + // at the cap, so nothing oversized was retained in memory. + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-cap" }, + }); + expect(download.status).toBe(200); + const sameSession = Buffer.from(await download.arrayBuffer()); + expect(sameSession.equals(bytes)).toBe(false); + expect(sameSession.subarray(4, 8).toString("ascii")).toBe("ftyp"); // placeholder MP4 + + // Replay-from-fixture (second identical submit) serves the placeholder. + const second = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "oversized render" }), + }); + const secondEnvelope = (await second.json()) as { id: string }; + expect(upstream.counts.submit).toBe(1); // replayed, not proxied + const secondDownload = await fetch( + `${mock.url}/api/v1/videos/${secondEnvelope.id}/content?index=0`, + { headers: { Authorization: "Bearer sk-cap" } }, + ); + expect(secondDownload.status).toBe(200); + const body = Buffer.from(await secondDownload.arrayBuffer()); + expect(body.equals(bytes)).toBe(false); + expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp"); // placeholder MP4 + }); + + test("maxContentBytes: 0 disables the cap entirely", async () => { + const bytes = Buffer.alloc(64, 0x62); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-cap-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + openRouterVideo: { maxContentBytes: 0 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-cap" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "unlimited render" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-cap" } }) + ).arrayBuffer(); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + const saved = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")) as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + }); + + test("a declared over-cap Content-Length skips the download entirely — placeholder even same-session", async () => { + const bytes = Buffer.alloc(24, 0x63); // 24 bytes > 16-byte cap, Content-Length declared + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-cap-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + openRouterVideo: { maxContentBytes: 16 }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-cl" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "declared oversize" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-cl" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("maxContentBytes"))).toBe(true); + + // Fixture persisted WITHOUT b64, with a _warning. + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const saved = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")) as { + fixtures: { response: { video: { b64?: string } } }[]; + _warning?: string; + }; + expect(saved.fixtures[0].response.video.b64).toBeUndefined(); + expect(saved._warning).toContain("maxContentBytes"); + + // The download was never buffered: even the same-session job serves the + // placeholder, not the oversized bytes. + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-cl" }, + }); + expect(download.status).toBe(200); + const body = Buffer.from(await download.arrayBuffer()); + expect(body.equals(bytes)).toBe(false); + expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp"); // placeholder MP4 + }); + + test("a negative maxContentBytes warns at createServer and falls back to the default cap", async () => { + const bytes = Buffer.from("small render bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-cap-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + openRouterVideo: { maxContentBytes: -5 }, + }, + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + await mock.start(); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("maxContentBytes"))).toBe(true); + + // The invalid cap is treated as the default — a small capture records b64. + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-neg" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "negative cap" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-neg" } }) + ).arrayBuffer(); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + const files = fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + const saved = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")) as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + }); +}); + +// ─── Task 7: models listing record passthrough ────────────────────────────── + +describe("OpenRouter video record — models listing passthrough", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("record + provider relays the upstream listing verbatim, journals proxy, writes no fixture", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-models-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + // Verbatim upstream body — "upstream/model-1" can never be synthesized. + expect(data).toEqual({ data: [{ id: "upstream/model-1", name: "upstream/model-1" }] }); + expect(upstream.counts.models).toBe(1); + + const entry = mock.journal + .getAll() + .find((e) => e.method === "GET" && e.path === "/api/v1/videos/models"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(200); + expect(entry!.response.source).toBe("proxy"); + + // Listings are never recorded as fixtures. + expect(fs.readdirSync(tmpDir).filter((f) => f.endsWith(".json"))).toHaveLength(0); + }); + + test("upstream failure warns and falls back to the synthesized listing", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-models-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + mock.addFixture({ + match: { model: "acme/video-z", endpoint: "video" }, + response: { video: { id: "v1", status: "completed" } }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.map((m: { id: string }) => m.id)).toEqual(["acme/video-z"]); + expect( + warnSpy.mock.calls.some((c) => c.join(" ").includes("falling back to the synthesized")), + ).toBe(true); + }); + + test("without record the listing is synthesized and the upstream is never contacted", async () => { + upstream = await startOpenRouterVideoUpstream({}); + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { model: "acme/video-y", endpoint: "video" }, + response: { video: { id: "v1", status: "completed" } }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.map((m: { id: string }) => m.id)).toEqual(["acme/video-y"]); + expect(upstream.counts.models).toBe(0); + }); + + test("strict mode gates the models proxy: synthesized listing, upstream untouched", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-models-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + strict: true, + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + mock.addFixture({ + match: { model: "acme/video-s", endpoint: "video" }, + response: { video: { id: "v1", status: "completed" } }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.map((m: { id: string }) => m.id)).toEqual(["acme/video-s"]); + expect(upstream.counts.models).toBe(0); + }); +}); + +// ─── Task 8: chaos labeling and metrics templating on the record path ─────── + +describe("OpenRouter video record — chaos source labels and metrics", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeRecordOptions(upstreamUrl: string) { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-chaos-")); + return { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir }; + } + + test("chaos drop on a record-mode submit journals source proxy and skips the upstream", async () => { + upstream = await startOpenRouterVideoUpstream({}); + mock = new LLMock({ port: 0, logLevel: "silent", record: makeRecordOptions(upstream.url) }); + await mock.start(); + + const dropped = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-aimock-chaos-drop": "1" }, + body: JSON.stringify({ model: "m/v", prompt: "chaos record" }), + }); + expect(dropped.status).toBe(500); + expect((await dropped.json()).error.code).toBe("chaos_drop"); + expect(upstream.counts.submit).toBe(0); + + const entry = mock.journal + .getAll() + .find((e) => e.path === "/api/v1/videos" && e.response.chaosAction === "drop"); + expect(entry).toBeDefined(); + expect(entry!.response.source).toBe("proxy"); + }); + + test("chaos drop on a strict no-fixture submit journals source internal (strict wins over record)", async () => { + upstream = await startOpenRouterVideoUpstream({}); + mock = new LLMock({ + port: 0, + logLevel: "silent", + strict: true, + record: makeRecordOptions(upstream.url), + }); + await mock.start(); + + const dropped = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-aimock-chaos-drop": "1" }, + body: JSON.stringify({ model: "m/v", prompt: "strict chaos record" }), + }); + expect(dropped.status).toBe(500); + expect((await dropped.json()).error.code).toBe("chaos_drop"); + expect(upstream.counts.submit).toBe(0); + + // Strict would 503 before any proxy attempt, so the chaos-dropped request + // would never have been proxied — the label must be "internal". + const entry = mock.journal + .getAll() + .find((e) => e.path === "/api/v1/videos" && e.response.chaosAction === "drop"); + expect(entry).toBeDefined(); + expect(entry!.response.source).toBe("internal"); + }); + + test("chaos drop on a record-job poll journals source internal (rolls before job lookup)", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 5 }); + mock = new LLMock({ port: 0, logLevel: "silent", record: makeRecordOptions(upstream.url) }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "chaos poll" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const dropped = await fetch(envelope.polling_url, { + headers: { "x-aimock-chaos-drop": "1" }, + }); + expect(dropped.status).toBe(500); + expect((await dropped.json()).error.code).toBe("chaos_drop"); + // Chaos rolls before the job lookup — the upstream is never polled. + expect(upstream.counts.status).toBe(0); + + const entry = mock.journal + .getAll() + .find( + (e) => + e.path.startsWith(`/api/v1/videos/${envelope.id}`) && e.response.chaosAction === "drop", + ); + expect(entry).toBeDefined(); + expect(entry!.response.source).toBe("internal"); + }); + + test("record-mode lifecycle paths stay templated in metrics ({jobId}, no raw uuid)", async () => { + upstream = await startOpenRouterVideoUpstream({}); + mock = new LLMock({ + port: 0, + logLevel: "silent", + metrics: true, + record: makeRecordOptions(upstream.url), + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-m" }, + body: JSON.stringify({ model: "m/v", prompt: "metrics record" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-m" } }) + ).json(); + expect(poll.status).toBe("completed"); + await ( + await fetch(poll.unsigned_urls[0], { headers: { Authorization: "Bearer sk-m" } }) + ).arrayBuffer(); + + const metricsBody = await (await fetch(`${mock.url}/metrics`)).text(); + expect(metricsBody).toMatch(/path="\/api\/v1\/videos"[,}]/); + expect(metricsBody).toContain('path="/api/v1/videos/{jobId}"'); + expect(metricsBody).toContain('path="/api/v1/videos/{jobId}/content"'); + expect(metricsBody).not.toContain(envelope.id); + }); +}); + +// ─── Round 1 CR: upstream timeouts (A1) ───────────────────────────────────── + +describe("OpenRouter video record — upstream timeouts (record.upstreamTimeoutMs)", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-timeout-")); + } + + async function startMock( + upstreamUrl: string, + extra?: { logLevel?: "silent" | "warn" }, + ): Promise { + tmpDir = makeTmpDir(); + const m = new LLMock({ + port: 0, + logLevel: extra?.logLevel ?? "silent", + record: { + providers: { openrouter: upstreamUrl }, + fixturePath: tmpDir, + upstreamTimeoutMs: 200, + }, + }); + await m.start(); + return m; + } + + test("submit proxy 502s when the upstream accepts but never responds", async () => { + upstream = await startOpenRouterVideoUpstream({ hangOnSubmit: true }); + mock = await startMock(upstream.url); + + const t0 = Date.now(); + const res = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-t" }, + body: JSON.stringify({ model: "m/v", prompt: "hung submit" }), + }); + expect(res.status).toBe(502); + expect((await res.json()).error.type).toBe("proxy_error"); + expect(Date.now() - t0).toBeLessThan(2000); + expect(upstream.counts.submit).toBe(1); + }); + + test("poll proxy 502s when the upstream status endpoint hangs", async () => { + upstream = await startOpenRouterVideoUpstream({ hangOnStatus: true }); + mock = await startMock(upstream.url); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-t" }, + body: JSON.stringify({ model: "m/v", prompt: "hung poll" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { polling_url: string }; + + const t0 = Date.now(); + const poll = await fetch(envelope.polling_url); + expect(poll.status).toBe(502); + expect((await poll.json()).error.type).toBe("proxy_error"); + expect(Date.now() - t0).toBeLessThan(2000); + }); + + test("capture-fetch hang still relays completed, warns, and persists nothing (job stays live)", async () => { + // Round-4 B3 pin update: a hung capture fetch used to persist a b64-less + // fixture and terminalize; it now persists nothing so a later completed + // poll can retry the capture. + upstream = await startOpenRouterVideoUpstream({ hangOnContent: true }); + mock = await startMock(upstream.url, { logLevel: "warn" }); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-t" }, + body: JSON.stringify({ model: "m/v", prompt: "hung capture" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-t" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => warnSpy.mock.calls.some((c) => c.join(" ").includes("capture failed"))); + + // Nothing persisted; the next poll still proxies upstream (job is live). + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-t" } }) + ).json(); + expect((again as { status: string }).status).toBe("completed"); + expect(upstream.counts.status).toBe(2); + }); + + test("models relay falls back to synthesis when the upstream hangs", async () => { + upstream = await startOpenRouterVideoUpstream({ hangOnModels: true }); + mock = await startMock(upstream.url, { logLevel: "warn" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const t0 = Date.now(); + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + expect(Date.now() - t0).toBeLessThan(2000); + const data = await res.json(); + // Synthesized default set — never the stub's listing. + expect(data.data.map((m: { id: string }) => m.id)).toContain("bytedance/seedance-2.0"); + expect( + warnSpy.mock.calls.some((c) => c.join(" ").includes("falling back to the synthesized")), + ).toBe(true); + }); +}); + +// ─── Round 1 CR: relay hygiene on proxied polls (A2, A5) ──────────────────── + +describe("OpenRouter video record — relay hygiene on proxied polls", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock(upstreamUrl: string): Promise { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-relay-")); + const m = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir }, + }); + await m.start(); + return m; + } + + async function submitJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-relay" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + test("non-terminal relay rewrites polling_url and preserves extra fields", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "in_progress", + polling_url: `${selfUrl}/api/v1/videos/${jobId}`, + foo: "bar", + }), + }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "leaky pending"); + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.id).toBe(envelope.id); + expect(poll.status).toBe("in_progress"); + expect(poll.foo).toBe("bar"); + // The upstream polling_url must NOT escape the mock. + expect(poll.polling_url).toBe(`${mock.url}/api/v1/videos/${envelope.id}`); + }); + + test("completed relay preserves extras, rewrites both unsigned_urls, passes non-number cost through with a warn", async () => { + const bytes = Buffer.from("hygiene bytes"); + upstream = await startOpenRouterVideoUpstream({ + videoBytes: bytes, + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "completed", + polling_url: `${selfUrl}/api/v1/videos/${jobId}`, + unsigned_urls: [ + `${selfUrl}/api/v1/videos/${jobId}/content?index=0`, + `${selfUrl}/api/v1/videos/${jobId}/content?index=1`, + ], + usage: { cost: "0.12" }, + model: "x", + foo: 1, + }), + }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "hygiene render"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-relay" } }) + ).json(); + expect(poll.id).toBe(envelope.id); + expect(poll.status).toBe("completed"); + // Extras relayed verbatim. + expect(poll.model).toBe("x"); + expect(poll.foo).toBe(1); + // polling_url rewritten to the mock. + expect(poll.polling_url).toBe(`${mock.url}/api/v1/videos/${envelope.id}`); + // unsigned_urls rewritten to the mock origin, SAME length. + expect(poll.unsigned_urls).toEqual([ + `${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, + `${mock.url}/api/v1/videos/${envelope.id}/content?index=1`, + ]); + // usage passed through untouched — not coerced to a number, not invented. + expect(poll.usage).toEqual({ cost: "0.12" }); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("usage.cost"))).toBe(true); + // >1 unsigned_urls: only index 0 is captured — warned. + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("index 0"))).toBe(true); + + // Capture still used unsigned_urls[0]; fixture has the bytes but no + // cost (non-number cost is never coerced into the fixture). + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { + fixtures: { response: { video: { b64?: string; cost?: number } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + expect(saved.fixtures[0].response.video.cost).toBeUndefined(); + }); + + test("completed without unsigned_urls relays faithfully (no invented URLs or usage), warns, persists nothing", async () => { + // Round-4 B3 pin update: persist-without-b64 is now reserved for the + // over-cap path. A completed body with no usable content URL fails the + // capture without persisting; the job stays a live proxy so a later poll + // (whose upstream body may carry URLs) can retry. + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ id: jobId, status: "completed" }), + }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "urlless completion"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-relay" } }) + ).json(); + expect(poll.status).toBe("completed"); + expect(poll.unsigned_urls).toBeUndefined(); + expect(poll.usage).toBeUndefined(); + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("without unsigned_urls")), + ); + + // Nothing persisted; the next poll still proxies upstream. + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-relay" } }) + ).json(); + expect((again as { status: string }).status).toBe("completed"); + expect(upstream.counts.status).toBe(2); + }); + + test("failed with an object-shaped error records the extracted message and relays the body through", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ + id: jobId, + status: "failed", + error: { message: "boom", code: 400 }, + }), + }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "object error"); + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.status).toBe("failed"); + // A2 passthrough: the upstream error object is relayed verbatim. + expect(poll.error).toEqual({ message: "boom", code: 400 }); + + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { + fixtures: { response: { video: { error?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.error).toBe("boom"); + }); + + test("failed with an unusable error value warns and persists the fixture without error", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ id: jobId, status: "failed", error: 42 }), + }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "numeric error"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await (await fetch(envelope.polling_url)).json(); + expect(poll.status).toBe("failed"); + expect(poll.error).toBe(42); // relayed verbatim per A2 + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("unusable error"))).toBe(true); + + const files = readRecordedFixtureFiles(tmpDir!); + const saved = files[0].content as { + fixtures: { response: { video: { error?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.error).toBeUndefined(); + + // Replay default applies once the job is terminal in memory. + const again = await (await fetch(envelope.polling_url)).json(); + expect(again.error).toBe("Video generation failed"); + }); +}); + +// ─── Round 1 CR: proxy-only mode (A3) ─────────────────────────────────────── + +describe("OpenRouter video record — proxy-only mode", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("full lifecycle: no fixtures, no caching, content live-proxied on every fetch", async () => { + const bytes = Buffer.from("proxy only payload"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-proxyonly-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-po" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "proxy only render" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-po" } }) + ).json(); + expect(poll.status).toBe("completed"); + expect(poll.unsigned_urls).toEqual([ + `${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, + ]); + // No eager capture under proxy-only — the bytes were never fetched. + expect(upstream.counts.content).toBe(0); + + // Content is live-proxied: bytes equal the stub's, every fetch hits upstream. + const download1 = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-po" }, + }); + expect(download1.status).toBe(200); + expect(download1.headers.get("content-type")).toBe("video/mp4"); + expect(Buffer.from(await download1.arrayBuffer()).equals(bytes)).toBe(true); + expect(upstream.counts.content).toBe(1); + + const download2 = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-po" }, + }); + expect(Buffer.from(await download2.arrayBuffer()).equals(bytes)).toBe(true); + expect(upstream.counts.content).toBe(2); + + // A poll after completed still proxies upstream (no replay mutation). + const statusBefore = upstream.counts.status; + const again = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-po" } }) + ).json(); + expect(again.status).toBe("completed"); + expect(upstream.counts.status).toBe(statusBefore + 1); + + // No fixture file on disk, no in-memory fixture (a second identical + // submit is proxied again). + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const second = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-po" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "proxy only render" }), + }); + expect(second.status).toBe(200); + expect(upstream.counts.submit).toBe(2); + + // Live-proxied content downloads journal source "proxy". + const contentEntry = mock.journal + .getAll() + .find((e) => e.method === "GET" && e.path.includes("/content")); + expect(contentEntry).toBeDefined(); + expect(contentEntry!.response.status).toBe(200); + expect(contentEntry!.response.source).toBe("proxy"); + }); +}); + +// ─── Round 1 CR: capture concurrency + persistence surfacing (B1, B3) ─────── + +describe("OpenRouter video record — capture concurrency and persistence failures", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + let blockerDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + if (blockerDir) { + fs.rmSync(blockerDir, { recursive: true, force: true }); + blockerDir = undefined; + } + }); + + test("two parallel polls at completed capture exactly once", async () => { + const bytes = Buffer.from("captured once"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-race-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-race" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "race render" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const [a, b] = await Promise.all([ + fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-race" } }), + fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-race" } }), + ]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(((await a.json()) as { status: string }).status).toBe("completed"); + expect(((await b.json()) as { status: string }).status).toBe("completed"); + + // Exactly one capture fetch, one persisted fixture entry (the capture is + // detached — await its completion signal). + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(upstream.counts.content).toBe(1); + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + expect((files[0].content as { fixtures: unknown[] }).fixtures).toHaveLength(1); + }); + + test("a persist failure on the completed branch is logged (the relay left before the detached capture)", async () => { + // Round-3 A5: the completed relay leaves BEFORE the capture persists, so + // a persist failure can no longer ride an X-AIMock-Record-Error header on + // the completed branch (the failed branch, which persists synchronously, + // still sets it — pinned separately). It surfaces through the error log, + // and the in-memory job still terminalizes so the session keeps working. + upstream = await startOpenRouterVideoUpstream({}); + blockerDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-persistfail-")); + const blockerFile = path.join(blockerDir, "not-a-dir"); + fs.writeFileSync(blockerFile, "in the way"); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: blockerFile }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-pf" }, + body: JSON.stringify({ model: "m/v", prompt: "unsaveable render" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const poll = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-pf" }, + }); + expect(poll.status).toBe(200); + expect(((await poll.json()) as { status: string }).status).toBe("completed"); + + // The detached capture hits the persist failure and logs it. + await waitUntil(() => + errorSpy.mock.calls.some((c) => c.join(" ").includes("Failed to save fixture")), + ); + + // The in-memory job still mutated into a terminal replay job. + const again = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-pf" }, + }); + expect(((await again.json()) as { status: string }).status).toBe("completed"); + expect(upstream.counts.status).toBe(1); + }); +}); + +// ─── Round 1 CR: forwarded-header hygiene (B7) ────────────────────────────── + +describe("OpenRouter video record — mock-internal headers never reach the upstream", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("x-test-id / x-aimock-strict / x-aimock-context / x-aimock-chaos-* are stripped on submit, poll, and capture", async () => { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-hdrs-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const internalHeaders = { + "X-Test-Id": "hdr-strip", + "X-AIMock-Strict": "false", + "X-AIMock-Context": "ctx-strip", + "X-AIMock-Chaos-Drop": "0", + }; + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-hdr", + ...internalHeaders, + }, + body: JSON.stringify({ model: "m/v", prompt: "header hygiene" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { polling_url: string }; + + const poll = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-hdr", ...internalHeaders }, + }); + expect(poll.status).toBe(200); + // The capture fetch is detached from the relayed poll — wait for it to + // reach the upstream before asserting on its headers. + await waitUntil(() => upstream!.lastHeaders.content !== undefined); + + for (const [name, captured] of Object.entries({ + submit: upstream.lastHeaders.submit, + status: upstream.lastHeaders.status, + content: upstream.lastHeaders.content, + })) { + expect(captured, `${name} headers captured`).toBeDefined(); + expect(captured!["x-test-id"], `x-test-id on ${name}`).toBeUndefined(); + expect(captured!["x-aimock-strict"], `x-aimock-strict on ${name}`).toBeUndefined(); + expect(captured!["x-aimock-context"], `x-aimock-context on ${name}`).toBeUndefined(); + expect(captured!["x-aimock-chaos-drop"], `x-aimock-chaos-drop on ${name}`).toBeUndefined(); + } + // Auth still forwarded. + expect(upstream.lastHeaders.submit!.authorization).toBe("Bearer sk-hdr"); + }); +}); + +// ─── Round 1 CR: record-job TTL refresh (B17) ─────────────────────────────── + +describe("OpenRouter video record — record-job TTL refresh", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.useRealTimers(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("each successful proxied poll refreshes createdAt — long generations survive the TTL", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 10 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-ttl-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-ttl" }, + body: JSON.stringify({ model: "m/v", prompt: "slow generation" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + // 55 minutes later (inside the 1h TTL): poll succeeds and refreshes. + vi.setSystemTime(Date.now() + 55 * 60_000); + const refresh = await fetch(envelope.polling_url); + expect(refresh.status).toBe(200); + await refresh.arrayBuffer(); // consume the body so the socket is released + + // Another 55 minutes: 110min since submit, but only 55min since the last + // successful proxied poll — the job must still be alive. + vi.setSystemTime(Date.now() + 55 * 60_000); + const res = await fetch(envelope.polling_url); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("in_progress"); + }); +}); + +// ─── Round 1 CR: recorded fixture model normalization (A7) ────────────────── + +describe("OpenRouter video record — fixture model normalization", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function recordWithModel( + model: string, + recordFullModelVersion?: boolean, + ): Promise { + upstream = await startOpenRouterVideoUpstream({}); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-model-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + ...(recordFullModelVersion !== undefined ? { recordFullModelVersion } : {}), + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-m" }, + body: JSON.stringify({ model, prompt: "normalized model render" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-m" } }) + ).arrayBuffer(); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + const files = readRecordedFixtureFiles(tmpDir); + expect(files).toHaveLength(1); + const saved = files[0].content as { fixtures: { match: { model?: string } }[] }; + return saved.fixtures[0].match.model; + } + + test("a date-suffixed model records under the normalized name (suffix stripped)", async () => { + expect(await recordWithModel("vendor/video-20260101")).toBe("vendor/video"); + }); + + test("recordFullModelVersion: true records the verbatim submitted model", async () => { + expect(await recordWithModel("vendor/video-20260101", true)).toBe("vendor/video-20260101"); + }); +}); + +// ─── Round 2 CR: idle-based timeouts on byte-bearing fetches (A1, B10) ────── + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("OpenRouter video record — idle-based content timeout semantics", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let contentHost: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await contentHost?.close(); + contentHost = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock(upstreamUrl: string, record: Partial): Promise { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-idle-")); + const m = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstreamUrl }, + fixturePath: tmpDir, + ...record, + }, + }); + await m.start(); + return m; + } + + async function submitAndGetEnvelope( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-idle" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + test("a steadily-dripping capture download outlives upstreamTimeoutMs (idle, not total deadline)", async () => { + const bytes = Buffer.alloc(64, 0x64); + // 16 chunks × 50ms ≈ 800ms total — more than 2× upstreamTimeoutMs. With + // the old AbortSignal.timeout total-deadline semantics this capture + // aborts (silent no-b64 degrade); with idle semantics it succeeds. + contentHost = await startPlainContentHost(bytes, { chunkSize: 4, chunkDelayMs: 50 }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + mock = await startMock(upstream.url, { upstreamTimeoutMs: 300, bodyTimeoutMs: 1000 }); + const envelope = await submitAndGetEnvelope(mock, "dripping render"); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-idle" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + }); + + test("a mid-body stall beyond bodyTimeoutMs aborts the capture — warn, nothing persisted, job stays live", async () => { + // Round-4 B3 pin update: a mid-body stall used to persist a b64-less + // fixture and terminalize; it now persists nothing (retry on next poll). + const bytes = Buffer.alloc(64, 0x65); + contentHost = await startPlainContentHost(bytes, { + chunkSize: 8, + chunkDelayMs: 10, + stallAfterBytes: 16, + }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-idle-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + upstreamTimeoutMs: 5000, + bodyTimeoutMs: 300, + }, + }); + await mock.start(); + const envelope = await submitAndGetEnvelope(mock, "stalling render"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const t0 = Date.now(); + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-idle" } }) + ).json(); + expect(poll.status).toBe("completed"); + expect(Date.now() - t0).toBeLessThan(3000); + await waitUntil(() => warnSpy.mock.calls.some((c) => c.join(" ").includes("capture failed"))); + // Nothing persisted — the failed capture leaves the job a live proxy. + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + }); + + test("the proxy-only content relay also streams with idle semantics — slow drip succeeds", async () => { + const bytes = Buffer.alloc(64, 0x66); + contentHost = await startPlainContentHost(bytes, { chunkSize: 4, chunkDelayMs: 50 }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-idle-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + upstreamTimeoutMs: 300, + bodyTimeoutMs: 1000, + }, + }); + await mock.start(); + const envelope = await submitAndGetEnvelope(mock, "proxy only drip"); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-idle" } }) + ).json(); + expect(poll.status).toBe("completed"); + + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-idle" }, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + }); + + test("B10: an undeclared-length stream past maxContentBytes aborts mid-read and retains nothing", async () => { + const bytes = Buffer.alloc(64, 0x67); // 64 bytes, cap 16, no Content-Length (chunked drip) + contentHost = await startPlainContentHost(bytes, { chunkSize: 8, chunkDelayMs: 5 }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-idle-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + openRouterVideo: { maxContentBytes: 16 }, + }, + }); + await mock.start(); + const envelope = await submitAndGetEnvelope(mock, "streamed oversize"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-idle" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("maxContentBytes"))).toBe(true); + + // Fixture persisted WITHOUT b64, with a _warning in the file. + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + _warning?: string; + }; + expect(saved.fixtures[0].response.video.b64).toBeUndefined(); + expect(saved._warning).toContain("maxContentBytes"); + + // Nothing oversized retained: same-session content serves the placeholder. + const download = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-idle" }, + }); + const body = Buffer.from(await download.arrayBuffer()); + expect(body.equals(bytes)).toBe(false); + expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp"); + }); +}); + +// ─── Round 2 CR: capturing-window race (A2) + capturing hygiene (A5) ──────── + +describe("OpenRouter video record — capturing window", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock(upstreamUrl: string): Promise { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-capwin-")); + const m = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir }, + }); + await m.start(); + return m; + } + + async function submitJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-win" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + test("A2: the content URL is downloadable during the capturing window (no 400)", async () => { + const bytes = Buffer.from("window bytes"); + // The capture's content fetch takes ~800ms — a wide-open capturing window. + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, contentDelayMs: 800 }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "window render"); + + // Poll A enters the capture sequence and hangs on the content fetch. + // Deterministic ordering: wait for the capture's content fetch to ARRIVE + // at the upstream (counts.content) instead of sleeping — the window is + // then provably open (contentDelayMs keeps the fetch unanswered). + const pollA = fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-win" } }); + await waitUntil(() => upstream!.counts.content === 1); + + // Poll B relays the upstream completed body during the window. + const pollB = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-win" }, + }); + expect(pollB.status).toBe(200); + // The window is ACTUALLY open: the capture's content fetch reached the + // upstream but has not been answered yet (contentDelayMs), and nothing + // is persisted — poll B returned mid-capture, not after it. + expect(upstream.counts.content).toBe(1); + expect(upstream.counts.contentServed).toBe(0); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const bodyB = (await pollB.json()) as { status: string; unsigned_urls: string[] }; + expect(bodyB.status).toBe("completed"); + expect(bodyB.unsigned_urls).toHaveLength(1); + + // Immediately fetch the relayed content URL — still inside the window. + // Before the fix this 400s ("not completed"); it must live-proxy instead. + const download = await fetch(bodyB.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-win" }, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + + const a = await pollA; + expect(a.status).toBe(200); + expect(((await a.json()) as { status: string }).status).toBe("completed"); + }); + + test("A5: a poll relayed during the capturing window refreshes the job TTL", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const bytes = Buffer.from("ttl window bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, contentDelayMs: 1200 }); + mock = await startMock(upstream.url); + const envelope = await submitJob(mock, "ttl window render"); + + // Deterministic: wait for the capture's content fetch to arrive at the + // upstream — the capturing window is then provably open (contentDelayMs + // keeps it unanswered while the fake clock jumps below). + const pollA = fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-win" } }); + await waitUntil(() => upstream!.counts.content === 1); + + // +50min (inside the 1h TTL): the capturing-window relay must refresh. + vi.setSystemTime(Date.now() + 50 * 60_000); + const pollB = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-win" }, + }); + expect(pollB.status).toBe(200); + await pollB.arrayBuffer(); + + // +100min since submit, but only 50min since the refreshed poll. + vi.setSystemTime(Date.now() + 50 * 60_000); + const pollC = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-win" }, + }); + expect(pollC.status).toBe(200); // without the refresh: evicted → 404 + await pollC.arrayBuffer(); + + await (await pollA).arrayBuffer(); + }); + + test("A5: a throw inside the capture sequence resets job.capturing — the next poll captures", async () => { + // No step of the capture sequence throws organically (fetch and persist + // failures are caught) — inject one via a record.openRouterVideo getter + // that throws when armed. The cap is resolved inside the capture sequence, + // after `capturing` flips true, so the throw lands inside the try/finally. + const bytes = Buffer.from("finally bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-capwin-")); + let explode = false; + const record = { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + get openRouterVideo() { + if (explode) throw new Error("injected capture failure"); + return undefined; + }, + } as RecordConfig; + mock = new LLMock({ port: 0, logLevel: "silent", record }); + await mock.start(); + + const envelope = await submitJob(mock, "finally render"); + + explode = true; + const blown = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-win" }, + }); + // Round-3 A5: the capture is DETACHED — the relay has already left when + // the injected throw fires, so the poll succeeds and the throw is + // contained inside the capture sequence (logged, never unhandled). + expect(blown.status).toBe(200); + expect(((await blown.json()) as { status: string }).status).toBe("completed"); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); // nothing persisted + explode = false; + + // Without the finally, `capturing` stays latched: this poll would + // early-relay forever and never persist a fixture. + const poll = await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-win" } }) + ).json(); + expect(poll.status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(1); + }); +}); + +// ─── Round 2 CR: relay hygiene (A3) ───────────────────────────────────────── + +describe("OpenRouter video record — non-array unsigned_urls", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("A3: a non-array unsigned_urls is stripped from the relay with a warn (never leaks)", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: `${selfUrl}/api/v1/videos/${jobId}/content?secret=1`, + }), + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-nonarray-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-na" }, + body: JSON.stringify({ model: "m/v", prompt: "non-array urls" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const pollRes = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-na" }, + }); + expect(pollRes.status).toBe(200); + const raw = await pollRes.text(); + const poll = JSON.parse(raw) as { status: string; unsigned_urls?: unknown }; + expect(poll.status).toBe("completed"); + // Stripped, not relayed verbatim — the upstream URL must not escape. + expect(poll.unsigned_urls).toBeUndefined(); + expect(raw).not.toContain(upstream.url); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("non-array unsigned_urls"))).toBe( + true, + ); + }); +}); + +// ─── Round 2 CR: strict gates record-job proxying (B1) ────────────────────── + +describe("OpenRouter video record — strict gates record-job proxying", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("a per-request X-AIMock-Strict poll of a record job 503s without contacting the upstream", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 5 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-strict-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-st" }, + body: JSON.stringify({ model: "m/v", prompt: "strict poll" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + expect(upstream.counts.status).toBe(0); + + const blocked = await fetch(envelope.polling_url, { + headers: { "X-AIMock-Strict": "true" }, + }); + expect(blocked.status).toBe(503); + const data = await blocked.json(); + expect(data.error.message).toContain("Strict mode"); + expect(upstream.counts.status).toBe(0); // never reached the upstream + + // The gate is per-request: a normal poll still proxies. + const ok = await fetch(envelope.polling_url); + expect(ok.status).toBe(200); + await ok.arrayBuffer(); + expect(upstream.counts.status).toBe(1); + }); + + test("server-level strict blocks record-job polls created under a per-request strict-off override", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 5 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-strict-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + strict: true, + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + // Submit with strict overridden OFF — the record proxy engages. + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-st", + "X-AIMock-Strict": "false", + }, + body: JSON.stringify({ model: "m/v", prompt: "strict server poll" }), + }); + expect(submit.status).toBe(200); + const envelope = (await submit.json()) as { polling_url: string }; + + // A header-less poll falls back to the server-level strict → 503. + const blocked = await fetch(envelope.polling_url); + expect(blocked.status).toBe(503); + await blocked.arrayBuffer(); + expect(upstream.counts.status).toBe(0); + + // Strict-off override proxies again. + const ok = await fetch(envelope.polling_url, { headers: { "X-AIMock-Strict": "false" } }); + expect(ok.status).toBe(200); + await ok.arrayBuffer(); + expect(upstream.counts.status).toBe(1); + }); + + test("strict blocks the proxy-only content live-proxy", async () => { + const bytes = Buffer.from("strict content bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-strict-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-st" }, + body: JSON.stringify({ model: "m/v", prompt: "strict content" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const poll = (await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-st" } }) + ).json()) as { unsigned_urls: string[] }; + + const blocked = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-st", "X-AIMock-Strict": "true" }, + }); + expect(blocked.status).toBe(503); + expect((await blocked.json()).error.message).toContain("Strict mode"); + expect(upstream.counts.content).toBe(0); + + // Without the strict override the live proxy serves the bytes. + const ok = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-st" }, + }); + expect(ok.status).toBe(200); + expect(Buffer.from(await ok.arrayBuffer()).equals(bytes)).toBe(true); + }); +}); + +// ─── Round 2 CR: proxy-only content index handling (B2) + 401 fidelity (B3) ─ + +describe("OpenRouter video record — proxy-only content index handling", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let hostA: Awaited> | undefined; + let hostB: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await hostA?.close(); + hostA = undefined; + await hostB?.close(); + hostB = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startProxyOnly( + statusBody: (selfUrl: string, jobId: string) => Record, + ): Promise<{ m: LLMock; unsignedUrls: string[] }> { + upstream = await startOpenRouterVideoUpstream({ statusBody }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-index-")); + const m = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await m.start(); + mock = m; + const submit = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-idx" }, + body: JSON.stringify({ model: "m/v", prompt: "indexed content" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const poll = (await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-idx" } }) + ).json()) as { unsigned_urls: string[] }; + return { m, unsignedUrls: poll.unsigned_urls }; + } + + test("B2: index selects the position-aligned upstream URL", async () => { + hostA = await startPlainContentHost(Buffer.from("first video")); + hostB = await startPlainContentHost(Buffer.from("second video")); + const a = hostA.url; + const b = hostB.url; + const { unsignedUrls } = await startProxyOnly((_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [`${a}/a.mp4`, `${b}/b.mp4`], + })); + expect(unsignedUrls).toHaveLength(2); + + const dl = await fetch(unsignedUrls[1], { headers: { Authorization: "Bearer sk-idx" } }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).toString()).toBe("second video"); + }); + + test("B2: an out-of-range index warns and serves index 0", async () => { + hostA = await startPlainContentHost(Buffer.from("first video")); + const a = hostA.url; + const { unsignedUrls } = await startProxyOnly((_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [`${a}/a.mp4`], + })); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const outOfRange = unsignedUrls[0].replace("index=0", "index=5"); + const dl = await fetch(outOfRange, { headers: { Authorization: "Bearer sk-idx" } }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).toString()).toBe("first video"); + expect( + warnSpy.mock.calls.some((c) => { + const line = c.join(" "); + return line.includes("index=5") && line.includes("serving index 0"); + }), + ).toBe(true); + }); + + test("B2: non-string entries keep their position (skipped at use time)", async () => { + hostB = await startPlainContentHost(Buffer.from("second video")); + const b = hostB.url; + const { unsignedUrls } = await startProxyOnly((_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [42, `${b}/b.mp4`], + })); + // The relay preserves array length — positions align. + expect(unsignedUrls).toHaveLength(2); + + // index=1 resolves the REAL second entry (not shifted by filtering). + const dl = await fetch(unsignedUrls[1], { headers: { Authorization: "Bearer sk-idx" } }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).toString()).toBe("second video"); + + // index=0 points at the unusable entry — its fallback (urls[0]) is the + // same unusable entry, so the download 502s, naming the ACTUAL condition + // (round-6 pin: urls exist but entry 0 is unusable — not "without + // usable unsigned_urls", which describes an empty array). + const bad = await fetch(unsignedUrls[0], { headers: { Authorization: "Bearer sk-idx" } }); + expect(bad.status).toBe(502); + const badBody = (await bad.json()) as { error: { message: string } }; + expect(badBody.error.message).toContain("unsigned_urls[0] is unusable"); + }); + + test("B3: an upstream 401 on the proxy-only content fetch passes through to the client", async () => { + upstream = await startOpenRouterVideoUpstream({ contentStatus: 401 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-index-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await mock.start(); + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-401" }, + body: JSON.stringify({ model: "m/v", prompt: "upstream rejects auth" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const poll = (await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-401" } }) + ).json()) as { unsigned_urls: string[] }; + + const dl = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-401" }, + }); + // Real-API fidelity: the upstream's auth rejection reaches the client + // as-is instead of a generic 502 proxy_error. + expect(dl.status).toBe(401); + expect(await dl.text()).toContain("stub content error"); + }); +}); + +// ─── Round 2 CR: journal-after-guard (B4) + disableRecording (B6) ─────────── + +describe("OpenRouter video record — relay write hygiene", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + test("B4: a client that disconnects before the relay leaves no phantom 200 journal entry", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 5, statusDelayMs: 400 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-phantom-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-ph" }, + body: JSON.stringify({ model: "m/v", prompt: "phantom journal" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + + const ac = new AbortController(); + const pending = fetch(envelope.polling_url, { signal: ac.signal }); + setTimeout(() => ac.abort(), 100); + await expect(pending).rejects.toThrow(); + + // Deterministic completion signal (no fixed sleep): a SECOND, normal poll + // is dispatched after the abort, so its upstream response — delayed by + // the same statusDelayMs — resolves strictly after the aborted poll's, + // and Node processes the aborted poll's relay step first. By the time + // poll #2's journal entry lands, poll #1 has definitely reached (and + // skipped) its journal step. + const second = await fetch(envelope.polling_url); + expect(second.status).toBe(200); + await second.arrayBuffer(); + + const pollEntries = mock.journal + .getAll() + .filter((e) => e.method === "GET" && e.path.startsWith(`/api/v1/videos/${envelope.id}`)); + // Exactly ONE 200 entry — poll #2's. The aborted relay never left, so it + // journaled nothing. + expect(pollEntries.filter((e) => e.response.status === 200)).toHaveLength(1); + }); + + test("B6: disabling recording mid-flight fails record-job polls loudly — even non-terminal ones", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 5 }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-disable-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-dis" }, + body: JSON.stringify({ model: "m/v", prompt: "orphaned record job" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + mock.disableRecording(); + + const poll = await fetch(envelope.polling_url); + expect(poll.status).toBe(502); + expect((await poll.json()).error.message).toContain("no longer configured"); + // Fails loudly BEFORE contacting the upstream. + expect(upstream.counts.status).toBe(0); + }); +}); + +// ─── Round 2 CR: contract pins (B13) ──────────────────────────────────────── + +describe("OpenRouter video record — round 2 contract pins", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let contentHost: Awaited> | undefined; + let tmpDir: string | undefined; + let blockerDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await contentHost?.close(); + contentHost = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + if (blockerDir) { + fs.rmSync(blockerDir, { recursive: true, force: true }); + blockerDir = undefined; + } + }); + + test("the proxy-only content fetch drops the client Bearer for off-origin unsigned_urls", async () => { + const bytes = Buffer.from("cdn proxied bytes"); + contentHost = await startPlainContentHost(bytes); + const cdn = contentHost.url; + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [`${cdn}/v.mp4`], + }), + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-pin-")); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-pin" }, + body: JSON.stringify({ model: "m/v", prompt: "cdn proxy only" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + const poll = (await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-pin" } }) + ).json()) as { unsigned_urls: string[] }; + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const dl = await fetch(poll.unsigned_urls[0], { + headers: { Authorization: "Bearer sk-pin" }, + }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).equals(bytes)).toBe(true); + // The off-origin host never received the client's Bearer. + expect(contentHost.lastHeaders()).toBeDefined(); + expect(contentHost.lastHeaders()!.authorization).toBeUndefined(); + expect( + warnSpy.mock.calls.some((c) => { + const line = c.join(" "); + return line.includes("differs from the provider origin") && line.includes("Authorization"); + }), + ).toBe(true); + }); + + test("proxy-only completed-without-unsigned_urls 502s on content", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ id: jobId, status: "completed" }), + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-pin-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { + providers: { openrouter: upstream.url }, + fixturePath: tmpDir, + proxyOnly: true, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-pin" }, + body: JSON.stringify({ model: "m/v", prompt: "urlless proxy only" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + const poll = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-pin" }, + }); + expect(((await poll.json()) as { status: string }).status).toBe("completed"); + + const dl = await fetch(`${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, { + headers: { Authorization: "Bearer sk-pin" }, + }); + expect(dl.status).toBe(502); + expect((await dl.json()).error.type).toBe("proxy_error"); + }); + + test("rewritten poll-body URLs embed ?testId= for a non-default testId", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "completed", + polling_url: `${selfUrl}/api/v1/videos/${jobId}`, + unsigned_urls: [`${selfUrl}/api/v1/videos/${jobId}/content?index=0`], + }), + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-pin-")); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-tid", + "X-Test-Id": "round2-pin", + }, + body: JSON.stringify({ model: "m/v", prompt: "testid urls" }), + }); + const envelope = (await submit.json()) as { id: string; polling_url: string }; + expect(envelope.polling_url).toContain("?testId=round2-pin"); + + const poll = (await ( + await fetch(envelope.polling_url, { headers: { Authorization: "Bearer sk-tid" } }) + ).json()) as { polling_url: string; unsigned_urls: string[] }; + expect(poll.polling_url).toBe(`${mock.url}/api/v1/videos/${envelope.id}?testId=round2-pin`); + expect(poll.unsigned_urls).toEqual([ + `${mock.url}/api/v1/videos/${envelope.id}/content?index=0&testId=round2-pin`, + ]); + }); + + test("a persist failure on the FAILED branch sets X-AIMock-Record-Error", async () => { + upstream = await startOpenRouterVideoUpstream({ + finalStatus: "failed", + error: "doomed render", + }); + blockerDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-pin-")); + const blockerFile = path.join(blockerDir, "not-a-dir"); + fs.writeFileSync(blockerFile, "in the way"); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: blockerFile }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-ff" }, + body: JSON.stringify({ model: "m/v", prompt: "unsaveable failure" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + const poll = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-ff" }, + }); + expect(poll.status).toBe(200); + expect(((await poll.json()) as { status: string }).status).toBe("failed"); + expect(poll.headers.get("x-aimock-record-error")).toBeTruthy(); + }); + + test("a persist failure with non-Latin-1 characters in the error still relays (sanitized header, no 500)", async () => { + // The fs error message embeds the fixture path verbatim — a path with + // characters outside Latin-1 (e.g. a Unicode project directory) would + // make res.setHeader throw ERR_INVALID_CHAR and turn a recoverable + // record-path failure into a 500. The header must ride sanitized instead. + upstream = await startOpenRouterVideoUpstream({ + finalStatus: "failed", + error: "doomed render", + }); + blockerDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-latin1-")); + const blockerFile = path.join(blockerDir, "not-a-dir-日本語-héllo"); + fs.writeFileSync(blockerFile, "in the way"); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: upstream.url }, fixturePath: blockerFile }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-l1" }, + body: JSON.stringify({ model: "m/v", prompt: "unsaveable unicode failure" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + const poll = await fetch(envelope.polling_url, { + headers: { Authorization: "Bearer sk-l1" }, + }); + expect(poll.status).toBe(200); + expect(((await poll.json()) as { status: string }).status).toBe("failed"); + const header = poll.headers.get("x-aimock-record-error"); + expect(header).toBeTruthy(); + // Sanitized: nothing outside what Node accepts in a header value, but the + // useful (Latin-1) part of the message survives. + expect(header).toMatch(/^[\t\x20-\x7e\x80-\xff]*$/); + expect(header).toContain("not-a-dir-"); + }); +}); + +// ─── Round 3 CR: stale-reference resurrection (A1), failed-branch concurrency +// (A2), content record-disabled gate (A3), detached capture (A5), streamed +// content relay (A6), models fallback label (B2), unusable-[0] warn (B5), +// 403 passthrough pin (B9) ──────────────────────────────────────────────────── + +describe("OpenRouter video record — round 3 CR", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let contentHost: Awaited> | undefined; + let rawUpstream: { url: string; close: () => Promise } | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await contentHost?.close(); + contentHost = undefined; + await rawUpstream?.close(); + rawUpstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock( + upstreamUrl: string, + extra?: Partial & { logLevel?: "silent" | "warn" }, + ): Promise { + const { logLevel, ...record } = extra ?? {}; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-r3-")); + const m = new LLMock({ + port: 0, + logLevel: logLevel ?? "silent", + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir, ...record }, + }); + await m.start(); + mock = m; + return m; + } + + async function submitJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-r3" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + const auth = { Authorization: "Bearer sk-r3" }; + + test("A1: a stale concurrent poll cannot resurrect the record job over the captured replay job", async () => { + const bytes = Buffer.from("resurrection bytes"); + // Both polls' upstream status fetches take statusDelayMs. Poll A's capture + // replaces the map entry at ~statusDelayMs + contentDelayMs; poll B is + // dispatched mid-flight so its status response resolves strictly AFTER + // that replacement — its dispatch-time job reference is then detached. + upstream = await startOpenRouterVideoUpstream({ + videoBytes: bytes, + contentDelayMs: 400, + statusDelayMs: 800, + }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, "resurrection render"); + + const pollA = fetch(envelope.polling_url, { headers: auth }); // status resolves ~800 + // Deterministic capture-started gate: wait for the capture's content + // fetch to ARRIVE at the upstream (counts.content — incremented on + // arrival, answered contentDelayMs later) instead of sleeping. Poll B is + // then dispatched while the map still holds the record job (replacement + // needs the content response, ~400ms away), and B's own status response + // (dispatch + 800ms) resolves strictly after the replacement — both + // margins are anchored to server-side timers, not CI wall-clock speed. + await waitUntil(() => upstream!.counts.content >= 1); + const pollB = fetch(envelope.polling_url, { headers: auth }); // resolves ~dispatch+800; entry replaced ~dispatch+400 + const [a, b] = await Promise.all([pollA, pollB]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(((await a.json()) as { status: string }).status).toBe("completed"); + expect(((await b.json()) as { status: string }).status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(upstream.counts.status).toBe(2); + + // The third poll must be served from the terminal replay job — a + // resurrected record job would proxy upstream (counts.status would grow) + // and live-proxy content forever. + const third = await (await fetch(envelope.polling_url, { headers: auth })).json(); + expect((third as { status: string }).status).toBe("completed"); + expect(upstream.counts.status).toBe(2); + + const download = await fetch(`${m.url}/api/v1/videos/${envelope.id}/content?index=0`, { + headers: auth, + }); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).equals(bytes)).toBe(true); + expect(upstream.counts.content).toBe(1); // captured bytes, not a live proxy + }); + + test("A1: a stale pending response cannot regress a captured terminal job", async () => { + const bytes = Buffer.from("regress bytes"); + // Custom upstream: the FIRST status request hangs 700ms then reports + // pending; every later one reports completed immediately. The slow + // pending resolves AFTER the fast poll's capture replaced the entry. + let statusCalls = 0; + let selfUrl = "http://stub"; + rawUpstream = await new Promise((resolve) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", selfUrl); + const sendJson = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (req.method === "POST" && url.pathname === "/api/v1/videos") { + req.resume(); + req.on("end", () => + sendJson(200, { + id: "up-regress-1", + polling_url: `${selfUrl}/api/v1/videos/up-regress-1`, + status: "pending", + }), + ); + return; + } + if (req.method === "GET" && url.pathname.endsWith("/content")) { + res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": bytes.length }); + res.end(bytes); + return; + } + if (req.method === "GET") { + statusCalls++; + if (statusCalls === 1) { + setTimeout(() => sendJson(200, { id: "up-regress-1", status: "pending" }), 700); + } else { + sendJson(200, { + id: "up-regress-1", + status: "completed", + unsigned_urls: [`${selfUrl}/api/v1/videos/up-regress-1/content`], + usage: { cost: 0.1 }, + }); + } + return; + } + sendJson(404, {}); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + const m = await startMock(rawUpstream.url); + const envelope = await submitJob(m, "regress render"); + + const slowPending = fetch(envelope.polling_url, { headers: auth }); // status call #1 + // Deterministic ordering: the slow-pending poll must be status call #1 — + // wait for its ARRIVAL at the upstream instead of sleeping. + await waitUntil(() => statusCalls === 1); + const fast = await fetch(envelope.polling_url, { headers: auth }); // status call #2 → capture + expect(((await fast.json()) as { status: string }).status).toBe("completed"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + // The stale pending body is still relayed verbatim to ITS caller... + const slow = await slowPending; + expect(((await slow.json()) as { status: string }).status).toBe("pending"); + + // ...but must not have regressed the terminal job: the next poll is + // served locally as completed, with no extra upstream call. + const again = await (await fetch(envelope.polling_url, { headers: auth })).json(); + expect((again as { status: string }).status).toBe("completed"); + expect(statusCalls).toBe(2); + }); + + test("A2: two parallel polls at failed persist exactly one fixture and one registration", async () => { + upstream = await startOpenRouterVideoUpstream({ + finalStatus: "failed", + error: "double doom", + statusDelayMs: 150, + }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, "race failure"); + + const [a, b] = await Promise.all([ + fetch(envelope.polling_url, { headers: auth }), + fetch(envelope.polling_url, { headers: auth }), + ]); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(((await a.json()) as { status: string }).status).toBe("failed"); + expect(((await b.json()) as { status: string }).status).toBe("failed"); + + // Exactly one fixture file with one entry on disk... + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + expect((files[0].content as { fixtures: unknown[] }).fixtures).toHaveLength(1); + // ...and exactly one in-memory registration. + expect(m.getFixtures().filter((f) => f.match.userMessage === "race failure")).toHaveLength(1); + }); + + test("A3: disabling recording mid-flight fails record-job content downloads loudly", async () => { + const bytes = Buffer.from("gated content bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + const m = await startMock(upstream.url, { proxyOnly: true }); + const envelope = await submitJob(m, "orphaned content job"); + + // Proxy-only keeps the job kind:"record" after the terminal poll. + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + unsigned_urls: string[]; + }; + expect(poll.status).toBe("completed"); + + m.disableRecording(); + + // The orphaned record job must fail loudly BEFORE contacting the + // upstream — and before forwarding the client's Bearer anywhere. + const dl = await fetch(poll.unsigned_urls[0], { headers: auth }); + expect(dl.status).toBe(502); + expect((await dl.json()).error.message).toContain("no longer configured"); + expect(upstream.counts.content).toBe(0); + }); + + test("A5: the completed poll relays before the capture download finishes; the fixture lands later", async () => { + const bytes = Buffer.from("detached capture bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, contentDelayMs: 1500 }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, "detached render"); + + const t0 = Date.now(); + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + }; + const elapsed = Date.now() - t0; + expect(poll.status).toBe("completed"); + // The relay must NOT have waited out the 1500ms content download. + expect(elapsed).toBeLessThan(1000); + // The capture is still in flight at relay time... + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + // ...and lands later with the real bytes. + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1, 8000); + const saved = readRecordedFixtureFiles(tmpDir!)[0].content as { + fixtures: { response: { video: { b64?: string } } }[]; + }; + expect(saved.fixtures[0].response.video.b64).toBe(bytes.toString("base64")); + expect(upstream.counts.content).toBe(1); + }); + + test("A6: the content live-proxy streams — client sees headers before the upstream finishes", async () => { + const bytes = Buffer.alloc(64, 0x68); + // 16 chunks × 50ms ≈ 800ms of upstream drip, idle timeout 1000ms. + contentHost = await startPlainContentHost(bytes, { chunkSize: 4, chunkDelayMs: 50 }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + const m = await startMock(upstream.url, { proxyOnly: true, bodyTimeoutMs: 1000 }); + const envelope = await submitJob(m, "streamed relay"); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + + const dl = await fetch(poll.unsigned_urls[0], { headers: auth }); // resolves on HEADERS + expect(dl.status).toBe(200); + expect(dl.headers.get("content-type")).toBe("video/mp4"); + // First byte beat the upstream: the drip is still writing when the + // client already holds the response head — streaming, not buffering. + expect(contentHost.finishedAt()).toBeUndefined(); + const got = Buffer.from(await dl.arrayBuffer()); + expect(got.equals(bytes)).toBe(true); + expect(contentHost.finishedAt()).toBeDefined(); + }); + + test("B2: the synthesized fallback after a failed models proxy journals source internal", async () => { + const m = await startMock(UPSTREAM_DOWN_URL); + const res = await fetch(`${m.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + await res.arrayBuffer(); + const entry = m.journal + .getAll() + .find((e) => e.method === "GET" && e.path === "/api/v1/videos/models"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(200); + expect(entry!.response.source).toBe("internal"); + }); + + test("B5: unsigned_urls present but [0] unusable warns distinctly from absent unsigned_urls", async () => { + // Round-4 B3 pin update: the unusable-[0] capture failure no longer + // persists a b64-less fixture — the distinct warn is the surviving + // contract; the job stays a live proxy. + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [""], + }), + }); + const m = await startMock(upstream.url, { logLevel: "warn" }); + const envelope = await submitJob(m, "unusable first url"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + }; + expect(poll.status).toBe("completed"); + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("unusable unsigned_urls[0]")), + ); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + }); + + test("B9: an upstream 403 on the proxy-only content fetch passes through to the client", async () => { + upstream = await startOpenRouterVideoUpstream({ contentStatus: 403 }); + const m = await startMock(upstream.url, { proxyOnly: true }); + const envelope = await submitJob(m, "upstream forbids"); + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + + const dl = await fetch(poll.unsigned_urls[0], { headers: auth }); + // Real-API fidelity: the upstream's 403 reaches the client as-is instead + // of a generic 502 proxy_error (only the 401 case was pinned before). + expect(dl.status).toBe(403); + expect(await dl.text()).toContain("stub content error"); + }); +}); + +// ─── Round 4 CR: orphaned-read hygiene (A1), bounded error-body read (A2), +// reset-array pollution (A3), bounded drain wait (A4), in-window status +// regression (A5), disconnect guards (B1), poll 401/403 passthrough (B2), +// capture-failure retry (B3), content TTL refresh (B6), strict-suppressed +// models journal (B9) ──────────────────────────────────────────────────────── + +describe("OpenRouter video record — round 4 CR", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let contentHost: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await contentHost?.close(); + contentHost = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock( + upstreamUrl: string, + extra?: Partial & { logLevel?: "silent" | "warn" }, + ): Promise { + const { logLevel, ...record } = extra ?? {}; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-r4-")); + const m = new LLMock({ + port: 0, + logLevel: logLevel ?? "silent", + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir, ...record }, + }); + await m.start(); + mock = m; + return m; + } + + const auth = { Authorization: "Bearer sk-r4" }; + + async function submitJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + /** Collect unhandledRejection events around `fn` and assert there were none. + * NOTE on red-green: Promise.race subscribes to every input promise, so a + * read rejecting after the idle timeout won the race is mechanically + * handled today — this is a defense-in-depth PIN (documented in the A1 + * source comment) guarding the invariant against a refactor that races the + * read differently, not a reproducible crash. */ + async function expectNoUnhandledRejection(fn: () => Promise): Promise { + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + process.on("unhandledRejection", onRejection); + try { + await fn(); + // One extra macrotask turn so a would-be rejection from the just- + // destroyed socket has a chance to surface before we assert. + await sleep(100); + } finally { + process.off("unhandledRejection", onRejection); + } + expect(rejections).toEqual([]); + } + + test("A1: a stream error after the capture's idle timeout cannot crash the server (orphaned read pin)", async () => { + await expectNoUnhandledRejection(async () => { + const bytes = Buffer.alloc(64, 0x71); + // Stall mid-body so the idle timeout (200ms) wins the race and the + // in-flight read is orphaned... + contentHost = await startPlainContentHost(bytes, { + chunkSize: 8, + chunkDelayMs: 5, + stallAfterBytes: 16, + }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + const m = await startMock(upstream.url, { bodyTimeoutMs: 200, logLevel: "warn" }); + const envelope = await submitJob(m, "orphan read render"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + }; + expect(poll.status).toBe("completed"); + await waitUntil(() => warnSpy.mock.calls.some((c) => c.join(" ").includes("capture failed"))); + + // ...then destroy the stalled socket so the orphaned read errors NOW. + await contentHost.close(); + contentHost = undefined; + + // The server survives and keeps serving. + const alive = await fetch(`${m.url}/api/v1/videos/models`); + expect(alive.status).toBe(200); + await alive.arrayBuffer(); + }); + }); + + test("A1: a stream error after the streamed relay's idle timeout cannot crash the server", async () => { + await expectNoUnhandledRejection(async () => { + const bytes = Buffer.alloc(64, 0x72); + contentHost = await startPlainContentHost(bytes, { + chunkSize: 8, + chunkDelayMs: 5, + stallAfterBytes: 16, + }); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + const m = await startMock(upstream.url, { + proxyOnly: true, + bodyTimeoutMs: 200, + logLevel: "warn", + }); + const envelope = await submitJob(m, "orphan relay render"); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + // The relay commits a 200 then stalls with the upstream; the idle abort + // truncates the transfer — the client download fails mid-body. + await expect( + fetch(poll.unsigned_urls[0], { headers: auth }).then((r) => r.arrayBuffer()), + ).rejects.toThrow(); + + // Destroy the stalled upstream socket so any orphaned read errors now. + await contentHost.close(); + contentHost = undefined; + + const alive = await fetch(`${m.url}/api/v1/videos/models`); + expect(alive.status).toBe(200); + await alive.arrayBuffer(); + }); + }); + + test("A2: an upstream 401 with a stalling body is bounded by bodyTimeoutMs — 502, no hang", async () => { + // Documented choice: an error body that stalls past the idle timeout + // surfaces as a 502 proxy_error (the upstream status cannot be relayed + // faithfully without its body); an over-cap error body instead relays + // the upstream status with an empty body (see the source comment). + upstream = await startOpenRouterVideoUpstream({ contentStatus: 401, stallContentBody: true }); + const m = await startMock(upstream.url, { proxyOnly: true, bodyTimeoutMs: 200 }); + const envelope = await submitJob(m, "stalling 401 body"); + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + + const t0 = Date.now(); + const dl = await fetch(poll.unsigned_urls[0], { headers: auth }); + expect(dl.status).toBe(502); + expect((await dl.json()).error.type).toBe("proxy_error"); + // Bounded by the 200ms idle timeout, not hung on a bare arrayBuffer(). + expect(Date.now() - t0).toBeLessThan(3000); + }); + + test("A3: a fixtures reset during an in-flight capture discards the stale fixture (memory and disk)", async () => { + const bytes = Buffer.from("stale world bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes, contentDelayMs: 600 }); + const m = await startMock(upstream.url, { logLevel: "warn" }); + const envelope = await submitJob(m, "reset mid-capture"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + }; + expect(poll.status).toBe("completed"); + // The capture's content fetch is in flight (contentDelayMs holds it open). + await waitUntil(() => upstream!.counts.content === 1); + + // The world resets mid-capture: fixtures array cleared, job map cleared. + const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" }); + expect(reset.status).toBe(200); + await reset.arrayBuffer(); + + // Deterministic completion signal: the capture notices the reset and + // warns instead of persisting (the warn names both possible causes — + // fixtures reset or TTL eviction — since the guard cannot tell them apart). + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("no longer holds this job")), + ); + + // The stale fixture landed NOWHERE: not in the next world's in-memory + // array, not on disk. + expect(m.getFixtures()).toHaveLength(0); + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + }); + + test("A4: a client that stops reading cannot wedge the streamed relay — bounded by bodyTimeoutMs", async () => { + // 8 MB through the relay against a client that never reads: res.write + // returns false once the socket buffers fill, and the drain never comes. + const bytes = Buffer.alloc(8 * 1024 * 1024, 0x73); + contentHost = await startPlainContentHost(bytes); + upstream = await startOpenRouterVideoUpstream({ unsignedUrlOrigin: contentHost.url }); + const m = await startMock(upstream.url, { + proxyOnly: true, + bodyTimeoutMs: 300, + logLevel: "warn", + }); + const envelope = await submitJob(m, "stalled client render"); + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Raw http client so the response can be paused (never consumed). NOTE: + // the paused client cannot OBSERVE the server-side destroy (the FIN sits + // in the kernel buffer behind the data it refuses to read), so the + // assertions below are server-side: the bounded-abort warn fires and the + // upstream connection is released. + const target = new URL(poll.unsigned_urls[0]); + const t0 = Date.now(); + const clientReq = await new Promise((resolve, reject) => { + const req = http.get( + { + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + headers: auth, + }, + (res) => { + expect(res.statusCode).toBe(200); + res.pause(); // stop reading forever — the wedge + res.on("error", () => {}); // premature close is the EXPECTED outcome + resolve(req); + }, + ); + req.on("error", reject); + }); + + try { + // The relay terminates within the 300ms bound (+ scheduling margin) — + // before the fix the drain wait never resolves and this warn never + // fires (the waitUntil times out). + await waitUntil(() => + warnSpy.mock.calls.some((c) => c.join(" ").includes("stopped reading")), + ); + expect(Date.now() - t0).toBeLessThan(5000); + // ...and the upstream reader was released: the relay's cancel tears the + // mock→upstream connection down (a wedged relay pins it forever). + await waitUntil(() => contentHost!.openSockets() === 0); + } finally { + clientReq.destroy(); + } + }); + + test("A5: a stale pending response during the capture window cannot regress the job (content stays 200)", async () => { + const bytes = Buffer.from("window regress bytes"); + let statusCallNum = 0; + upstream = await startOpenRouterVideoUpstream({ + videoBytes: bytes, + contentDelayMs: 800, + statusBody: (selfUrl, jobId) => { + statusCallNum++; + // First poll completes (opening the capture window — contentDelayMs + // keeps it open); every later poll reports a STALE pending. + return statusCallNum === 1 + ? { + id: jobId, + status: "completed", + unsigned_urls: [`${selfUrl}/api/v1/videos/${jobId}/content?index=0`], + usage: { cost: 0.1 }, + } + : { id: jobId, status: "pending" }; + }, + }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, "window regress render"); + + const poll1 = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + unsigned_urls: string[]; + }; + expect(poll1.status).toBe("completed"); + // Capture window provably open: the content fetch arrived upstream. + await waitUntil(() => upstream!.counts.content === 1); + + // The stale pending is relayed verbatim to ITS caller (identity holds + // during the window, so this used to overwrite job.status)... + const poll2 = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + }; + expect(poll2.status).toBe("pending"); + + // ...but must NOT have reopened the 400 content window: the download + // still live-proxies during the capture window. + const dl = await fetch(poll1.unsigned_urls[0], { headers: auth }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).equals(bytes)).toBe(true); + }); + + test("B1: a client that disconnects during the models proxy attempt leaves no synthesis journal entry", async () => { + upstream = await startOpenRouterVideoUpstream({ hangOnModels: true }); + const m = await startMock(upstream.url, { upstreamTimeoutMs: 300 }); + + const ac = new AbortController(); + const pending = fetch(`${m.url}/api/v1/videos/models`, { signal: ac.signal }); + setTimeout(() => ac.abort(), 50); + await expect(pending).rejects.toThrow(); + + // A second, normal request lands strictly after the first one's failed + // proxy attempt (both wait out the same 300ms timeout; the first started + // earlier) — by then the aborted handler has reached (and skipped) its + // journal step. + const second = await fetch(`${m.url}/api/v1/videos/models`); + expect(second.status).toBe(200); + await second.arrayBuffer(); + + const entries = m.journal + .getAll() + .filter((e) => e.method === "GET" && e.path === "/api/v1/videos/models"); + // Exactly ONE entry — the second request's. The aborted synthesis never + // left, so it journaled nothing. + expect(entries).toHaveLength(1); + }); + + test("B1: a client that disconnects during a slow ResponseFactory replay leaves no journal entry", async () => { + const m = new LLMock({ port: 0, logLevel: "silent" }); + m.addFixture({ + match: { userMessage: "slow factory", endpoint: "video" }, + response: async () => { + await sleep(400); + return { video: { id: "v-slow", status: "completed", b64: "AAAA" } }; + }, + }); + await m.start(); + mock = m; + + const ac = new AbortController(); + const pending = fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "slow factory" }), + signal: ac.signal, + }); + setTimeout(() => ac.abort(), 100); + await expect(pending).rejects.toThrow(); + + // Second, normal submit: its factory resolves strictly after the aborted + // one's (started 100ms+ later), so the aborted handler has already + // reached (and skipped) its journal step. + const ok = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "slow factory" }), + }); + expect(ok.status).toBe(200); + await ok.arrayBuffer(); + + const entries = m.journal + .getAll() + .filter( + (e) => e.method === "POST" && e.path === "/api/v1/videos" && e.response.status === 200, + ); + expect(entries).toHaveLength(1); + }); + + for (const status of [401, 403] as const) { + test(`B2: an upstream ${status} on the record-job poll passes through verbatim`, async () => { + upstream = await startOpenRouterVideoUpstream({ statusHttpStatus: status }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, `poll auth reject ${status}`); + + const poll = await fetch(envelope.polling_url, { headers: auth }); + // Real-API fidelity (mirrors the content path): the upstream's auth + // rejection reaches the client as-is, not as a generic 502 proxy_error. + expect(poll.status).toBe(status); + const body = (await poll.json()) as { error: { message: string; code: number } }; + expect(body.error.message).toBe("stub poll rejected"); + expect(body.error.code).toBe(status); + + const entry = m.journal + .getAll() + .find((e) => e.method === "GET" && e.path.startsWith(`/api/v1/videos/${envelope.id}`)); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(status); + expect(entry!.response.source).toBe("proxy"); + + // The job is untouched — the next poll proxies upstream again. + const again = await fetch(envelope.polling_url, { headers: auth }); + expect(again.status).toBe(status); + await again.arrayBuffer(); + expect(upstream!.counts.status).toBe(2); + }); + } + + test("B6: replay-job content fetches refresh the TTL — long downloads keep the job alive", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const m = new LLMock({ port: 0, logLevel: "silent" }); + m.addFixture({ + match: { userMessage: "ttl content render", endpoint: "video" }, + response: { video: { id: "v-ttl", status: "completed", b64: "QUFBQQ==" } }, + }); + await m.start(); + mock = m; + + const submit = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "ttl content render" }), + }); + const envelope = (await submit.json()) as { id: string }; + const contentUrl = `${m.url}/api/v1/videos/${envelope.id}/content?index=0`; + + // +55min (inside the 1h TTL): the content fetch succeeds and refreshes. + vi.setSystemTime(Date.now() + 55 * 60_000); + const first = await fetch(contentUrl, { headers: auth }); + expect(first.status).toBe(200); + await first.arrayBuffer(); + + // +110min since submit, but only 55min since the refreshed content fetch + // — without the refresh this is evicted → 404. + vi.setSystemTime(Date.now() + 55 * 60_000); + const second = await fetch(contentUrl, { headers: auth }); + expect(second.status).toBe(200); + await second.arrayBuffer(); + }); + + test("B6: record-job (proxy-only) content fetches refresh the TTL too", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const bytes = Buffer.from("ttl proxied bytes"); + upstream = await startOpenRouterVideoUpstream({ videoBytes: bytes }); + const m = await startMock(upstream.url, { proxyOnly: true }); + const envelope = await submitJob(m, "ttl proxied render"); + + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + + // +55min: live-proxied download succeeds and refreshes. + vi.setSystemTime(Date.now() + 55 * 60_000); + const first = await fetch(poll.unsigned_urls[0], { headers: auth }); + expect(first.status).toBe(200); + await first.arrayBuffer(); + + // +110min since the last poll, 55min since the refreshed download. + vi.setSystemTime(Date.now() + 55 * 60_000); + const second = await fetch(poll.unsigned_urls[0], { headers: auth }); + expect(second.status).toBe(200); + await second.arrayBuffer(); + }); + + test("B9: a strict-suppressed models proxy journals the strict override (no source — synthesis convention)", async () => { + upstream = await startOpenRouterVideoUpstream({}); + const m = await startMock(upstream.url); + + // Per-request strict ON (server default off): the proxy is suppressed + // and the synthesized listing is served. + const res = await fetch(`${m.url}/api/v1/videos/models`, { + headers: { "X-AIMock-Strict": "true" }, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + expect(upstream.counts.models).toBe(0); + + const entry = m.journal + .getAll() + .find((e) => e.method === "GET" && e.path === "/api/v1/videos/models"); + expect(entry).toBeDefined(); + expect(entry!.response.strictOverride).toBe(true); + // Convention pin: only a FAILED proxy attempt labels the synthesis + // source:"internal"; a never-attempted proxy (strict-suppressed, like + // plain no-record) omits source entirely. + expect(entry!.response.source).toBeUndefined(); + }); +}); + +// ─── Round 5 CR: submit 401/403 passthrough, capture-window URL refresh, +// world-generation guard on submit insertion, strict-override-relayed models +// journal ───────────────────────────────────────────────────────────────────── + +describe("OpenRouter video record — round 5 CR", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let hostA: Awaited> | undefined; + let hostB: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await hostA?.close(); + hostA = undefined; + await hostB?.close(); + hostB = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock( + upstreamUrl: string, + extra?: Partial & { logLevel?: "silent" | "warn"; strict?: boolean }, + ): Promise { + const { logLevel, strict, ...record } = extra ?? {}; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-r5-")); + const m = new LLMock({ + port: 0, + logLevel: logLevel ?? "silent", + strict, + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir, ...record }, + }); + await m.start(); + mock = m; + return m; + } + + const auth = { Authorization: "Bearer sk-r5" }; + + async function submitJob( + m: LLMock, + prompt: string, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + for (const status of [401, 403] as const) { + test(`an upstream ${status} on the record submit passes through verbatim`, async () => { + upstream = await startOpenRouterVideoUpstream({ submitHttpStatus: status }); + const m = await startMock(upstream.url, { logLevel: "warn" }); + + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ + model: "bytedance/seedance-2.0", + prompt: `submit reject ${status}`, + }), + }); + // Real-API fidelity (mirrors the poll and content paths): the + // upstream's auth rejection reaches the client as-is, not as a generic + // 502 proxy_error. + expect(res.status).toBe(status); + const body = (await res.json()) as { error: { message: string; code: number } }; + expect(body.error.message).toBe("stub submit rejected"); + expect(body.error.code).toBe(status); + + const entry = m.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/api/v1/videos"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(status); + expect(entry!.response.source).toBe("proxy"); + + // Nothing was persisted and no job was created for the rejected submit. + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + }); + } + + test("a later completed poll refreshes the capture-window upstream URLs (rotated unsigned_urls)", async () => { + const bytesA = Buffer.alloc(40, 0x61); + const bytesB = Buffer.from("fresh rotated bytes"); + // Host A drips slowly so the eager capture (which fetches the FIRST + // completed poll's URL) keeps the capturing window open while the test + // polls again and downloads. + hostA = await startPlainContentHost(bytesA, { chunkSize: 4, chunkDelayMs: 200 }); + hostB = await startPlainContentHost(bytesB); + let terminalPolls = 0; + upstream = await startOpenRouterVideoUpstream({ + statusBody: (_selfUrl, jobId) => { + terminalPolls++; + const host = terminalPolls === 1 ? hostA!.url : hostB!.url; + return { + id: jobId, + status: "completed", + unsigned_urls: [`${host}/api/v1/videos/${jobId}/content?index=0`], + }; + }, + }); + const m = await startMock(upstream.url); + const envelope = await submitJob(m, "rotated urls render"); + + // First completed poll opens the capture window (capture fetch: host A, + // dripping for ~2s). + const first = await fetch(envelope.polling_url, { headers: auth }); + expect(first.status).toBe(200); + await first.arrayBuffer(); + + // Second completed poll carries the ROTATED URL set — the window must + // refresh its stored upstream URLs. + const second = await fetch(envelope.polling_url, { headers: auth }); + expect(second.status).toBe(200); + await second.arrayBuffer(); + + // The in-window live-proxied download must hit the FRESH upstream URL. + const dl = await fetch(`${m.url}/api/v1/videos/${envelope.id}/content?index=0`, { + headers: auth, + }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).equals(bytesB)).toBe(true); + expect(hostB.lastHeaders()).toBeDefined(); // host B actually served it + }); + + test("a fixtures reset landing mid-submit-fetch never inserts the job into the new world", async () => { + upstream = await startOpenRouterVideoUpstream({ submitDelayMs: 400 }); + const m = await startMock(upstream.url, { logLevel: "warn" }); + + const submitPromise = fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "reset mid submit" }), + }); + await sleep(150); // the upstream submit is in flight + const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" }); + expect(reset.status).toBe(200); + await reset.arrayBuffer(); + + // The envelope still relays (documented choice — the warned 404-on-poll + // outcome matches TTL eviction semantics)... + const res = await submitPromise; + expect(res.status).toBe(200); + const envelope = (await res.json()) as { polling_url: string }; + + // ...but the stale job was NOT inserted into the new world: the poll + // 404s locally and the upstream status endpoint is never contacted. + const poll = await fetch(envelope.polling_url, { headers: auth }); + expect(poll.status).toBe(404); + await poll.arrayBuffer(); + expect(upstream.counts.status).toBe(0); + }); + + test("a strict-override-relayed models listing journals the override (strictOverride: false)", async () => { + upstream = await startOpenRouterVideoUpstream({}); + const m = await startMock(upstream.url, { strict: true }); + + // Server default strict ON, per-request strict OFF: the proxy engages + // and the relay entry must surface the override like every other + // strict-influenced journal entry on this surface. + const res = await fetch(`${m.url}/api/v1/videos/models`, { + headers: { "X-AIMock-Strict": "false" }, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + expect(upstream.counts.models).toBe(1); + + const entry = m.journal + .getAll() + .find((e) => e.method === "GET" && e.path === "/api/v1/videos/models"); + expect(entry).toBeDefined(); + expect(entry!.response.source).toBe("proxy"); + expect(entry!.response.strictOverride).toBe(false); + }); +}); + +// ─── Round 6 CR: proxyOnly stash clobber, strictOverride journal consistency, +// capture-failure body samples, warn latches, envelope-read bounding ───────── + +describe("OpenRouter video record — round 6 CR", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let rawUpstream: { url: string; close: () => Promise } | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + await rawUpstream?.close(); + rawUpstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function startMock( + upstreamUrl: string, + extra?: Partial & { logLevel?: "silent" | "warn"; strict?: boolean }, + ): Promise { + const { logLevel, strict, ...record } = extra ?? {}; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-or-video-r6-")); + const m = new LLMock({ + port: 0, + logLevel: logLevel ?? "silent", + strict, + record: { providers: { openrouter: upstreamUrl }, fixturePath: tmpDir, ...record }, + }); + await m.start(); + mock = m; + return m; + } + + const auth = { Authorization: "Bearer sk-r6" }; + + async function submitJob( + m: LLMock, + prompt: string, + headers?: Record, + ): Promise<{ id: string; polling_url: string }> { + const res = await fetch(`${m.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth, ...headers }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; polling_url: string }; + } + + test("proxyOnly: a later completed poll without unsigned_urls does not clobber a usable stash", async () => { + const bytes = Buffer.from("stash survives bytes"); + let terminalPolls = 0; + upstream = await startOpenRouterVideoUpstream({ + videoBytes: bytes, + statusBody: (selfUrl, jobId) => { + terminalPolls++; + // First completed poll carries the urls; the second omits them (a + // defective later poll must not clobber the usable stash). + return terminalPolls === 1 + ? { + id: jobId, + status: "completed", + unsigned_urls: [`${selfUrl}/api/v1/videos/${jobId}/content?index=0`], + } + : { id: jobId, status: "completed" }; + }, + }); + const m = await startMock(upstream.url, { proxyOnly: true }); + const envelope = await submitJob(m, "stash clobber render"); + + const first = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + unsigned_urls: string[]; + }; + expect(first.status).toBe("completed"); + expect(first.unsigned_urls).toHaveLength(1); + + const second = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + status: string; + unsigned_urls?: unknown; + }; + expect(second.status).toBe("completed"); + expect(second.unsigned_urls).toBeUndefined(); + + // The download after the defective second poll must still live-proxy the + // FIRST poll's stashed upstream URL instead of 502ing on a clobbered + // (undefined) stash. + const dl = await fetch(first.unsigned_urls[0], { headers: auth }); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).equals(bytes)).toBe(true); + }); + + test("a strict-OFF override on a record-job poll journals strictOverride: false (proxied entries)", async () => { + upstream = await startOpenRouterVideoUpstream({ pollsBeforeCompleted: 2 }); + const m = await startMock(upstream.url, { strict: true }); + const override = { "X-AIMock-Strict": "false" }; + + const envelope = await submitJob(m, "strict override journal", override); + const poll = await fetch(envelope.polling_url, { headers: { ...auth, ...override } }); + expect(poll.status).toBe(200); + await poll.arrayBuffer(); + expect(upstream.counts.status).toBe(1); + + // The proxied poll entry must surface the override like every other + // strict-influenced journal entry on this surface (file-wide convention). + const entry = m.journal + .getAll() + .find((e) => e.method === "GET" && e.path.startsWith(`/api/v1/videos/${envelope.id}`)); + expect(entry).toBeDefined(); + expect(entry!.response.source).toBe("proxy"); + expect(entry!.response.strictOverride).toBe(false); + + // The proxied submit entry carries it too. + const submitEntry = m.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/api/v1/videos"); + expect(submitEntry).toBeDefined(); + expect(submitEntry!.response.source).toBe("proxy"); + expect(submitEntry!.response.strictOverride).toBe(false); + }); + + test("a capture content-fetch failure names the upstream status AND a body sample", async () => { + upstream = await startOpenRouterVideoUpstream({ contentStatus: 500 }); + const m = await startMock(upstream.url, { logLevel: "warn" }); + const envelope = await submitJob(m, "capture failure sample"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await fetch(envelope.polling_url, { headers: auth }); + expect(poll.status).toBe(200); + await poll.arrayBuffer(); + await waitUntil(() => warnSpy.mock.calls.some((c) => c.join(" ").includes("capture failed"))); + + const line = warnSpy.mock.calls.map((c) => c.join(" ")).find((l) => l.includes("Content 500")); + expect(line).toBeDefined(); + // The bounded body sample makes the failure diagnosable (mirrors the + // submit/poll proxy errors, which always carried a body snippet). + expect(line).toContain("stub content error"); + }); + + test("a proxy-only content-fetch failure 502s with the upstream status AND a body sample", async () => { + upstream = await startOpenRouterVideoUpstream({ contentStatus: 500 }); + const m = await startMock(upstream.url, { proxyOnly: true }); + const envelope = await submitJob(m, "proxy content failure sample"); + const poll = (await (await fetch(envelope.polling_url, { headers: auth })).json()) as { + unsigned_urls: string[]; + }; + + const dl = await fetch(poll.unsigned_urls[0], { headers: auth }); + expect(dl.status).toBe(502); + const body = (await dl.json()) as { error: { message: string } }; + expect(body.error.message).toContain("Content 500"); + expect(body.error.message).toContain("stub content error"); + }); + + test("the non-array unsigned_urls warn fires once per job, not once per poll", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: `${selfUrl}/api/v1/videos/${jobId}/content?secret=1`, + }), + }); + const m = await startMock(upstream.url, { proxyOnly: true, logLevel: "warn" }); + const envelope = await submitJob(m, "non-array warn latch"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + for (let i = 0; i < 2; i++) { + const poll = await fetch(envelope.polling_url, { headers: auth }); + expect(poll.status).toBe(200); + await poll.arrayBuffer(); + } + + const warns = warnSpy.mock.calls.filter((c) => c.join(" ").includes("non-array unsigned_urls")); + expect(warns).toHaveLength(1); + }); + + test("the non-number usage.cost warn fires once per job, not once per poll", async () => { + upstream = await startOpenRouterVideoUpstream({ + statusBody: (selfUrl, jobId) => ({ + id: jobId, + status: "completed", + unsigned_urls: [`${selfUrl}/api/v1/videos/${jobId}/content?index=0`], + usage: { cost: "lots" }, + }), + }); + const m = await startMock(upstream.url, { proxyOnly: true, logLevel: "warn" }); + const envelope = await submitJob(m, "bad cost warn latch"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + for (let i = 0; i < 2; i++) { + const poll = await fetch(envelope.polling_url, { headers: auth }); + expect(poll.status).toBe(200); + await poll.arrayBuffer(); + } + + const warns = warnSpy.mock.calls.filter((c) => c.join(" ").includes("non-number usage.cost")); + expect(warns).toHaveLength(1); + }); + + test("an over-cap models envelope is refused and falls back to the synthesized listing", async () => { + // A pathological multi-megabyte "envelope" must not be buffered in full + // and relayed — the bounded read refuses it and the synthesis serves. + let selfUrl = "http://stub"; + rawUpstream = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: [{ id: "x".repeat(2 * 1024 * 1024) }] })); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + const m = await startMock(rawUpstream.url); + m.addFixture({ + match: { userMessage: "synth model render", endpoint: "video", model: "synth/model-1" }, + response: { video: { id: "v-synth", status: "completed" } }, + }); + + const res = await fetch(`${m.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = (await res.json()) as { data: { id: string }[] }; + // Synthesized from fixtures — not the over-cap upstream listing. + expect(data.data.map((e) => e.id)).toEqual(["synth/model-1"]); + }); +}); diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index b73d91d1..ec87e550 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1,16 +1,31 @@ -import { describe, test, expect, afterEach, vi } from "vitest"; +import { describe, test, expect, afterAll, afterEach, vi } from "vitest"; import * as http from "node:http"; import { LLMock } from "../llmock.js"; import { createServer } from "../server.js"; import { resolveProgression } from "../fal.js"; -import { OpenRouterVideoJobMap, OPENROUTER_VIDEO_MAX_ENTRIES } from "../openrouter-video.js"; -import type { VideoResponse } from "../types.js"; +import { + OpenRouterVideoJobMap, + OPENROUTER_VIDEO_MAX_ENTRIES, + handleOpenRouterVideoStatus, + type OpenRouterVideoJob, +} from "../openrouter-video.js"; +import { Journal } from "../journal.js"; +import { Logger } from "../logger.js"; +import { DEFAULT_TEST_ID } from "../constants.js"; +import type { HandlerDefaults, VideoResponse } from "../types.js"; import { SKIPPED_BY_STATE_RE } from "./helpers/strict-matchers.js"; +import { startRefusingUpstream } from "./helpers/refusing-upstream.js"; + +// Deterministically-failing upstream: the port is held by a live +// connection-destroying server for the whole suite (no TOCTOU re-bind window). +const refusingUpstream = await startRefusingUpstream(); +const UPSTREAM_DOWN_URL = refusingUpstream.url; +afterAll(() => refusingUpstream.close()); // ─── Task 1: shared progression resolver + extended video fixture fields ─── describe("resolveProgression (shared with fal queue)", () => { - test("is exported and defaults to 0/0 (complete on first poll)", () => { + test("is exported and defaults to 0/0 (seeded terminal at submit)", () => { expect(resolveProgression(undefined)).toEqual({ pollsBeforeInProgress: 0, pollsBeforeCompleted: 0, @@ -841,7 +856,7 @@ describe("OpenRouter video — chaos source label and models route", () => { mock = undefined; }); - test("submit chaos with no fixture journals source internal (surface never proxies)", async () => { + test("submit chaos with no fixture journals source internal when record is not configured", async () => { mock = new LLMock({ port: 0 }); await mock.start(); @@ -902,14 +917,21 @@ describe("OpenRouter video — logger observability", () => { body: JSON.stringify({ model: "m/v", prompt: "still processing" }), }); expect(res.status).toBe(200); - expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("processing"))).toBe(true); + // Assert the coercion warn specifically — the fixture text itself contains + // "processing", so a bare includes("processing") could match any log line + // that echoes the prompt. + expect( + warnSpy.mock.calls.some((c) => + c.join(" ").includes('status "processing" — treated as completed'), + ), + ).toBe(true); }); - test("warns about the record-mode gap on no-match when record is configured", async () => { + test("record-gap warn is retired: record without an openrouter upstream warns about the missing provider URL", async () => { mock = new LLMock({ port: 0, logLevel: "warn", - record: { providers: { openai: "http://127.0.0.1:9" } }, + record: { providers: { openai: UPSTREAM_DOWN_URL } }, }); await mock.start(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -920,8 +942,15 @@ describe("OpenRouter video — logger observability", () => { body: JSON.stringify({ model: "m/v", prompt: "unrecorded prompt" }), }); expect(res.status).toBe(404); + // Record mode IS supported on this surface now — the old gap warn is gone; + // the actionable warn is the missing provider URL (fal convention). expect( warnSpy.mock.calls.some((c) => c.join(" ").includes("record mode is not supported")), + ).toBe(false); + expect( + warnSpy.mock.calls.some((c) => + c.join(" ").includes('No upstream URL configured for provider "openrouter"'), + ), ).toBe(true); }); @@ -938,6 +967,9 @@ describe("OpenRouter video — logger observability", () => { expect( warnSpy.mock.calls.some((c) => c.join(" ").includes("record mode is not supported")), ).toBe(false); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("No upstream URL configured"))).toBe( + false, + ); }); test("no-match debug log includes model and prompt snippet", async () => { @@ -1352,6 +1384,47 @@ describe("OpenRouter video — full lifecycle integration", () => { expect(body.equals(bytes)).toBe(true); }); + test("each replay status poll refreshes the job TTL — long polled generations survive", async () => { + // Round-3 B3: mirrors the record-job TTL-refresh guarantee on the replay + // path. Without the refresh, the second poll below finds the job evicted + // (110 minutes after submit) and 404s, even though the client polled only + // 55 minutes ago. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + mock = new LLMock({ + port: 0, + openRouterVideo: { pollsBeforeInProgress: 5, pollsBeforeCompleted: 10 }, + }); + mock.addFixture({ + match: { userMessage: "slow replay gen", endpoint: "video" }, + response: { video: { id: "vid_ttl", status: "completed" } }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "slow replay gen" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + // 55 minutes later (inside the 1h TTL): poll succeeds and refreshes. + vi.setSystemTime(Date.now() + 55 * 60_000); + const refresh = await fetch(envelope.polling_url); + expect(refresh.status).toBe(200); + await refresh.arrayBuffer(); // consume the body so the socket is released + + // Another 55 minutes: 110min since submit, 55min since the last poll — + // the job must still be alive. + vi.setSystemTime(Date.now() + 55 * 60_000); + const res = await fetch(envelope.polling_url); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("pending"); + } finally { + vi.useRealTimers(); + } + }); + test("failed lifecycle: submit → poll failed → download rejected", async () => { mock = new LLMock({ port: 0 }); mock.addFixture({ @@ -2112,6 +2185,7 @@ describe("OpenRouterVideoJobMap (unit)", () => { function makeJob(id: string) { return { + kind: "replay" as const, jobId: id, status: "pending" as const, pollCount: 0, @@ -2151,6 +2225,27 @@ describe("OpenRouterVideoJobMap (unit)", () => { expect(map.get("t:job1")).toBeDefined(); expect(map.get(`t:job${CAP}`)).toBeDefined(); // newest entry retained }); + + test("set() refreshes insertion order — a TTL-refreshed key survives FIFO eviction", () => { + // The record path documents that each successful proxied poll refreshes + // the job's TTL via set(). For that guarantee to hold under capacity + // pressure, a refreshed key must move to the BACK of the Map's insertion + // order — otherwise FIFO eviction still treats it as the oldest entry. + const map = new OpenRouterVideoJobMap(); + const CAP = OPENROUTER_VIDEO_MAX_ENTRIES; + const job = makeJob("shared"); + for (let i = 0; i < CAP; i++) { + map.set(`t:job${i}`, job); + } + // Refresh the oldest key, then overflow by one. + map.set("t:job0", job); + expect(map.size).toBe(CAP); + map.set(`t:job${CAP}`, job); + expect(map.size).toBe(CAP); + expect(map.get("t:job0")).toBeDefined(); // refreshed — must survive + expect(map.get("t:job1")).toBeUndefined(); // now the true oldest — evicted + expect(map.get(`t:job${CAP}`)).toBeDefined(); + }); }); describe("OpenRouter video — reset plumbing", () => { @@ -2879,3 +2974,260 @@ describe("OpenRouter video — CR round 8 contract pins", () => { expect(entry!.response.source).toBe("internal"); }); }); + +// ─── Round 5 CR: disconnected replay polls + once-per-job empty-error warn ─── + +describe("OpenRouter video status — disconnected replay poll (round 5)", () => { + function fakeReq(): http.IncomingMessage { + return { + method: "GET", + url: "/api/v1/videos/job-r5", + headers: { host: "127.0.0.1:4010" }, + } as unknown as http.IncomingMessage; + } + + function fakeRes(opts: { destroyed: boolean }): { + res: http.ServerResponse; + body: () => string; + } { + const chunks: Buffer[] = []; + const res = { + statusCode: 200, + headersSent: false, + destroyed: opts.destroyed, + writableEnded: false, + writeHead(status: number) { + res.statusCode = status; + (res as unknown as { headersSent: boolean }).headersSent = true; + return res; + }, + write(data: string | Buffer) { + chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); + return true; + }, + end(data?: string | Buffer) { + if (data) chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); + (res as unknown as { writableEnded: boolean }).writableEnded = true; + }, + setHeader() { + return res; + }, + on() { + return res; + }, + once() { + return res; + }, + off() { + return res; + }, + } as unknown as http.ServerResponse; + return { res, body: () => Buffer.concat(chunks).toString() }; + } + + test("an aborted poll consumes no progression step — the next live poll observes it", async () => { + const jobs = new OpenRouterVideoJobMap(); + const job: OpenRouterVideoJob = { + kind: "replay", + jobId: "job-r5", + status: "pending", + pollCount: 0, + pollsBeforeInProgress: 1, + pollsBeforeCompleted: 2, + video: { id: "v-r5", status: "completed" }, + }; + jobs.set(`${DEFAULT_TEST_ID}:job-r5`, job); + const journal = new Journal(); + const defaults: HandlerDefaults = { + latency: 0, + chunkSize: 20, + replaySpeed: 1, + logger: new Logger("silent"), + }; + + // A poll whose client disconnected while it was queued: no write, no + // journal entry — and no progression step consumed. + const dead = fakeRes({ destroyed: true }); + await handleOpenRouterVideoStatus( + fakeReq(), + dead.res, + "job-r5", + [], + journal, + defaults, + () => {}, + jobs, + ); + expect(dead.body()).toBe(""); + expect(journal.getAll()).toHaveLength(0); + + // The next LIVE poll gets the progression step the aborted one would + // have consumed: first actual poll → in_progress (an aborted poll that + // advanced the job would make this read "completed"). + const live = fakeRes({ destroyed: false }); + await handleOpenRouterVideoStatus( + fakeReq(), + live.res, + "job-r5", + [], + journal, + defaults, + () => {}, + jobs, + ); + expect((JSON.parse(live.body()) as { status: string }).status).toBe("in_progress"); + }); +}); + +describe("OpenRouter video status — empty-error warn fires once per job (round 5)", () => { + let mock: LLMock; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + }); + + test("a polling loop on an empty-error failed fixture warns once, not per poll", async () => { + mock = new LLMock({ port: 0, logLevel: "warn" }); + mock.addFixture({ + match: { userMessage: "warn once render", endpoint: "video" }, + response: { video: { id: "v-warn-once", status: "failed", error: "" } }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "warn once render" }), + }); + const envelope = (await submit.json()) as { polling_url: string }; + + for (let i = 0; i < 3; i++) { + const poll = await fetch(envelope.polling_url); + expect(poll.status).toBe(200); + expect(((await poll.json()) as { status: string }).status).toBe("failed"); + } + + const emptyErrorWarns = warnSpy.mock.calls.filter((c) => + c.join(" ").includes("empty error message"), + ); + expect(emptyErrorWarns).toHaveLength(1); + }); +}); + +// ─── Round 6 CR: mid-await reset on the replay submit + content-warn latch ── + +describe("OpenRouter video submit — fixtures reset during a slow ResponseFactory (round 6)", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + test("a reset landing while the factory is awaited never seeds the new world", async () => { + mock = new LLMock({ port: 0, logLevel: "silent" }); + mock.addFixture({ + match: { userMessage: "slow factory render", endpoint: "video" }, + response: async () => { + await new Promise((r) => setTimeout(r, 400)); + return { video: { id: "v-slow", status: "completed" as const } }; + }, + }); + await mock.start(); + + const submitPromise = fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "slow factory render" }), + }); + // The factory is mid-await — reset the world underneath it. + await new Promise((r) => setTimeout(r, 150)); + const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" }); + expect(reset.status).toBe(200); + await reset.arrayBuffer(); + + // The envelope still relays (mirrors the record-submit guard's + // documented choice)... + const res = await submitPromise; + expect(res.status).toBe(200); + const envelope = (await res.json()) as { polling_url: string }; + + // ...but the job was NOT inserted into the new world — its poll 404s + // instead of resurrecting pre-reset state. + const poll = await fetch(envelope.polling_url); + expect(poll.status).toBe(404); + await poll.arrayBuffer(); + }); +}); + +describe("OpenRouter video content — fixture-defect warns fire once per job (round 6)", () => { + let mock: LLMock; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + }); + + test("re-downloading an empty-b64 fixture warns once, not per download", async () => { + mock = new LLMock({ port: 0, logLevel: "warn" }); + mock.addFixture({ + match: { userMessage: "empty b64 render", endpoint: "video" }, + response: { video: { id: "v-empty-b64", status: "completed", b64: "", url: "https://x/v" } }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "empty b64 render" }), + }); + const envelope = (await submit.json()) as { id: string }; + + for (let i = 0; i < 2; i++) { + const dl = await fetch(`${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, { + headers: { Authorization: "Bearer sk-warn" }, + }); + expect(dl.status).toBe(200); + await dl.arrayBuffer(); + } + + const emptyWarns = warnSpy.mock.calls.filter((c) => c.join(" ").includes("empty b64")); + expect(emptyWarns).toHaveLength(1); + const urlWarns = warnSpy.mock.calls.filter((c) => c.join(" ").includes("url is ignored")); + expect(urlWarns).toHaveLength(1); + }); + + test("re-downloading a corrupt-b64 fixture warns once, not per download", async () => { + mock = new LLMock({ port: 0, logLevel: "warn" }); + mock.addFixture({ + match: { userMessage: "corrupt b64 render", endpoint: "video" }, + // Invalid characters Node's lenient decoder skips — decoded byte count + // falls short of what the length implies. + response: { video: { id: "v-corrupt-b64", status: "completed", b64: "AA!!AA!!" } }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const submit = await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "corrupt b64 render" }), + }); + const envelope = (await submit.json()) as { id: string }; + + for (let i = 0; i < 2; i++) { + const dl = await fetch(`${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, { + headers: { Authorization: "Bearer sk-warn" }, + }); + expect(dl.status).toBe(200); + await dl.arrayBuffer(); + } + + const corruptWarns = warnSpy.mock.calls.filter((c) => + c.join(" ").includes("likely corrupt base64"), + ); + expect(corruptWarns).toHaveLength(1); + }); +}); diff --git a/src/__tests__/recorder.test.ts b/src/__tests__/recorder.test.ts index 0265882e..f430af6d 100644 --- a/src/__tests__/recorder.test.ts +++ b/src/__tests__/recorder.test.ts @@ -5,7 +5,13 @@ import * as os from "node:os"; import * as path from "node:path"; import type { Fixture, FixtureFile } from "../types.js"; import { createServer, type ServerInstance } from "../server.js"; -import { proxyAndRecord, buildFixtureMatch, type ProxyCapturedResponse } from "../recorder.js"; +import { + proxyAndRecord, + buildFixtureMatch, + persistFixture, + sanitizeHeaderValue, + type ProxyCapturedResponse, +} from "../recorder.js"; import type { RecordConfig } from "../types.js"; import { Logger } from "../logger.js"; import { LLMock } from "../llmock.js"; @@ -3068,6 +3074,57 @@ describe("recorder auth header handling", () => { await new Promise((resolve) => echoServer.close(() => resolve())); }); + + it("mock-internal control headers never reach a generic upstream", async () => { + // Pins the CHANGELOG claim that x-test-id / x-aimock-strict / + // x-aimock-context / x-aimock-chaos-* are stripped on every provider + // proxy path — this is the generic proxyAndRecord walk (STRIP_HEADERS + + // the chaos prefix family in buildForwardHeaders). + let receivedHeaders: http.IncomingHttpHeaders = {}; + const echoServer = http.createServer((req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "echo" }, index: 0 }], + model: "gpt-4", + }), + ); + }); + await new Promise((resolve) => echoServer.listen(0, "127.0.0.1", resolve)); + const echoAddr = echoServer.address() as { port: number }; + const echoUrl = `http://127.0.0.1:${echoAddr.port}`; + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-record-")); + recorder = await createServer([], { + port: 0, + record: { providers: { openai: echoUrl }, fixturePath: tmpDir }, + }); + + await post( + `${recorder.url}/v1/chat/completions`, + { + model: "gpt-4", + messages: [{ role: "user", content: "internal header strip" }], + }, + { + Authorization: "Bearer sk-test", + "X-Test-Id": "generic-hdr-strip", + "X-AIMock-Strict": "false", + "X-AIMock-Context": "ctx-strip", + "X-AIMock-Chaos-Drop": "0", + }, + ); + + expect(receivedHeaders["x-test-id"]).toBeUndefined(); + expect(receivedHeaders["x-aimock-strict"]).toBeUndefined(); + expect(receivedHeaders["x-aimock-context"]).toBeUndefined(); + expect(receivedHeaders["x-aimock-chaos-drop"]).toBeUndefined(); + // Auth still forwarded. + expect(receivedHeaders["authorization"]).toBe("Bearer sk-test"); + + await new Promise((resolve) => echoServer.close(() => resolve())); + }); }); // --------------------------------------------------------------------------- @@ -5159,39 +5216,41 @@ describe("makeUpstreamRequest body timeout", () => { await new Promise((resolve) => fastRawServer!.listen(0, "127.0.0.1", resolve)); const port2 = (fastRawServer!.address() as { port: number }).port; + // try/finally so a failing assertion cannot leak the extra tmpDir. const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-record-clamp-neg-")); - const record2: RecordConfig = { - providers: { openai: `http://127.0.0.1:${port2}` }, - fixturePath: tmpDir2, - bodyTimeoutMs: -500, - }; - - const { req: req2, res: res2 } = createMockReqRes(); - const chunks2: Buffer[] = []; - Object.assign(res2, { - writeHead: () => res2, - end: (data?: Buffer | string) => { - if (data) chunks2.push(typeof data === "string" ? Buffer.from(data) : data); - return res2; - }, - setHeader: () => res2, - }); + try { + const record2: RecordConfig = { + providers: { openai: `http://127.0.0.1:${port2}` }, + fixturePath: tmpDir2, + bodyTimeoutMs: -500, + }; - await proxyAndRecord( - req2, - res2, - { model: "gpt-4", messages: [{ role: "user", content: "hello" }] }, - "openai", - "/v1/chat/completions", - fixtures, - { record: record2, logger }, - ); + const { req: req2, res: res2 } = createMockReqRes(); + const chunks2: Buffer[] = []; + Object.assign(res2, { + writeHead: () => res2, + end: (data?: Buffer | string) => { + if (data) chunks2.push(typeof data === "string" ? Buffer.from(data) : data); + return res2; + }, + setHeader: () => res2, + }); - // Negative values should also be clamped to 30_000 - expect(setTimeoutSpy).toHaveBeenCalledWith(30_000, expect.any(Function)); + await proxyAndRecord( + req2, + res2, + { model: "gpt-4", messages: [{ role: "user", content: "hello" }] }, + "openai", + "/v1/chat/completions", + fixtures, + { record: record2, logger }, + ); - // Clean up the extra tmpDir - fs.rmSync(tmpDir2, { recursive: true, force: true }); + // Negative values should also be clamped to 30_000 + expect(setTimeoutSpy).toHaveBeenCalledWith(30_000, expect.any(Function)); + } finally { + fs.rmSync(tmpDir2, { recursive: true, force: true }); + } }); }); @@ -5750,8 +5809,9 @@ describe("multi-call fixture disambiguation (issue #185)", () => { // Calls 2 and 3 share identical match criteria (model + userMessage + // turnIndex + hasToolResult). systemHash is metadata for drift detection, - // not a match discriminator — so the second haiku call overwrites the first - // in the recorder cache. Only 2 distinct files are produced. + // not a match discriminator — so call 3 MATCHES the in-memory fixture + // call 2 just recorded and replays it (no second proxy, no overwrite). + // Only 2 distinct files are produced. const files = fs.readdirSync(fixturePath).filter((f) => f.endsWith(".json")); expect(files.length).toBe(2); @@ -6517,3 +6577,151 @@ describe("recorder frame-timing: CRLF delimiters", () => { await new Promise((resolve) => rawServer.close(() => resolve())); }); }); + +describe("persistFixture snapshot merge — _warning carry-forward", () => { + it("a later clean capture into the same snapshot file retains the existing _warning", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-persist-warn-")); + try { + const record: RecordConfig = { + providers: { openai: "http://127.0.0.1:1" }, + fixturePath: dir, + }; + const logger = new Logger("silent"); + const fixtures: Fixture[] = []; + + const first = persistFixture({ + record, + providerKey: "openai", + testId: "warn-merge", + fixture: { match: { userMessage: "first capture" }, response: { content: "a" } }, + fixtures, + warnings: ["W1 original over-cap warning"], + logger, + }); + expect(first.kind).toBe("written"); + + // Second, clean capture for the same testId + provider merges into the + // same snapshot file — the original _warning must survive the rewrite. + const second = persistFixture({ + record, + providerKey: "openai", + testId: "warn-merge", + fixture: { match: { userMessage: "second capture" }, response: { content: "b" } }, + fixtures, + logger, + }); + expect(second.kind).toBe("written"); + + const file = JSON.parse( + fs.readFileSync(path.join(dir, "warn-merge", "openai.json"), "utf-8"), + ) as { fixtures: unknown[]; _warning?: string }; + expect(file.fixtures).toHaveLength(2); + expect(file._warning).toContain("W1 original over-cap warning"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a re-emitted warning does not accumulate into the carried-forward _warning (round 6)", () => { + // The existing _warning is a "; "-joined string — it must be split back + // into its elements before deduping, or "A; B" + "A" becomes "A; B; A". + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-persist-warn-dedupe-")); + try { + const record: RecordConfig = { + providers: { openai: "http://127.0.0.1:1" }, + fixturePath: dir, + }; + const logger = new Logger("silent"); + const fixtures: Fixture[] = []; + + const first = persistFixture({ + record, + providerKey: "openai", + testId: "warn-dedupe", + fixture: { match: { userMessage: "first capture" }, response: { content: "a" } }, + fixtures, + warnings: ["W-A repeated warning", "W-B other warning"], + logger, + }); + expect(first.kind).toBe("written"); + + // The second capture re-emits W-A — the merged _warning must stay + // exactly the two original entries. + const second = persistFixture({ + record, + providerKey: "openai", + testId: "warn-dedupe", + fixture: { match: { userMessage: "second capture" }, response: { content: "b" } }, + fixtures, + warnings: ["W-A repeated warning"], + logger, + }); + expect(second.kind).toBe("written"); + + const file = JSON.parse( + fs.readFileSync(path.join(dir, "warn-dedupe", "openai.json"), "utf-8"), + ) as { fixtures: unknown[]; _warning?: string }; + expect(file._warning).toBe("W-A repeated warning; W-B other warning"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a non-array `fixtures` in the existing snapshot file is discarded with a warn, not spread", () => { + // Spreading a string `fixtures` would silently mangle the file into an + // array of single characters plus the new fixture. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-persist-nonarray-")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const record: RecordConfig = { + providers: { openai: "http://127.0.0.1:1" }, + fixturePath: dir, + }; + const logger = new Logger("warn"); + const fixtures: Fixture[] = []; + + const fileDir = path.join(dir, "nonarray-merge"); + fs.mkdirSync(fileDir, { recursive: true }); + fs.writeFileSync( + path.join(fileDir, "openai.json"), + JSON.stringify({ fixtures: "oops not an array" }), + "utf-8", + ); + + const result = persistFixture({ + record, + providerKey: "openai", + testId: "nonarray-merge", + fixture: { match: { userMessage: "fresh capture" }, response: { content: "a" } }, + fixtures, + logger, + }); + expect(result.kind).toBe("written"); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("non-array"))).toBe(true); + + const file = JSON.parse(fs.readFileSync(path.join(fileDir, "openai.json"), "utf-8")) as { + fixtures: unknown[]; + }; + expect(Array.isArray(file.fixtures)).toBe(true); + expect(file.fixtures).toHaveLength(1); + expect(file.fixtures[0]).toMatchObject({ match: { userMessage: "fresh capture" } }); + } finally { + // Restore in the finally: a failing assertion above must not leak the + // console spy into later tests. + warnSpy.mockRestore(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("sanitizeHeaderValue", () => { + it("replaces characters Node rejects in header values and keeps Latin-1 intact", () => { + // Multibyte (>0xFF) and control characters become "?"; tab, printable + // ASCII, and the 0x80-0xFF Latin-1 range pass through untouched. + expect(sanitizeHeaderValue("ENOTDIR: not a directory, mkdir '/tmp/日本語/héllo'")).toBe( + "ENOTDIR: not a directory, mkdir '/tmp/???/héllo'", + ); + expect(sanitizeHeaderValue("line1\nline2\x7f")).toBe("line1?line2?"); + expect(sanitizeHeaderValue("plain ascii\twith tab")).toBe("plain ascii\twith tab"); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 9834bfd1..cbdf0fc3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ Options: --provider-azure Upstream URL for Azure OpenAI --provider-ollama Upstream URL for Ollama --provider-cohere Upstream URL for Cohere + --provider-openrouter Upstream URL for OpenRouter (video record proxy) --upstream-timeout-ms Idle timeout (ms) on upstream socket before response (default: 30000) --body-timeout-ms Idle timeout (ms) on upstream response body between chunks (default: 30000) --agui-record Enable AG-UI recording (proxy unmatched AG-UI requests) @@ -74,6 +75,7 @@ const { values } = parseArgs({ "provider-azure": { type: "string" }, "provider-ollama": { type: "string" }, "provider-cohere": { type: "string" }, + "provider-openrouter": { type: "string" }, "upstream-timeout-ms": { type: "string" }, "body-timeout-ms": { type: "string" }, "agui-record": { type: "boolean", default: false }, @@ -135,7 +137,7 @@ if (Number.isNaN(replaySpeed) || replaySpeed <= 0) { const journalMax = Number(values["journal-max"]); if (Number.isNaN(journalMax) || !Number.isInteger(journalMax) || journalMax < 0) { console.error( - `Invalid journal-max: ${values["journal-max"]} (must be a non-negative integer; 0 or omitted = unbounded)`, + `Invalid journal-max: ${values["journal-max"]} (must be a non-negative integer; 0 = unbounded)`, ); process.exit(1); } @@ -223,6 +225,7 @@ if (values.record || values["proxy-only"]) { if (values["provider-azure"]) providers.azure = values["provider-azure"]; if (values["provider-ollama"]) providers.ollama = values["provider-ollama"]; if (values["provider-cohere"]) providers.cohere = values["provider-cohere"]; + if (values["provider-openrouter"]) providers.openrouter = values["provider-openrouter"]; if (Object.keys(providers).length === 0) { console.error( @@ -254,6 +257,33 @@ if (values.record || values["proxy-only"]) { upstreamTimeoutMs, bodyTimeoutMs, }; +} else { + // These flags configure upstream proxying — without --record or + // --proxy-only they would be parsed and then silently dropped. Routed + // through the constructed logger so --log-level is respected. + const droppedProviderFlags = ( + [ + "provider-openai", + "provider-anthropic", + "provider-gemini", + "provider-vertexai", + "provider-bedrock", + "provider-azure", + "provider-ollama", + "provider-cohere", + "provider-openrouter", + ] as const + ).filter((flag) => values[flag] !== undefined); + if (droppedProviderFlags.length > 0) { + logger.warn( + `--${droppedProviderFlags.join("/--")} only apply to --record/--proxy-only upstream proxying — ignored without one of those flags.`, + ); + } + if (upstreamTimeoutMs !== undefined || bodyTimeoutMs !== undefined) { + logger.warn( + "--upstream-timeout-ms/--body-timeout-ms only apply to --record/--proxy-only upstream proxying — ignored without one of those flags.", + ); + } } // Parse AG-UI record/proxy config from CLI flags diff --git a/src/fal-audio.ts b/src/fal-audio.ts index 350675ef..f2a01b87 100644 --- a/src/fal-audio.ts +++ b/src/fal-audio.ts @@ -24,8 +24,14 @@ import { } from "./helpers.js"; import { writeErrorResponse } from "./sse-writer.js"; import { matchFixtureDiagnostic } from "./router.js"; -import { buildFixtureMatch, persistFixture, proxyAndRecord } from "./recorder.js"; -import { buildFalForwardHeaders, walkFalQueue } from "./fal.js"; +import { + buildFixtureMatch, + buildForwardHeaders, + persistFixture, + proxyAndRecord, + sanitizeHeaderValue, +} from "./recorder.js"; +import { walkFalQueue } from "./fal.js"; import type { Journal } from "./journal.js"; import { applyChaos } from "./chaos.js"; @@ -509,7 +515,7 @@ async function handleQueueSubmit( * * Returns `true` if the request has been handled (response written and * journaled); `false` if recording wasn't configured for this provider and the - * caller should fall through to strict/404. + * caller should fall through to 404 (strict is gated before recording). */ async function tryRecordAudioQueueWalk(args: { req: http.IncomingMessage; @@ -567,16 +573,22 @@ async function tryRecordAudioQueueWalk(args: { upstreamBase, submitPath: pathname, body, - headers: buildFalForwardHeaders(req), + headers: buildForwardHeaders(req), pollIntervalMs: record.fal?.pollIntervalMs, timeoutMs: record.fal?.timeoutMs, + upstreamTimeoutMs: record.upstreamTimeoutMs, // Legacy aimock-style paths, not the model-prefixed fal.ai layout. fallbackStatusPath: (id) => `/fal/queue/requests/${id}/status`, fallbackResultPath: (id) => `/fal/queue/requests/${id}`, + logger: defaults.logger, }); } catch (err) { const msg = err instanceof Error ? err.message : "Unknown queue-walk error"; defaults.logger.error(`fal-audio queue-walk proxy failed: ${msg}`); + // Guard BEFORE journaling (openrouter-video convention): a client that + // disconnected during the multi-second walk gets neither a write nor a + // journal entry. + if (res.destroyed || res.writableEnded) return true; journal.add({ method: req.method ?? "POST", path: pathname, @@ -595,6 +607,8 @@ async function tryRecordAudioQueueWalk(args: { if (!finalBody || typeof finalBody !== "object") { defaults.logger.error("fal-audio queue-walk produced non-object result"); + // Same disconnect guard as the catch above. + if (res.destroyed || res.writableEnded) return true; journal.add({ method: req.method ?? "POST", path: pathname, @@ -618,7 +632,7 @@ async function tryRecordAudioQueueWalk(args: { match: buildFixtureMatch(matchRequest, record), response: { json: finalBody, status: 200 }, }; - persistFixture({ + const persistResult = persistFixture({ record, providerKey: "fal", testId, @@ -626,6 +640,12 @@ async function tryRecordAudioQueueWalk(args: { fixtures, logger: defaults.logger, }); + // Surface a persist failure on the envelope (parity with fal.ts's + // queue-walk record path and the generic recorder relay) — the synthesized + // envelope below has not been written yet, so the header can still ride it. + if (persistResult.kind === "failed" && !res.headersSent) { + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); + } const requestId = crypto.randomUUID(); const job: FalJob = { @@ -636,6 +656,11 @@ async function tryRecordAudioQueueWalk(args: { }; falJobs.set(`${testId}:${requestId}`, job); + // Guard BEFORE journaling (openrouter-video convention): a client that + // disconnected during the multi-second walk gets neither a write nor a + // journal entry. The persisted fixture and seeded job above stay — the + // captured upstream response is valuable regardless. + if (res.destroyed || res.writableEnded) return true; journal.add({ method: req.method ?? "POST", path: pathname, diff --git a/src/fal.ts b/src/fal.ts index 07273cf6..ec6bd9a0 100644 --- a/src/fal.ts +++ b/src/fal.ts @@ -26,7 +26,15 @@ import { } from "./helpers.js"; import { writeErrorResponse } from "./sse-writer.js"; import { matchFixtureDiagnostic } from "./router.js"; -import { buildFixtureMatch, persistFixture, proxyAndRecord } from "./recorder.js"; +import type { Logger } from "./logger.js"; +import { + buildFixtureMatch, + buildForwardHeaders, + clampTimeout, + persistFixture, + proxyAndRecord, + sanitizeHeaderValue, +} from "./recorder.js"; import { resolveUpstreamUrl } from "./url.js"; import type { Journal } from "./journal.js"; import { audioToFalFile } from "./fal-audio.js"; @@ -597,7 +605,8 @@ export async function handleFal( journal, }); if (outcome === "handled") return "handled"; - // outcome === "no_upstream" — fall through to strict/404 + // outcome === "no_upstream" — fall through to 404 (strict was + // already handled above) } else { const outcome = await proxyAndRecord( req, @@ -775,33 +784,16 @@ const DEFAULT_FAL_POLL_INTERVAL_MS = 1000; // on the upstream queue; 15 min gives headroom without trapping a genuinely // hung job indefinitely. const DEFAULT_FAL_TIMEOUT_MS = 900_000; +// Per-fetch upstream timeout default for the walk's submit/status/result +// fetches — same value and clamp conventions as the rest of the recording +// surfaces (recorder.ts / openrouter-video.ts). +const DEFAULT_FAL_FETCH_TIMEOUT_MS = 30_000; -// Hop-by-hop and client-set headers excluded from upstream forwarding. -// Mirrors STRIP_HEADERS in recorder.ts. -const FAL_STRIP_FORWARD_HEADERS = new Set([ - "connection", - "keep-alive", - "transfer-encoding", - "te", - "trailer", - "upgrade", - "proxy-authorization", - "proxy-authenticate", - "host", - "content-length", - "cookie", - "accept-encoding", -]); - -export function buildFalForwardHeaders(req: http.IncomingMessage): Record { - const out: Record = {}; - for (const [name, val] of Object.entries(req.headers)) { - if (val === undefined) continue; - if (FAL_STRIP_FORWARD_HEADERS.has(name.toLowerCase())) continue; - out[name] = Array.isArray(val) ? val.join(", ") : val; - } - return out; -} +// Upstream header forwarding uses buildForwardHeaders (recorder.ts) — the +// shared strip list (hop-by-hop, client-set, and the mock-internal x-test-id +// / x-aimock-strict / x-aimock-context / x-aimock-chaos-* family), so the +// fal queue walk never leaks control headers onto a real provider's wire +// (one shared list, not a per-surface copy, so the surfaces cannot drift). /** * Walk a fal-shaped queue protocol upstream: POST submit, poll status until @@ -818,6 +810,15 @@ export async function walkFalQueue(args: { headers: Record; pollIntervalMs?: number; timeoutMs?: number; + /** + * Per-fetch upstream timeout (`record.upstreamTimeoutMs` clamp conventions, + * 30s default) applied to each of the walk's submit/status/result fetches + * via AbortSignal — a hung upstream socket must not pin the walk past its + * budget. Each fetch's signal is additionally clamped to the walk's + * remaining deadline; an abort surfaces through the caller's existing + * failure handling (502, no fixture persisted). + */ + upstreamTimeoutMs?: number; /** * Build the status-poll URL from `request_id` when upstream's submit * response doesn't return a usable `status_url`. The legacy path uses @@ -826,6 +827,8 @@ export async function walkFalQueue(args: { */ fallbackStatusPath: (requestId: string) => string; fallbackResultPath: (requestId: string) => string; + /** Warn sink for the same-origin envelope-URL gate (omitting it only mutes the warns). */ + logger?: Logger; }): Promise { const { upstreamBase, @@ -834,62 +837,112 @@ export async function walkFalQueue(args: { headers, pollIntervalMs = DEFAULT_FAL_POLL_INTERVAL_MS, timeoutMs = DEFAULT_FAL_TIMEOUT_MS, + upstreamTimeoutMs, fallbackStatusPath, fallbackResultPath, + logger, } = args; const deadline = Date.now() + timeoutMs; + const perFetchTimeoutMs = clampTimeout(upstreamTimeoutMs, DEFAULT_FAL_FETCH_TIMEOUT_MS); + // Bound every upstream fetch: the per-fetch timeout, additionally clamped + // to the walk's remaining budget so a fetch can never outlive the deadline + // (floored at 1ms — AbortSignal.timeout rejects non-positive values; the + // deadline check in the poll loop is the authoritative expiry). + const fetchSignal = (): AbortSignal => + AbortSignal.timeout(Math.max(1, Math.min(perFetchTimeoutMs, deadline - Date.now()))); // ── 1. POST submit ──────────────────────────────────────────────── const submitUrl = resolveUpstreamUrl(upstreamBase, submitPath); - const submitRes = await fetch(submitUrl, { method: "POST", headers, body }); + const submitRes = await fetch(submitUrl, { + method: "POST", + headers, + body, + signal: fetchSignal(), + }); const submitText = await submitRes.text(); if (!submitRes.ok) { throw new Error(`Submit ${submitRes.status}: ${submitText.slice(0, 200)}`); } const submitJson = parseJsonOrThrow(submitText, "Submit"); + // JSON.parse admits null/arrays/scalars — reject non-object envelopes with + // a clean error instead of TypeError-ing on the field reads below (the + // OpenRouter video proxies apply the same guard). + if (submitJson === null || typeof submitJson !== "object" || Array.isArray(submitJson)) { + throw new Error("Submit response is not a JSON object"); + } const env = submitJson as Record; const upstreamRequestId = String(env.request_id ?? "").trim(); if (!upstreamRequestId) { throw new Error("Submit response missing request_id"); } - // Prefer the URLs upstream returned — a proxy in front of fal.ai may sit on - // a different host than the canonical `queue.fal.run` — and only fall back - // to constructed paths if the envelope omits them. - const envStatusUrl = env.status_url; - const envResponseUrl = env.response_url; - const statusUrl = - typeof envStatusUrl === "string" && envStatusUrl - ? new URL(envStatusUrl) - : resolveUpstreamUrl(upstreamBase, fallbackStatusPath(upstreamRequestId)); - const resultUrl = - typeof envResponseUrl === "string" && envResponseUrl - ? new URL(envResponseUrl) - : resolveUpstreamUrl(upstreamBase, fallbackResultPath(upstreamRequestId)); + // Prefer the URLs upstream returned — but ONLY same-origin with the + // configured upstream (mirroring the OpenRouter video proxy's + // polling_url gate): every status/result fetch below forwards the client's + // headers — including Authorization — so an envelope nominating a foreign + // host must never receive them. Same-origin still covers the documented + // proxy-in-front case (the configured upstream IS that proxy); off-origin, + // unparseable, or absent envelope URLs fall back to the constructed + // canonical paths on the upstream origin, with a warn. + const upstreamOrigin = submitUrl.origin; + const adoptSameOrigin = (value: unknown, label: string, fallbackPath: string): URL => { + if (typeof value === "string" && value) { + try { + const parsed = new URL(value); + if (parsed.origin === upstreamOrigin) return parsed; + logger?.warn( + `Upstream ${label} origin ${parsed.origin} differs from the upstream origin ${upstreamOrigin} — using the constructed canonical path instead`, + ); + } catch { + logger?.warn( + `Upstream ${label} is not a valid URL (${value.slice(0, 100)}) — using the constructed canonical path instead`, + ); + } + } + return resolveUpstreamUrl(upstreamBase, fallbackPath); + }; + const statusUrl = adoptSameOrigin( + env.status_url, + "status_url", + fallbackStatusPath(upstreamRequestId), + ); + const resultUrl = adoptSameOrigin( + env.response_url, + "response_url", + fallbackResultPath(upstreamRequestId), + ); // ── 2. Poll status until COMPLETED ─────────────────────────────── while (true) { if (Date.now() > deadline) throw new Error(`Queue walk timed out after ${timeoutMs}ms`); - const statusRes = await fetch(statusUrl, { headers }); + const statusRes = await fetch(statusUrl, { headers, signal: fetchSignal() }); const statusText = await statusRes.text(); if (!statusRes.ok) { throw new Error(`Status ${statusRes.status}: ${statusText.slice(0, 200)}`); } - const statusJson = parseJsonOrThrow(statusText, "Status") as Record; + const statusParsed = parseJsonOrThrow(statusText, "Status"); + // Same non-object guard as the submit envelope above. + if (statusParsed === null || typeof statusParsed !== "object" || Array.isArray(statusParsed)) { + throw new Error("Status response is not a JSON object"); + } + const statusJson = statusParsed as Record; const s = String(statusJson.status ?? ""); if (s === "COMPLETED") break; if (s === "FAILED" || s === "ERROR" || s === "CANCELLED") { throw new Error(`Upstream job terminated with status ${s}`); } const remaining = deadline - Date.now(); - const sleep = Math.min(pollIntervalMs, Math.max(0, remaining)); - if (sleep <= 0) throw new Error(`Queue walk timed out after ${timeoutMs}ms`); - await new Promise((r) => setTimeout(r, sleep)); + // Time out only when the budget is actually exhausted — a zero + // pollIntervalMs is a valid "poll as fast as possible" configuration, + // not an instant expiry (setTimeout(0) still yields the event loop). + if (remaining <= 0) throw new Error(`Queue walk timed out after ${timeoutMs}ms`); + const sleep = Math.min(pollIntervalMs, remaining); + await new Promise((r) => setTimeout(r, Math.max(0, sleep))); } // ── 3. GET final result ────────────────────────────────────────── - const resultRes = await fetch(resultUrl, { headers }); + const resultRes = await fetch(resultUrl, { headers, signal: fetchSignal() }); const resultText = await resultRes.text(); if (!resultRes.ok) { throw new Error(`Result ${resultRes.status}: ${resultText.slice(0, 200)}`); @@ -940,15 +993,21 @@ async function proxyAndRecordFalQueueSubmit(args: { upstreamBase, submitPath: strippedPath, body, - headers: buildFalForwardHeaders(req), + headers: buildForwardHeaders(req), pollIntervalMs: record.fal?.pollIntervalMs, timeoutMs: record.fal?.timeoutMs, + upstreamTimeoutMs: record.upstreamTimeoutMs, fallbackStatusPath: (id) => `${modelId}/requests/${id}/status`, fallbackResultPath: (id) => `${modelId}/requests/${id}`, + logger: defaults.logger, }); } catch (err) { const msg = err instanceof Error ? err.message : "Unknown queue-walk error"; defaults.logger.error(`fal queue-walk proxy failed: ${msg}`); + // Guard BEFORE journaling (openrouter-video convention): a client that + // disconnected during the multi-second walk gets neither a write nor a + // journal entry. + if (res.destroyed || res.writableEnded) return "handled"; journal.add({ method: req.method ?? "POST", path: pathname, @@ -973,7 +1032,7 @@ async function proxyAndRecordFalQueueSubmit(args: { match: buildFixtureMatch(matchRequest, record), response: { json: finalBody, status: 200 }, }; - persistFixture({ + const persistResult = persistFixture({ record, providerKey: "fal", testId: getTestId(req), @@ -981,6 +1040,12 @@ async function proxyAndRecordFalQueueSubmit(args: { fixtures, logger: defaults.logger, }); + // Surface a persist failure on the envelope (parity with the generic + // recorder relay and the OpenRouter failed branch) — the synthesized + // envelope below has not been written yet, so the header can still ride it. + if (persistResult.kind === "failed" && !res.headersSent) { + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); + } // ── 5. Synthesise envelope + seed state (same shape as the replay path) ── const newRequestId = crypto.randomUUID(); @@ -1009,6 +1074,11 @@ async function proxyAndRecordFalQueueSubmit(args: { cancel_url: `https://${FAL_HOSTS.queue}/${modelId}/requests/${newRequestId}/cancel`, queue_position: queuePosition(job), }; + // Guard BEFORE journaling (openrouter-video convention): a client that + // disconnected during the multi-second walk gets neither a write nor a + // journal entry. The persisted fixture and seeded state above stay — the + // captured upstream response is valuable regardless. + if (res.destroyed || res.writableEnded) return "handled"; journal.add({ method: req.method ?? "POST", path: pathname, diff --git a/src/index.ts b/src/index.ts index a16d5194..f2264f7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,15 @@ export { handleImages } from "./images.js"; export { handleSpeech } from "./speech.js"; export { handleTranscription } from "./transcription.js"; export { handleVideoCreate, handleVideoStatus, VideoStateMap } from "./video.js"; +export { + handleOpenRouterVideoCreate, + handleOpenRouterVideoStatus, + handleOpenRouterVideoContent, + handleOpenRouterVideoModels, + OpenRouterVideoJobMap, + OPENROUTER_VIDEO_MAX_ENTRIES, + OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES, +} from "./openrouter-video.js"; export { handleElevenLabsAudio } from "./elevenlabs-audio.js"; export { handleFalQueue } from "./fal-audio.js"; export { handleFal, FalQueueStateMap } from "./fal.js"; diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 41019dd3..4f4d53d2 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -1,6 +1,12 @@ import type * as http from "node:http"; import crypto from "node:crypto"; -import type { ChatCompletionRequest, Fixture, HandlerDefaults, VideoResponse } from "./types.js"; +import type { + ChatCompletionRequest, + Fixture, + HandlerDefaults, + RecordConfig, + VideoResponse, +} from "./types.js"; import type { Logger } from "./logger.js"; import { isVideoResponse, @@ -21,13 +27,24 @@ import { writeErrorResponse } from "./sse-writer.js"; import type { Journal } from "./journal.js"; import { applyChaos } from "./chaos.js"; import { resolveProgression } from "./fal.js"; +import { + buildFixtureMatch, + buildForwardHeaders, + clampTimeout, + persistFixture, + sanitizeHeaderValue, +} from "./recorder.js"; +import { resolveUpstreamUrl } from "./url.js"; /** * OpenRouter async video lifecycle mock (`/api/v1/videos`). Mirrors the * dedicated OpenRouter video-generation API: submit returns a job envelope, * status polls advance `pending → in_progress → completed | failed`, and a - * `/content` endpoint serves the bytes. Replay/strict-only — record mode is - * not wired for this surface. + * `/content` endpoint serves the bytes. With `record.providers.openrouter` + * configured, unmatched submits become a live interactive proxy: the submit + * is forwarded upstream and answered with a mock-rewritten envelope, each + * client poll is proxied upstream 1:1, and a completed job is captured + * eagerly as a fixture (strict mode still wins over record). */ interface OpenRouterVideoRequest { @@ -45,7 +62,8 @@ const OPENROUTER_VIDEO_TTL_MS = 3_600_000; // 1 hour type OpenRouterVideoStatus = "pending" | "in_progress" | "completed" | "failed"; -interface OpenRouterVideoJob { +interface OpenRouterVideoReplayJob { + kind: "replay"; jobId: string; status: OpenRouterVideoStatus; /** Number of status polls the caller has made against this job. */ @@ -56,8 +74,79 @@ interface OpenRouterVideoJob { pollsBeforeCompleted: number; /** The matched fixture's video object (terminal status, bytes, cost, error). */ video: VideoResponse["video"]; + /** + * Latch for the empty-`error` authoring warn on failed polls — a polling + * loop must surface the fixture defect once per job, not once per poll. + */ + emptyErrorWarned?: boolean; + /** + * Latch for the per-download fixture-defect warns on the content endpoint + * (b64 corruption / empty b64 / ignored url). The job's `video` is + * immutable after submit, so the warn set is identical on every download — + * surface it once per job, not once per re-download. + */ + contentWarnsLatched?: boolean; +} + +/** + * A job whose lifecycle is proxied live upstream (record mode, no fixture + * matched at submit). Every client poll is forwarded 1:1 to + * `upstreamPollingUrl`; when the upstream reports a terminal status the + * entry is captured as a fixture and MUTATED into a terminal + * OpenRouterVideoReplayJob — so the content endpoint serves it like any + * replay job. Under `record.proxyOnly` nothing is ever captured or mutated: + * the job stays `kind: "record"` (terminal statuses included) and the + * content endpoint live-proxies the stored upstream `unsigned_urls`. + */ +interface OpenRouterVideoRecordJob { + kind: "record"; + /** The mock-issued jobId the client polls with. */ + jobId: string; + /** + * Last upstream status relayed to the client. Terminal values occur under + * proxyOnly (the job stays a live proxy forever) and during the capturing + * window — `completed` is set synchronously with `capturing` so the content + * endpoint can live-proxy downloads while the eager capture is in flight. + * Outside those two cases the capturing path mutates the entry into a + * replay job at the terminal poll. + */ + status: OpenRouterVideoStatus; + /** Upstream's own job id (recorded as the fixture's video.id). */ + upstreamJobId: string; + /** Origin-validated absolute upstream URL proxied on every client poll. */ + upstreamPollingUrl: string; + /** + * Match snapshot built at submit time (post requestTransform). Its `model` + * is the submitted model as recorded by the standard model-normalization + * rules — date suffixes stripped unless `recordFullModelVersion`; model-less + * submits record the assumed default model. + */ + match: Fixture["match"]; + /** + * Upstream content URLs from a completed poll, stored under proxyOnly and + * during the capturing window so the content endpoint can live-proxy the + * bytes (never cached). Stored UNFILTERED — positions must line up with the + * indexes the rewritten relay handed the client — so entries may be + * non-strings; the content proxy skips unusable entries at use time. + */ + upstreamUnsignedUrls?: unknown[]; + /** + * Set synchronously before the first await of the eager-capture sequence so + * a concurrent completed poll relays the upstream body instead of starting + * a second capture/persist. + */ + capturing?: boolean; + /** + * Latches for the per-poll upstream-defect warns in rewriteRecordPollBody + * (mirrors emptyErrorWarned): a polling loop must surface each defect once + * per job, not once per poll. + */ + nonArrayUrlsWarned?: boolean; + badCostWarned?: boolean; } +export type OpenRouterVideoJob = OpenRouterVideoReplayJob | OpenRouterVideoRecordJob; + interface OpenRouterVideoEntry { job: OpenRouterVideoJob; createdAt: number; @@ -67,12 +156,30 @@ interface OpenRouterVideoEntry { * Per-testId job state for the OpenRouter video handler. Mirrors * FalQueueStateMap (fal.ts): lazy TTL eviction on `get`, FIFO eviction of the * oldest entries on `set` when over capacity, no background sweep timer. - * Deliberate lifetime divergence: this map is per-server-instance (cleared on - * server close/reset) while FalQueueStateMap is module-global. + * Deliberate divergences from FalQueueStateMap: + * - lifetime: this map is per-server-instance (cleared on server + * close/reset) while FalQueueStateMap is module-global; + * - `set()` refreshes: delete-before-set moves a re-inserted key to the + * back of the FIFO order AND resets its createdAt, so a refreshed entry + * gets a fresh TTL — FalQueueStateMap's plain overwrite keeps the + * original insertion slot and is never used as a TTL refresh. * Keys are `${testId}:${jobId}`. */ export class OpenRouterVideoJobMap { private readonly entries = new Map(); + private worldGeneration = 0; + + /** + * Monotonic world-generation counter, incremented by `clear()` (a fixtures + * reset). Identity checks (`jobs.get(key) === job`) can only guard + * continuations whose job was ALREADY inserted — a continuation that has + * not inserted yet (the submit proxy's upstream fetch) captures this value + * before its await and compares after, so it can detect that the world it + * belongs to was reset mid-flight and skip seeding the new world. + */ + get generation(): number { + return this.worldGeneration; + } get(key: string): OpenRouterVideoJob | undefined { const entry = this.entries.get(key); @@ -85,6 +192,13 @@ export class OpenRouterVideoJobMap { } set(key: string, job: OpenRouterVideoJob): void { + // Delete-before-set so a refreshed key moves to the BACK of the Map's + // insertion order. A plain overwrite keeps the original insertion slot, + // so the FIFO eviction below would treat a freshly TTL-refreshed entry + // as the oldest and could evict it under capacity pressure — breaking + // the documented "each successful proxied poll refreshes the TTL" + // guarantee. + this.entries.delete(key); this.entries.set(key, { job, createdAt: Date.now() }); if (this.entries.size > OPENROUTER_VIDEO_MAX_ENTRIES) { const excess = this.entries.size - OPENROUTER_VIDEO_MAX_ENTRIES; @@ -102,6 +216,7 @@ export class OpenRouterVideoJobMap { clear(): void { this.entries.clear(); + this.worldGeneration++; } get size(): number { @@ -118,7 +233,7 @@ export class OpenRouterVideoJobMap { * state. handleOpenRouterVideoCreate warns when a "processing" fixture is * coerced this way. */ -function terminalStatus(job: OpenRouterVideoJob): OpenRouterVideoStatus { +function terminalStatus(job: OpenRouterVideoReplayJob): OpenRouterVideoStatus { return job.video.status === "failed" ? "failed" : "completed"; } @@ -129,7 +244,7 @@ function terminalStatus(job: OpenRouterVideoJob): OpenRouterVideoStatus { * whose thresholds are equal still spends one poll in in_progress instead of * jumping straight to the terminal status (fal advanceJob semantics). */ -function advanceJob(job: OpenRouterVideoJob): void { +function advanceJob(job: OpenRouterVideoReplayJob): void { if (job.status === "completed" || job.status === "failed") return; job.pollCount += 1; @@ -212,6 +327,201 @@ function testIdSuffix(testId: string, sep: "?" | "&"): string { return testId === DEFAULT_TEST_ID ? "" : `${sep}testId=${encodeURIComponent(testId)}`; } +/** Default upstream timeout for the live lifecycle proxy (matches recorder.ts). */ +const DEFAULT_UPSTREAM_TIMEOUT_MS = 30_000; + +/** + * Abort signal for the SMALL-JSON upstream fetches on this surface (submit, + * status poll, models listing), honoring `record.upstreamTimeoutMs` with the + * same clamp conventions as recorder.ts (non-finite / non-positive values + * fall back to the 30s default). Note the nuance (documented on + * RecordConfig.upstreamTimeoutMs): these envelope-sized fetches use the value + * as a TOTAL deadline via AbortSignal.timeout — indistinguishable from a + * socket-idle timeout for small bodies. The byte-bearing content fetches use + * `fetchHeadersWithTimeout` + `readBodyIdle` instead, which implement true + * idle semantics. An abort rejects the fetch and surfaces through the + * caller's existing failure path (502 proxy_error on submit/poll, models + * synthesis fallback). + */ +function upstreamTimeoutSignal(record: RecordConfig | undefined): AbortSignal { + return AbortSignal.timeout(clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS)); +} + +/** + * Fetch a byte-bearing upstream URL with `record.upstreamTimeoutMs` gating + * only the HEADERS: the abort timer is cleared the moment the response head + * arrives, so a long — but steadily progressing — body download is never + * killed by a total deadline. Body progress is governed separately by + * `readBodyIdle` (between-chunk idle semantics). + */ +async function fetchHeadersWithTimeout( + url: URL, + headers: Record, + record: RecordConfig | undefined, +): Promise { + const timeoutMs = clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS); + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error(`Upstream response headers timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + try { + return await fetch(url, { headers, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** + * Buffer a fetch Response body with IDLE-based timeout semantics: a timer + * clamped from `record.bodyTimeoutMs` (30s default — same clamp as + * recorder.ts) is re-armed for every chunk, so a steadily-dripping body of + * any total duration completes and only a silent mid-body stall rejects. + * When `cap` > 0 the byte count is enforced DURING the read: on exceed the + * stream is cancelled and `{ overCap: true }` is returned with NOTHING + * oversized retained in memory — the cap is a memory guard as much as a + * disk guard. + */ +async function readBodyIdle( + res: Response, + record: RecordConfig | undefined, + cap = 0, +): Promise<{ overCap: false; buf: Buffer } | { overCap: true; bytesRead: number }> { + const body = res.body; + if (!body) return { overCap: false, buf: Buffer.alloc(0) }; + const idleMs = clampTimeout(record?.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS); + const reader = body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + let idleTimer: NodeJS.Timeout | undefined; + // A stream error landing AFTER the idle timeout has already won the + // race must never become an unhandledRejection (process crash) — attach + // a no-op rejection handler to the read promise BEFORE racing. + // Promise.race subscribes to its inputs too, so this is deliberate + // defense-in-depth pinning the invariant against a refactor that races + // the read differently; the race below still observes the rejection + // normally when the read loses first. + const readPromise = reader.read(); + readPromise.catch(() => {}); + const result = await Promise.race([ + readPromise, + new Promise((_, reject) => { + idleTimer = setTimeout( + () => reject(new Error(`Upstream response body idle for ${idleMs}ms`)), + idleMs, + ); + }), + ]).finally(() => clearTimeout(idleTimer)); + if (result.done) break; + total += result.value.byteLength; + if (cap > 0 && total > cap) { + return { overCap: true, bytesRead: total }; + } + chunks.push(Buffer.from(result.value)); + } + } finally { + // Idle expiry and the over-cap early return leave the stream open — + // release it. After a normal completion this is a no-op. + void reader.cancel().catch(() => {}); + } + return { overCap: false, buf: Buffer.concat(chunks) }; +} + +/** + * Cap on an upstream ERROR body (401/403 relays) buffered by the content + * proxy. Error bodies are envelope-sized JSON in practice — 64 KB is generous + * — and unlike the video bytes they are buffered in full before the relay, so + * an unbounded read would be a memory hole on a hostile upstream. + */ +const OPENROUTER_VIDEO_ERROR_BODY_CAP = 64 * 1024; + +/** + * Cap on the small-JSON upstream envelope bodies (submit, status poll, models + * listing) buffered before parse/relay. Envelopes are KB-scale in practice — + * 1 MB is generous headroom even for a large models listing — and, like the + * error-body cap above, they are buffered in full, so an unbounded `text()` + * would be a memory hole on a hostile upstream. + */ +const OPENROUTER_VIDEO_ENVELOPE_BODY_CAP = 1024 * 1024; + +/** + * Bounded read of a small-JSON upstream envelope body (submit, status poll, + * models listing): readBodyIdle's idle semantics plus the envelope cap. An + * over-cap body is a hard failure — the throw surfaces through each caller's + * existing failure path (502 proxy_error on submit/poll, models synthesis + * fallback) without retaining anything oversized in memory. + */ +async function readEnvelopeText(res: Response, record: RecordConfig | undefined): Promise { + const read = await readBodyIdle(res, record, OPENROUTER_VIDEO_ENVELOPE_BODY_CAP); + if (read.overCap) { + throw new Error( + `Upstream envelope body exceeded ${OPENROUTER_VIDEO_ENVELOPE_BODY_CAP} bytes (read aborted at ${read.bytesRead})`, + ); + } + return read.buf.toString("utf8"); +} + +/** + * Rewrite an upstream poll body for relay to the client. Every field is + * relayed verbatim EXCEPT the ones that would let the client escape the mock + * or learn upstream identifiers: + * - `id` → the mock jobId; + * - a present `polling_url` → the mock's poll URL (testId embedded); + * - a present `unsigned_urls` array → an array of the SAME length whose + * element i is the mock content URL with `?index=i`. On post-capture + * REPLAYS the mock content endpoint serves the index-0 bytes for every + * index (only index 0 is captured); during the capture window and under + * proxyOnly the index selects the live-proxied upstream URL. + * `usage` passes through untouched: a non-number `usage.cost` warns but is + * never coerced or dropped, and no usage is ever invented. + */ +function rewriteRecordPollBody(args: { + upstreamBody: Record; + job: OpenRouterVideoRecordJob; + req: http.IncomingMessage; + testId: string; + logger: Logger; +}): Record { + const { upstreamBody, job, req, testId, logger } = args; + const base = requestBase(req, logger); + const body: Record = { ...upstreamBody, id: job.jobId }; + if (upstreamBody.polling_url !== undefined) { + body.polling_url = `${base}/api/v1/videos/${job.jobId}${testIdSuffix(testId, "?")}`; + } + 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, "&")}`, + ); + } else if (upstreamBody.unsigned_urls !== undefined) { + // A non-array unsigned_urls cannot be index-rewritten — relaying it + // verbatim would leak the upstream value to the client, the exact escape + // this rewrite exists to prevent. Strip it with a warn (the any-value + // treatment polling_url gets above). Latched once per job (mirroring + // emptyErrorWarned): a polling loop must not spam the warn. + delete body.unsigned_urls; + if (!job.nonArrayUrlsWarned) { + job.nonArrayUrlsWarned = true; + 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`, + ); + } + } + const usage = upstreamBody.usage; + if (usage !== null && typeof usage === "object" && !Array.isArray(usage)) { + const cost = (usage as Record).cost; + if (cost !== undefined && typeof cost !== "number" && !job.badCostWarned) { + job.badCostWarned = true; + logger.warn( + `Upstream video job ${job.upstreamJobId} reported a non-number usage.cost (${JSON.stringify(cost).slice(0, 50)}) — passing it through untouched`, + ); + } + } + return body; +} + /** * Synthesizes a structurally valid journal body for field-validation 400s. * JournalEntry.body is typed `ChatCompletionRequest | null`, so the raw @@ -243,19 +553,24 @@ function validationJournalBody(videoReq: OpenRouterVideoRequest): ChatCompletion // ─── GET /api/v1/videos/{jobId} — status poll ─────────────────────────────── -export function handleOpenRouterVideoStatus( +export async function handleOpenRouterVideoStatus( req: http.IncomingMessage, res: http.ServerResponse, jobId: string, + fixtures: Fixture[], journal: Journal, defaults: HandlerDefaults, setCorsHeaders: (res: http.ServerResponse) => void, jobs: OpenRouterVideoJobMap, -): void { +): Promise { setCorsHeaders(res); const path = req.url ?? `/api/v1/videos/${jobId}`; const method = req.method ?? "GET"; + // Chaos rolls BEFORE the job lookup, so it cannot know whether the poll + // would have been proxied (record job) or served locally — the label stays + // "internal" even in record mode, and a chaos-dropped poll never reaches + // the upstream. if ( applyChaos( res, @@ -290,8 +605,66 @@ export function handleOpenRouterVideoStatus( return; } - advanceJob(job); + if (job.kind === "record") { + // Strict means nothing reaches an upstream — a record job's polls are + // pure upstream proxies, so an effective-strict request is refused with + // the strict 503 family (same gate as the models proxy; the submit-time + // gate cannot help a job that was created under a strict-off override). + if (resolveStrictMode(defaults.strict, req.headers)) { + defaults.logger.error( + `STRICT: video job ${jobId} is proxied live upstream (record mode) — refusing the upstream poll`, + ); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 503, + fixture: null, + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 503, + JSON.stringify({ + error: { + message: `Strict mode: video job ${jobId} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`, + type: "invalid_request_error", + code: "no_fixture_match", + }, + }), + ); + return; + } + await proxyOpenRouterVideoRecordPoll({ + req, + res, + job, + key: `${testId}:${jobId}`, + testId, + fixtures, + journal, + defaults, + jobs, + method, + path, + }); + return; + } + // Guard BEFORE advancing or journaling (file convention): a client that + // disconnected while the poll was queued gets neither a write nor a + // journal entry — and consumes no progression step or TTL refresh, so the + // next LIVE poll observes the state this one would have consumed. + if (res.destroyed || res.writableEnded) return; + advanceJob(job); + // Refresh the TTL on every replay poll, mirroring the record-job proxy + // path: the delete-before-set semantics also move the entry to the back of + // the FIFO eviction order, so an actively-polled job survives both the TTL + // and capacity pressure however long the client keeps polling. + jobs.set(`${testId}:${jobId}`, job); journal.add({ method, path, @@ -307,9 +680,11 @@ export function handleOpenRouterVideoStatus( ]; body.usage = { cost: job.video.cost ?? 0 }; } else if (job.status === "failed") { - if (job.video.error === "") { + if (job.video.error === "" && !job.emptyErrorWarned) { // An explicit-but-empty error is an authoring mistake — warn (mirroring - // the empty-b64 warn) instead of serving an empty failure reason. + // the empty-b64 warn) instead of serving an empty failure reason. Once + // per job: a polling loop on a failed job must not spam the warn. + job.emptyErrorWarned = true; defaults.logger.warn( `Video fixture for job ${job.jobId} has an empty error message — using the default`, ); @@ -335,8 +710,11 @@ const PLACEHOLDER_MP4 = Buffer.from([ // The `index` query param is accepted but ignored (jobs are single-video), and // fetching content deliberately does NOT advance job state — clients only // learn the content URL from a completed status poll (API fidelity; diverges -// from fal's advance-on-result queue semantics). -export function handleOpenRouterVideoContent( +// from fal's advance-on-result queue semantics). Exception: a completed +// kind:"record" job — reachable under record.proxyOnly AND during the eager +// capture window — live-proxies the stored upstream unsigned_urls instead, +// honoring the index. +export async function handleOpenRouterVideoContent( req: http.IncomingMessage, res: http.ServerResponse, jobId: string, @@ -344,7 +722,7 @@ export function handleOpenRouterVideoContent( defaults: HandlerDefaults, setCorsHeaders: (res: http.ServerResponse) => void, jobs: OpenRouterVideoJobMap, -): void { +): Promise { setCorsHeaders(res); const path = req.url ?? `/api/v1/videos/${jobId}/content`; const method = req.method ?? "GET"; @@ -425,12 +803,63 @@ export function handleOpenRouterVideoContent( return; } - // `index` is accepted but ignored (jobs are single-video) — warn when a - // present value asks for anything other than index 0, since the caller is - // silently getting index-0 bytes either way. const queryIdx = path.indexOf("?"); const indexParam = queryIdx === -1 ? null : new URLSearchParams(path.slice(queryIdx + 1)).get("index"); + + // A completed record job is reachable under record.proxyOnly (live proxy + // forever) and during the capturing window (a concurrent poll relayed the + // terminal body while the eager capture is still in flight). Live-proxy the + // stored upstream URL in both cases (the bytes are never cached). + if (job.kind === "record") { + // Strict means nothing reaches an upstream — gate the live content proxy + // exactly like the models proxy and the record-job poll path. + if (resolveStrictMode(defaults.strict, req.headers)) { + defaults.logger.error( + `STRICT: video job ${jobId} content is proxied live upstream (record mode) — refusing the upstream fetch`, + ); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 503, + fixture: null, + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 503, + JSON.stringify({ + error: { + message: `Strict mode: video job ${jobId} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`, + type: "invalid_request_error", + code: "no_fixture_match", + }, + }), + ); + return; + } + await proxyOpenRouterVideoRecordContent({ + req, + res, + job, + key: `${testId}:${jobId}`, + indexParam, + journal, + defaults, + jobs, + method, + path, + }); + return; + } + + // `index` is accepted but ignored (jobs are single-video) — warn when a + // present value asks for anything other than index 0, since the caller is + // silently getting index-0 bytes either way. 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`, @@ -459,7 +888,7 @@ export function handleOpenRouterVideoContent( .replace(/_/g, "/") .replace(/=.*$/, ""); const expectedBytes = Math.floor((sanitized.length * 3) / 4); - if (bytes.length === 0) { + if (bytes.length === 0 && !job.contentWarnsLatched) { // Padding-only payloads (e.g. "=", "====") are truthy but sanitize to // "" and decode to 0 bytes — dodging both the empty-string warn and // the length-mismatch check below. Warn whenever a non-empty b64 @@ -473,16 +902,18 @@ export function handleOpenRouterVideoContent( // floor formula agrees with Node's lenient decode whether the payload // is genuinely truncated or merely contains invalid characters the // sanitizer does not strip (e.g. "AAAA!" decodes fully). - 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`, - ); - } else if (bytes.length !== expectedBytes) { + 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`, + ); + } + } 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`, ); } } else { - if (job.video.b64 === "") { + if (job.video.b64 === "" && !job.contentWarnsLatched) { // An explicit-but-empty b64 is indistinguishable from an absent one to // the truthiness check above — warn so the author learns the fixture // is not controlling the served bytes. @@ -490,7 +921,7 @@ export function handleOpenRouterVideoContent( `Video fixture for job ${jobId} has an empty b64 — serving the placeholder MP4`, ); } - if (job.video.url) { + if (job.video.url && !job.contentWarnsLatched) { // Every other coercion on this surface warns — so does dropping the // author's url. The real OpenRouter content endpoint serves bytes, not // a redirect, so the mock has nothing to do with a url-only fixture. @@ -500,7 +931,18 @@ export function handleOpenRouterVideoContent( } bytes = PLACEHOLDER_MP4; } - + // Latch the fixture-defect warns above once per job: video is immutable + // after submit, so re-downloads would only repeat the identical warn set. + job.contentWarnsLatched = true; + + // Guard BEFORE journaling (file convention): a client that disconnected + // before the write gets neither a response nor a journal entry. + if (res.destroyed || res.writableEnded) return; + // Refresh the TTL on a successful serve, mirroring the replay poll path: a + // client stepping through downloads (or re-fetching) keeps the job alive + // however long the session runs. delete-before-set (jobs.set) also moves + // the entry to the back of the FIFO eviction order. + jobs.set(`${testId}:${jobId}`, job); journal.add({ method, path, @@ -516,6 +958,348 @@ export function handleOpenRouterVideoContent( res.end(bytes); } +/** + * Live-proxy a content download for a completed record job — reachable under + * proxyOnly (forever) and during the eager-capture window: the stored + * upstream `unsigned_urls[index || 0]` is fetched with the same same-origin + * Bearer-forwarding gate as the eager-capture path (headers gated by + * `upstreamTimeoutMs`, body by `bodyTimeoutMs` idle semantics), and the bytes + * are STREAMED to the client as `video/mp4` (matching the real API regardless + * of the upstream's content-type) — chunks are relayed as they arrive, so the + * whole video is never buffered in memory. An upstream 401/403 passes through + * verbatim (real-API fidelity); other pre-relay failures 502 (a mid-stream + * stall can only abort the already-committed 200). No state is mutated and + * nothing is cached — repeated downloads hit the upstream every time. + * Journaled source:"proxy" when the relay commits. Refuses with 502 before + * contacting the upstream when record mode was disabled mid-flight. A + * committed relay refreshes the job's TTL (identity-guarded) — a client + * working through a long download list must not lose the job mid-way. + */ +async function proxyOpenRouterVideoRecordContent(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + job: OpenRouterVideoRecordJob; + key: string; + indexParam: string | null; + journal: Journal; + defaults: HandlerDefaults; + jobs: OpenRouterVideoJobMap; + method: string; + path: string; +}): Promise { + const { req, res, job, key, indexParam, journal, defaults, jobs, method, path } = args; + const logger = defaults.logger; + + const proxyError = (msg: string): void => { + logger.error(`OpenRouter video content proxy failed: ${msg}`); + // Guard BEFORE journaling (file convention): a disconnected client gets + // neither a write nor a journal entry. + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 502, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { message: `Proxy to upstream failed: ${msg}`, type: "proxy_error" }, + }), + ); + }; + + // Recording can be disabled mid-flight (LLMock.disableRecording) — an + // orphaned record job must fail loudly BEFORE contacting the upstream + // (and before forwarding the client's Bearer anywhere), mirroring the + // record-job poll gate. The snapshot below is used for every later read + // (same discipline as the poll path): the gate and its consumers must see + // ONE config object even if the defaults are swapped mid-relay. + const record = defaults.record; + if (!record) { + proxyError("record mode is no longer configured for an in-flight record job"); + return; + } + + const urls = job.upstreamUnsignedUrls ?? []; + const parsedIndex = indexParam === null ? 0 : Number(indexParam); + const index = Number.isInteger(parsedIndex) && parsedIndex >= 0 ? parsedIndex : 0; + if (indexParam !== null && index !== parsedIndex) { + // Mirror the replay path's warn convention: a coerced index silently + // changes which bytes the caller gets. + logger.warn( + `Video content request for job ${job.jobId} has an unusable index=${indexParam} — serving index 0`, + ); + } + // The stored array is UNFILTERED (positions align with the rewritten relay + // indexes), so the addressed entry may be a non-string — skip unusable + // entries at use time, warning before the index-0 fallback. + const addressed = urls[index]; + let target = typeof addressed === "string" && addressed ? addressed : undefined; + if (target === undefined && index !== 0) { + // Two distinct upstream defects share this fallback — name the right one: + // an index past the array is the CLIENT asking for more videos than the + // upstream reported, while an in-range-but-unusable entry is the UPSTREAM + // having reported a non-string/empty URL at that position. + 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`, + ); + } 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`, + ); + } + const first = urls[0]; + target = typeof first === "string" && first ? first : undefined; + } + if (!target) { + // Name the actual condition: an empty array is the upstream reporting no + // URLs at all, while a non-empty array whose entry 0 is unusable is a + // different upstream defect (the index>0 warns above draw the same line). + 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)`, + ); + return; + } + + let contentUrl: URL; + try { + contentUrl = new URL(target); + } catch { + proxyError(`Upstream unsigned_urls[${index}] is not a valid URL (${target.slice(0, 100)})`); + return; + } + + // The client's Bearer is forwarded ONLY to the configured provider origin — + // the same gate as the eager-capture path. + const providerBase = record.providers.openrouter; + let providerOrigin: string; + try { + providerOrigin = new URL(providerBase ?? job.upstreamPollingUrl).origin; + } catch { + try { + providerOrigin = new URL(job.upstreamPollingUrl).origin; + } catch { + proxyError( + `Cannot determine the provider origin (invalid provider URL and polling URL) — refusing to forward credentials`, + ); + return; + } + } + const headers = buildForwardHeaders(req); + if (contentUrl.origin !== providerOrigin) { + delete headers.authorization; + logger.warn( + `Upstream unsigned_urls[${index}] origin ${contentUrl.origin} differs from the provider origin ${providerOrigin} — fetching content WITHOUT the client's Authorization header`, + ); + } + + // Headers gated by upstreamTimeoutMs, body by bodyTimeoutMs IDLE semantics + // — a steadily-downloading large video must never be killed by a total + // deadline (see fetchHeadersWithTimeout). + let contentRes: Response; + try { + contentRes = await fetchHeadersWithTimeout(contentUrl, headers, record); + } catch (err) { + proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + return; + } + if (contentRes.status === 401 || contentRes.status === 403) { + // Real-API fidelity: an upstream auth rejection is the client's + // problem (bad or expired Bearer) — relay the upstream status and body + // instead of wrapping them in a generic 502 proxy_error. The body read + // is bounded like every other upstream body on this surface (the header + // timer was cleared when the head arrived, so a bare arrayBuffer() here + // could hang forever): idle semantics from `record.bodyTimeoutMs` plus a + // small hard cap. Documented choices — a mid-body STALL 502s (the + // upstream status cannot be relayed faithfully without its body), while + // an OVER-CAP body relays the upstream status with an empty body + // (status fidelity preserved; an error body past 64 KB is pathological). + let errBody: Buffer; + try { + const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP); + if (read.overCap) { + 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`, + ); + errBody = Buffer.alloc(0); + } else { + errBody = read.buf; + } + } catch (err) { + proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + return; + } + logger.warn( + `Upstream content host rejected the download for job ${job.upstreamJobId} (${contentRes.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: contentRes.status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + res.writeHead(contentRes.status, { + "Content-Type": contentRes.headers.get("content-type") ?? "application/json", + }); + res.end(errBody); + return; + } + if (!contentRes.ok) { + // Include a bounded body sample so the failure is diagnosable (every + // other proxy error on this surface carries a body snippet). Bounded + // exactly like the 401/403 relay above: idle semantics + the error cap. + let sample = ""; + try { + const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP); + if (!read.overCap) sample = read.buf.toString("utf8").slice(0, 200); + } catch { + // Body unreadable — the status alone still names the failure. + } + proxyError(`Content ${contentRes.status}${sample ? `: ${sample}` : ""}`); + return; + } + + // Guard BEFORE journaling: a client that disconnected mid-fetch gets + // neither a write nor a journal entry — the journaled 200 must reflect a + // response that actually left. + if (res.destroyed || res.writableEnded) { + void contentRes.body?.cancel().catch(() => {}); + return; + } + // Journal when the relay COMMITS (the status line is about to hit the + // wire), then STREAM the body chunk-by-chunk: the whole video is never + // buffered in memory and the first bytes reach the client while the + // upstream is still sending. Journal-accuracy choice: a mid-stream abort + // (upstream stall / client disconnect) still happened as a 200 on the wire + // — just truncated — so journaling at commit time stays accurate to what + // left, whereas journaling only on completion would drop truncated relays + // entirely. Aborts are additionally logged below. + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 200, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + // TTL refresh at commit time, identity-guarded like every post-await map + // touch: an actively-downloading job must not be evicted between fetches. + if (jobs.get(key) === job) { + jobs.set(key, job); + } + // Always video/mp4 — matching both the real API and the replay path above. + // No Content-Length: the bytes are relayed as they arrive (chunked). + res.writeHead(200, { "Content-Type": "video/mp4" }); + + const body = contentRes.body; + if (!body) { + res.end(); + return; + } + // Same IDLE semantics as readBodyIdle: the timer is re-armed per chunk, so + // a steadily-dripping body of any total duration completes and only a + // silent mid-body stall aborts. + const idleMs = clampTimeout(record.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS); + const reader = body.getReader(); + try { + for (;;) { + let idleTimer: NodeJS.Timeout | undefined; + // Same orphaned-read hygiene as readBodyIdle: a stream error landing + // after the idle timeout won the race must never become an + // unhandledRejection (see the readBodyIdle comment for the full + // reasoning — deliberate defense-in-depth). + const readPromise = reader.read(); + readPromise.catch(() => {}); + const result = await Promise.race([ + readPromise, + new Promise((_, reject) => { + idleTimer = setTimeout( + () => reject(new Error(`Upstream response body idle for ${idleMs}ms`)), + idleMs, + ); + }), + ]).finally(() => clearTimeout(idleTimer)); + if (result.done) break; + if (res.destroyed) return; // client went away mid-stream — stop relaying + if (!res.write(Buffer.from(result.value))) { + // Backpressure: wait for drain, bailing on close so a client that + // disconnects mid-stall cannot wedge the relay — and BOUND the wait + // with the same clamped bodyTimeoutMs: a stalled-OPEN client (socket + // alive, never reading) would otherwise wedge the relay and pin the + // upstream reader forever. + const drained = await new Promise((resolve) => { + const cleanup = (): void => { + res.off("drain", onDrain); + res.off("close", onClose); + clearTimeout(drainTimer); + }; + const onDrain = (): void => { + cleanup(); + resolve(true); + }; + const onClose = (): void => { + cleanup(); + // The loop's res.destroyed check stops the relay — though the + // upstream reader's release can lag by up to one idle period + // (the next read must resolve or idle-expire before the + // destroyed check runs). + resolve(true); + }; + const drainTimer = setTimeout(() => { + cleanup(); + resolve(false); + }, idleMs); + res.once("drain", onDrain); + res.once("close", onClose); + }); + if (!drained) { + 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`, + ); + res.destroy(); // the finally below releases the upstream reader + return; + } + } + } + res.end(); + } catch (err) { + // The headers are already on the wire — a 502 is no longer possible. + // Destroy the response so the client observes a truncated transfer + // instead of a hang, and log the abort (the journaled 200 stands: a 200 + // status line did leave — see the journal-accuracy note above). + logger.error( + `OpenRouter video content relay aborted mid-stream: ${err instanceof Error ? err.message : String(err)}`, + ); + res.destroy(); + } finally { + // Stall abort and the client-gone return leave the stream open — release + // it. After a normal completion this is a no-op. + void reader.cancel().catch(() => {}); + } +} + // ─── GET /api/v1/videos/models — model listing ────────────────────────────── const DEFAULT_OPENROUTER_VIDEO_MODELS = [DEFAULT_OPENROUTER_VIDEO_MODEL, "openai/sora-2"]; @@ -541,15 +1325,22 @@ function modelEntry(id: string): Record { * `/api/tags` synthesis in server.ts). Falls back to a default model set when * no video fixtures are loaded. Note video models do not appear in the plain * `/api/v1/models` listing on the real API, hence the dedicated route. + * + * With `record.providers.openrouter` configured the listing is proxied + * upstream instead and relayed verbatim on success (journaled + * source:"proxy", never recorded as a fixture); an upstream failure warns + * and falls back to the synthesis below. Strict mode disables the proxy — + * a strict request is always served the synthesized listing (strict means + * "nothing reaches an upstream"). */ -export function handleOpenRouterVideoModels( +export async function handleOpenRouterVideoModels( req: http.IncomingMessage, res: http.ServerResponse, fixtures: Fixture[], journal: Journal, defaults: HandlerDefaults, setCorsHeaders: (res: http.ServerResponse) => void, -): void { +): Promise { setCorsHeaders(res); const path = req.url ?? "/api/v1/videos/models"; const method = req.method ?? "GET"; @@ -569,6 +1360,66 @@ export function handleOpenRouterVideoModels( ) return; + // Snapshot (file discipline): the gate and its consumers below must see ONE + // config object even if the defaults are swapped mid-request. + const record = defaults.record; + const upstreamBase = record?.providers.openrouter; + let proxyAttemptFailed = false; + if (upstreamBase && !resolveStrictMode(defaults.strict, req.headers)) { + // The try covers ONLY the upstream fetch: a throw from the journal/relay + // writes below must not be misattributed to the upstream (it would warn + // "proxy failed", double-journal, and attempt a second response via the + // synthesis fallback). + let relay: { text: string; contentType: string } | undefined; + try { + const target = resolveUpstreamUrl(upstreamBase, "/api/v1/videos/models"); + const upstreamRes = await fetch(target, { + headers: buildForwardHeaders(req), + signal: upstreamTimeoutSignal(record), + }); + const text = await readEnvelopeText(upstreamRes, record); + if (!upstreamRes.ok) { + throw new Error(`Models ${upstreamRes.status}: ${text.slice(0, 200)}`); + } + relay = { + text, + contentType: upstreamRes.headers.get("content-type") ?? "application/json", + }; + } catch (err) { + proxyAttemptFailed = true; + const msg = err instanceof Error ? err.message : "Unknown proxy error"; + defaults.logger.warn( + `OpenRouter video models proxy failed (${msg}) — falling back to the synthesized listing`, + ); + } + if (relay) { + // Guard BEFORE journaling (file convention): a client that + // disconnected while the upstream fetch was in flight gets neither a + // write nor a journal entry — and never the synthesis fallback. + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + // strictOverrideField on every journaled response (the file-wide + // convention — proxied entries included): a proxied path is reachable + // with a strict-ON server default only via a per-request strict-OFF + // override, which journal consumers must see. + response: { + status: 200, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + // Verbatim relay — listings are metadata, never recorded as fixtures. + res.writeHead(200, { "Content-Type": relay.contentType }); + res.end(relay.text); + return; + } + } + const modelIds = new Set(); let sawVideoFixture = false; for (const f of fixtures) { @@ -589,12 +1440,29 @@ export function handleOpenRouterVideoModels( } const ids = modelIds.size > 0 ? [...modelIds] : DEFAULT_OPENROUTER_VIDEO_MODELS; + // Guard BEFORE journaling (file convention): the synthesis is reachable + // after an AWAITED-but-failed proxy attempt above, so the client may have + // disconnected while that fetch was in flight — it gets neither a write + // nor a journal entry. + if (res.destroyed || res.writableEnded) return; journal.add({ method, path, headers: flattenHeaders(req.headers), body: null, - response: { status: 200, fixture: null }, + // A synthesis serving as the fallback for a FAILED proxy attempt is + // labeled source:"internal" so journal consumers can tell it apart from + // a relay that actually came from the upstream. A synthesis whose proxy + // was never attempted — strict-suppressed or plain no-record — omits + // source (the surface's existing convention); a strict override that + // suppressed the proxy is surfaced via strictOverrideField like every + // other strict-influenced journal entry on this surface. + response: { + status: 200, + fixture: null, + ...(proxyAttemptFailed ? { source: "internal" as const } : {}), + ...strictOverrideField(defaults.strict, req.headers), + }, }); res.writeHead(200, { "Content-Type": "application/json" }); @@ -751,7 +1619,15 @@ export async function handleOpenRouterVideoCreate( // Chaos deliberately rolls AFTER body validation and fixture matching // (mirrors handleCompletions) — unlike the GET endpoints above, where chaos - // rolls first. + // rolls first. Divergence from the generic record path (server.ts): + // EVERY firing chaos action here suppresses the would-be proxy entirely — + // the submit never reaches the upstream and nothing is recorded. On the + // generic path that is true only for drop/disconnect; a chaos "malformed" + // there still proxies upstream and swaps the relay body via the + // beforeWriteResponse hook (the fixture records what upstream really + // said), whereas here "malformed" synthesizes a mock body with no + // upstream contact. The roll is still LABELED "proxy" below because that + // is what the request would have been had chaos not fired. if ( applyChaos( res, @@ -760,9 +1636,17 @@ export async function handleOpenRouterVideoCreate( req.headers, journal, { method, path, headers: flattenHeaders(req.headers), body: syntheticReq }, - // This surface never proxies (replay/strict-only) — the no-fixture - // chaos path is still served internally. - fixture ? "fixture" : "internal", + // An unmatched submit is proxied upstream when record mode has an + // openrouter provider configured AND strict would not win (strict 503s + // before any proxy attempt) — label that chaos roll "proxy". A strict + // no-match, or no record/provider, is served internally. + fixture + ? "fixture" + : resolveStrictMode(defaults.strict, req.headers) + ? "internal" + : defaults.record?.providers.openrouter + ? "proxy" + : "internal", defaults.registry, defaults.logger, ) @@ -770,11 +1654,8 @@ export async function handleOpenRouterVideoCreate( return; if (!fixture) { - if (defaults.record) { - defaults.logger.warn( - "record mode is not supported for /api/v1/videos — returning 404/503 no-match response", - ); - } + // Strict mode wins over record: a strict no-match must fail loudly with + // 503 rather than silently recording a new fixture. const effectiveStrict = resolveStrictMode(defaults.strict, req.headers); if (effectiveStrict) { const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn); @@ -804,6 +1685,23 @@ export async function handleOpenRouterVideoCreate( return; } + if (defaults.record) { + const outcome = await proxyOpenRouterVideoSubmit({ + req, + res, + raw, + syntheticReq, + record: defaults.record, + journal, + defaults, + jobs, + method, + path, + }); + if (outcome === "handled") return; + // outcome === "no_upstream" — fall through to 404 (fal convention). + } + journal.add({ method, path, @@ -823,9 +1721,19 @@ export async function handleOpenRouterVideoCreate( return; } + // World-generation snapshot (mirrors the record-submit guard): a fixtures + // reset can land while the fixture's ResponseFactory below is awaited, and + // the job insertion at the bottom must not seed the NEW world with this + // pre-reset submit's job. + const worldGeneration = jobs.generation; const response = await resolveResponse(fixture, syntheticReq); + // Guards BEFORE journaling on every branch below (file convention): + // resolveResponse awaits the fixture's ResponseFactory, which can be + // arbitrarily slow — a client that disconnected meanwhile gets neither a + // write nor a journal entry. if (isErrorResponse(response)) { + if (res.destroyed || res.writableEnded) return; const status = response.status ?? 500; journal.add({ method, @@ -841,6 +1749,7 @@ export async function handleOpenRouterVideoCreate( } if (!isVideoResponse(response)) { + if (res.destroyed || res.writableEnded) return; journal.add({ method, path, @@ -858,6 +1767,7 @@ export async function handleOpenRouterVideoCreate( return; } + if (res.destroyed || res.writableEnded) return; journal.add({ method, path, @@ -884,7 +1794,8 @@ export async function handleOpenRouterVideoCreate( const jobId = crypto.randomUUID(); const progression = resolveProgression(defaults.openRouterVideo); - const job: OpenRouterVideoJob = { + const job: OpenRouterVideoReplayJob = { + kind: "replay", jobId, status: "pending", pollCount: 0, @@ -901,7 +1812,18 @@ export async function handleOpenRouterVideoCreate( if (progression.pollsBeforeCompleted === 0) { job.status = terminalStatus(job); } - jobs.set(`${testId}:${jobId}`, job); + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${jobId}`, job); + } else { + // A fixtures reset landed while the ResponseFactory was awaited — the + // world this submit belongs to is gone. Mirror the record-submit guard: + // skip the insertion but still relay the envelope below (the client's + // polls will 404, the same observable outcome as TTL eviction — warned + // here so the 404s are attributable). + defaults.logger.warn( + `OpenRouter video submit resolved after a fixtures reset — not inserting job ${jobId} into the new world (its polls will 404)`, + ); + } res.writeHead(200, { "Content-Type": "application/json" }); res.end( @@ -912,3 +1834,861 @@ export async function handleOpenRouterVideoCreate( }), ); } + +// ─── Record mode: live interactive proxy (submit) ─────────────────────────── + +const OPENROUTER_VIDEOS_PATH = "/api/v1/videos"; + +/** + * Proxy an unmatched video submit to the configured OpenRouter upstream and + * answer the client with a mock-rewritten envelope: a fresh aimock jobId and + * a polling_url pointing back at the mock (testId embedded). The upstream + * lifecycle is then driven interactively by the client's own polls — unlike + * fal's synchronous queue walk, nothing is polled server-side at submit. + * + * Returns "no_upstream" when record mode has no openrouter provider URL — + * the caller falls through to its 404 branch (fal convention). + */ +async function proxyOpenRouterVideoSubmit(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + raw: string; + syntheticReq: ChatCompletionRequest; + record: RecordConfig; + journal: Journal; + defaults: HandlerDefaults; + jobs: OpenRouterVideoJobMap; + method: string; + path: string; +}): Promise<"handled" | "no_upstream"> { + const { req, res, raw, syntheticReq, record, journal, defaults, jobs, method, path } = args; + + const upstreamBase = record.providers.openrouter; + if (!upstreamBase) { + defaults.logger.warn(`No upstream URL configured for provider "openrouter" — cannot proxy`); + return "no_upstream"; + } + + const proxyError = (msg: string): "handled" => { + defaults.logger.error(`OpenRouter video submit proxy failed: ${msg}`); + // Guard BEFORE journaling (file convention): a disconnected client gets + // neither a write nor a journal entry. + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 502, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { message: `Proxy to upstream failed: ${msg}`, type: "proxy_error" }, + }), + ); + return "handled"; + }; + + let submitUrl: URL; + let upstreamOrigin: string; + try { + submitUrl = resolveUpstreamUrl(upstreamBase, OPENROUTER_VIDEOS_PATH); + upstreamOrigin = new URL(upstreamBase).origin; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return proxyError(`Invalid upstream URL: ${upstreamBase} (${msg})`); + } + + defaults.logger.warn( + `NO FIXTURE MATCH — proxying video submit to ${upstreamBase}${OPENROUTER_VIDEOS_PATH}`, + ); + + // World-generation snapshot: a fixtures reset landing during the upstream + // fetch below clears the job map — the insertion guard after the fetch + // compares against this value (identity checks cannot help here: the job + // has not been inserted yet). + const worldGeneration = jobs.generation; + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(submitUrl, { + method: "POST", + headers: buildForwardHeaders(req), + body: raw, + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown proxy error"; + return proxyError(msg); + } + + if (fetched.status === 401 || fetched.status === 403) { + // Real-API fidelity (mirrors the poll and content paths): an upstream + // auth rejection is the client's problem (bad or expired Bearer) — relay + // the upstream status and body verbatim instead of wrapping them in a + // generic 502 proxy_error. No job is created; a retried submit with + // fixed credentials proxies again. + defaults.logger.warn( + `Upstream rejected the video submit (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: fetched.status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + res.writeHead(fetched.status, { + "Content-Type": fetched.contentType ?? "application/json", + }); + res.end(fetched.text); + return "handled"; + } + + let upstreamJobId: string; + let envelope: Record; + { + if (fetched.status < 200 || fetched.status >= 300) { + return proxyError(`Submit ${fetched.status}: ${fetched.text.slice(0, 200)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + return proxyError(`Submit returned non-JSON: ${fetched.text.slice(0, 200)}`); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return proxyError("Submit response is not a JSON object"); + } + envelope = parsed as Record; + upstreamJobId = String(envelope.id ?? "").trim(); + if (!upstreamJobId) { + return proxyError("Submit response missing id"); + } + } + + // Origin-validate upstream's polling_url before adopting it: every client + // poll forwards the client's Authorization header to this URL, so an + // envelope nominating a foreign host must not receive it. Off-origin (or + // missing/unparseable) polling_urls fall back to the constructed path on + // the configured provider origin. + let upstreamPollingUrl = resolveUpstreamUrl( + upstreamBase, + `${OPENROUTER_VIDEOS_PATH}/${encodeURIComponent(upstreamJobId)}`, + ).toString(); + const envPolling = envelope.polling_url; + if (typeof envPolling === "string" && envPolling) { + try { + const parsedPolling = new URL(envPolling); + if (parsedPolling.origin === upstreamOrigin) { + upstreamPollingUrl = parsedPolling.toString(); + } else { + defaults.logger.warn( + `Upstream polling_url origin ${parsedPolling.origin} differs from the configured provider origin ${upstreamOrigin} — using the constructed poll URL instead`, + ); + } + } catch { + defaults.logger.warn( + `Upstream polling_url is not a valid URL (${String(envPolling).slice(0, 100)}) — using the constructed poll URL instead`, + ); + } + } + + const testId = getTestId(req); + const matchRequest = defaults.requestTransform + ? defaults.requestTransform(syntheticReq) + : syntheticReq; + const jobId = crypto.randomUUID(); + const job: OpenRouterVideoRecordJob = { + kind: "record", + jobId, + status: "pending", + upstreamJobId, + upstreamPollingUrl, + match: buildFixtureMatch(matchRequest, record), + }; + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${jobId}`, job); + } else { + // A fixtures reset landed while the upstream submit was in flight — the + // world this submit belongs to is gone. Seeding the NEW world with a + // stale record job would resurrect pre-reset state, so skip the + // insertion but still relay the envelope below (the client's polls will + // 404, the same observable outcome as TTL eviction — warned here so the + // 404s are attributable). + 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)`, + ); + } + + // Guard BEFORE journaling (file convention): a client that disconnected + // while the upstream submit was in flight gets neither a write nor a + // journal entry. The job entry above stays — TTL eviction reaps it. + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 200, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + + // Same envelope shape as the replay path: mock jobId, mock polling_url + // (testId embedded for header-less polls), "pending" for API fidelity. + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: jobId, + polling_url: `${requestBase(req, defaults.logger)}/api/v1/videos/${jobId}${testIdSuffix(testId, "?")}`, + status: "pending", + }), + ); + return "handled"; +} + +// ─── Record mode: live interactive proxy (status poll + eager capture) ────── + +/** + * Default cap on the DECODED byte size embedded as `b64` in a recorded + * fixture (32 MB). Override per-server via + * `record.openRouterVideo.maxContentBytes` (0 = unlimited; negative or + * non-integer values are treated as the default, with a createServer warn). + * The cap protects disk AND memory: a capture whose upstream response + * DECLARES an over-cap Content-Length is skipped without downloading, while + * a response with no declared length is read as a stream with the byte count + * enforced during the read — on exceed the download aborts with nothing + * oversized retained in memory. In both cases the fixture is persisted + * without `b64` and the same-session job serves the placeholder MP4. + */ +export const OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES = 32 * 1024 * 1024; + +/** + * Proxy a status poll for a record-mode job 1:1 to the upstream and relay + * the result with mock-rewritten identifiers (rewriteRecordPollBody: id, + * polling_url, unsigned_urls; everything else verbatim). When the upstream + * reports `completed`, the rewritten body is relayed IMMEDIATELY and the + * eager capture (server-side fetch of unsigned_urls[0], forwarding the + * polling client's Bearer ONLY same-origin; persist; replay mutation) runs + * DETACHED in the background — see captureOpenRouterVideoRecordFixture — so + * an SDK poller is never blocked on a multi-minute download; the content + * handler (and every later poll) serves the job locally once the capture + * lands. `failed` persists a failed fixture synchronously on the relaying + * poll. Every post-await map mutation is identity-guarded + * (`jobs.get(key) === job`) so a stale response resolving after a concurrent + * poll's capture replaced the entry can never resurrect the detached record + * object over the terminal replay job. + * `cancelled`/`expired` (not representable in VideoResponse.status) pass + * through with a warn and are never recorded. Under `record.proxyOnly` + * nothing is captured, persisted, or mutated: terminal polls relay the + * rewritten upstream body, the upstream unsigned_urls are stored on the + * record job for the content endpoint to live-proxy, and every later poll + * keeps proxying upstream. Each successful proxied poll of a record job + * refreshes its TTL so a long generation cannot be evicted mid-recording. + */ +async function proxyOpenRouterVideoRecordPoll(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + job: OpenRouterVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + journal: Journal; + defaults: HandlerDefaults; + jobs: OpenRouterVideoJobMap; + method: string; + path: string; +}): Promise { + const { req, res, job, key, testId, fixtures, journal, defaults, jobs, method, path } = args; + const logger = defaults.logger; + + const journalProxy = (status: number): void => { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + }; + + const proxyError = (msg: string): void => { + logger.error(`OpenRouter video poll proxy failed: ${msg}`); + // Guard BEFORE journaling (file convention): a disconnected client gets + // neither a write nor a journal entry. + if (res.destroyed || res.writableEnded) return; + journalProxy(502); + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { message: `Proxy to upstream failed: ${msg}`, type: "proxy_error" }, + }), + ); + }; + + // Recording can be disabled mid-flight (LLMock.disableRecording) — an + // orphaned record job must fail loudly on ANY poll, before contacting the + // upstream, rather than silently relaying non-terminal statuses and only + // erroring at the terminal poll. + const record = defaults.record; + if (!record) { + proxyError("record mode is no longer configured for an in-flight record job"); + return; + } + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(job.upstreamPollingUrl, { + headers: buildForwardHeaders(req), + // The locally captured `record` — not defaults.record, which is the + // same object today but would silently diverge if the defaults were + // ever swapped between the gate above and this fetch. + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + return; + } + + if (fetched.status === 401 || fetched.status === 403) { + // Real-API fidelity (mirrors the content path): an upstream auth + // rejection is the client's problem (bad or expired Bearer) — relay the + // upstream status and body verbatim instead of wrapping them in a + // generic 502 proxy_error. The job is untouched: a later poll with fixed + // credentials proxies again. + logger.warn( + `Upstream rejected the status poll for job ${job.upstreamJobId} (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return; + journalProxy(fetched.status); + res.writeHead(fetched.status, { + "Content-Type": fetched.contentType ?? "application/json", + }); + res.end(fetched.text); + return; + } + + let upstreamBody: Record; + { + if (fetched.status < 200 || fetched.status >= 300) { + proxyError(`Status ${fetched.status}: ${fetched.text.slice(0, 200)}`); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + proxyError(`Status returned non-JSON: ${fetched.text.slice(0, 200)}`); + return; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + proxyError("Status response is not a JSON object"); + return; + } + upstreamBody = parsed as Record; + } + + const relayJson = (body: Record): void => { + // Guard BEFORE journaling: a client that disconnected while the upstream + // poll was in flight gets neither a write nor a journal entry — the + // journaled 200 must reflect a response that actually left. + if (res.destroyed || res.writableEnded) return; + journalProxy(200); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + // Mock-rewritten relay body — used by EVERY branch below so no upstream + // URL-bearing field (polling_url, unsigned_urls) can escape the mock. + const relayBody = rewriteRecordPollBody({ upstreamBody, job, req, testId, logger }); + + const upstreamStatus = String(upstreamBody.status ?? ""); + + if (upstreamStatus === "pending" || upstreamStatus === "in_progress") { + // Identity guard on EVERY post-await mutation/re-insert: the upstream + // fetch above may have resolved AFTER a concurrent poll's capture + // replaced the map entry with a terminal replay job (or after TTL + // eviction). Mutating or re-inserting the detached dispatch-time + // reference would regress the terminal job back to a live proxy — the + // body is still relayed either way. + if (jobs.get(key) === job) { + // Identity alone is NOT enough during the capture window (and under + // proxyOnly after a terminal poll): the SAME record object stays in + // the map with status "completed"/"failed", so a stale non-terminal + // response resolving late would regress it and reopen the content + // endpoint's 400 window. Skip the status write once capturing or + // terminal; the TTL refresh below still applies. + if (!job.capturing && job.status !== "completed" && job.status !== "failed") { + job.status = upstreamStatus; + } + // Refresh the TTL: an actively-polled record job must not be evicted + // mid-recording however long the generation takes. + jobs.set(key, job); + } + relayJson(relayBody); + return; + } + + if (upstreamStatus === "completed") { + if (record.proxyOnly) { + // Proxy-only: no fixtures, no in-memory caching, no replay mutation. + // The job stays kind:"record"; the upstream unsigned_urls are stored so + // the content endpoint can live-proxy the bytes (id mapping is + // inherently stateful — the bytes never are). Stored UNFILTERED so the + // live proxy's indexes stay aligned with the rewritten relay's. + // Identity-guarded like every post-await mutation in this function. + if (jobs.get(key) === job) { + const urls = upstreamBody.unsigned_urls; + job.status = "completed"; + // Refresh the stash only from an array body (the capture-window twin + // below guards this exact case): a later completed poll that omits or + // corrupts unsigned_urls must not clobber a usable stash with + // undefined. + if (Array.isArray(urls)) { + job.upstreamUnsignedUrls = [...urls]; + } + jobs.set(key, job); // TTL refresh + } + relayJson(relayBody); + return; + } + + if (job.capturing || jobs.get(key) !== job) { + // A concurrent poll already entered the capture sequence (capturing + // latched), or this dispatch-time reference is detached — the map entry + // was replaced by the finished capture's replay job (or TTL-evicted) + // while the upstream fetch above was in flight. Relay the rewritten + // body either way, but NEVER re-insert the stale object (it would + // resurrect a permanent live proxy OVER the terminal replay job) and + // never start a second capture/persist. + if (jobs.get(key) === job) { + // Refresh the stored upstream URLs from THIS poll's body (mirroring + // the proxyOnly branch): upstream unsigned URLs can rotate between + // polls (signed-by-time CDN links), so in-window content + // live-proxies must use the freshest set. Non-array bodies keep the + // existing stash — a defective later poll must not clobber a usable + // one. + const freshUrls = upstreamBody.unsigned_urls; + if (Array.isArray(freshUrls)) { + job.upstreamUnsignedUrls = [...freshUrls]; + } + jobs.set(key, job); // TTL refresh, like every other successful proxied poll + } + relayJson(relayBody); + return; + } + const urls = upstreamBody.unsigned_urls; + // Open the capturing window SYNCHRONOUSLY, before the first await of the + // capture sequence: `capturing` is the double-capture guard, and the + // terminal status + stashed upstream unsigned_urls make the job fully + // observable as completed while the capture is in flight — a client that + // follows this (or a concurrent) poll's relayed content URL gets a + // live-proxied download instead of a 400. + job.capturing = true; + job.status = "completed"; + // Refresh the stash only from an array body (the proxyOnly and + // capture-window siblings above guard this exact case): a later completed + // poll that omits or corrupts unsigned_urls must not clobber a usable + // stash with undefined. + if (Array.isArray(urls)) { + job.upstreamUnsignedUrls = [...urls]; + } + jobs.set(key, job); // TTL refresh — identity-checked above, no await since + + // Relay the completed body IMMEDIATELY: a real video download can take + // minutes, and an SDK poller blocked on it would time out. The capture + // (download + persist + replay mutation) runs DETACHED below; the + // capturing window keeps the job serving correctly meanwhile. + relayJson(relayBody); + + // Detached eager capture. Handles every failure internally and never + // rejects; closes the capturing window in its finally. + void captureOpenRouterVideoRecordFixture({ + req, + job, + key, + testId, + fixtures, + defaults, + jobs, + record, + upstreamBody, + }); + return; + } + + if (upstreamStatus === "failed") { + if (record.proxyOnly) { + // Proxy-only: relay the rewritten failure body, persist nothing, keep + // the job a live proxy. Identity-guarded like every post-await + // mutation in this function. + if (jobs.get(key) === job) { + job.status = "failed"; + jobs.set(key, job); // TTL refresh + } + relayJson(relayBody); + return; + } + + if (jobs.get(key) !== job) { + // Concurrency guard: a concurrent poll at "failed" already persisted + // the failure fixture and replaced the entry while this poll's + // upstream fetch was in flight (everything below the fetch is + // synchronous, so the first poll to resume wins atomically). Relay + // without persisting a duplicate fixture or re-registering it. + // This identity check ALSO covers a fixtures reset landing during the + // upstream fetch: performFixturesReset clears the job map, so a + // cleared world fails the check and the stale failure fixture never + // pollutes the next world's fixtures array — valid because everything + // from here to persistFixture below is synchronous (no interleaving + // point between check and persist). + relayJson(relayBody); + return; + } + + const rawError = upstreamBody.error; + let error: string | undefined; + if (typeof rawError === "string" && rawError) { + error = rawError; + } else if (rawError !== null && typeof rawError === "object" && !Array.isArray(rawError)) { + // Canonical envelope shape ({ error: { message, code } }) — extract the + // message like the recorder's error-fixture detection does. + const message = (rawError as Record).message; + if (typeof message === "string" && message) { + error = message; + } + } + if (error === undefined && rawError !== undefined && 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)`, + ); + } + const video: VideoResponse["video"] = { + id: job.upstreamJobId, + status: "failed", + ...(error !== undefined ? { error } : {}), + }; + const persistResult = persistFixture({ + record, + providerKey: "openrouter", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + logger, + }); + if (persistResult.kind === "failed" && !res.headersSent) { + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); + } + jobs.set(key, { + kind: "replay", + jobId: job.jobId, + status: "failed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + // Passthrough of the upstream failure body, identifiers rewritten. + relayJson(relayBody); + return; + } + + // cancelled / expired / anything else: terminal upstream states that are + // not representable in VideoResponse.status — pass through (identifiers + // rewritten), record nothing, and keep proxying any further polls live. + logger.warn( + `Upstream video job ${job.upstreamJobId} reported status "${upstreamStatus}" — not representable in fixture video.status; passing through without recording`, + ); + // Identity-guarded TTL refresh — the job keeps proxying live, but a + // detached reference is never re-inserted over a replaced entry. + if (jobs.get(key) === job) { + jobs.set(key, job); + } + relayJson(relayBody); +} + +/** + * Detached eager-capture sequence for a completed record job. Runs AFTER the + * triggering completed poll has been relayed (the client is never blocked on + * a potentially multi-minute video download): fetches `unsigned_urls[0]` + * server-side (the polling client's Bearer is forwarded ONLY same-origin), + * persists the fixture, and mutates the map entry into a terminal replay job. + * Every failure is handled internally and the returned promise NEVER + * rejects. Failure semantics: a capture FAILURE — no usable + * unsigned_urls, an invalid content URL, or a content fetch that errors on + * headers, status, or body — persists NOTHING and leaves the job a live + * proxy, so the next completed poll retries the capture (each failure + * warns). The ONLY degraded persist is the over-cap path (declared or + * streamed): retrying would always re-exceed the cap, so the b64-less + * fixture + placeholder is the deliberate, permanent outcome. The capturing + * window opened by the caller is closed in the finally when the map still + * holds this record job; on the success path the entry was just replaced and + * the detached record object deliberately KEEPS capturing=true so a + * concurrent poll that read it before the replacement still early-relays + * instead of starting a second capture. + */ +async function captureOpenRouterVideoRecordFixture(args: { + req: http.IncomingMessage; + job: OpenRouterVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + defaults: HandlerDefaults; + jobs: OpenRouterVideoJobMap; + record: RecordConfig; + upstreamBody: Record; +}): Promise { + const { req, job, key, testId, fixtures, defaults, jobs, record, upstreamBody } = args; + const logger = defaults.logger; + const urls = upstreamBody.unsigned_urls; + + const warnings: string[] = []; + let capturedB64: string | undefined; + try { + const usage = upstreamBody.usage; + const rawCost = + usage !== null && typeof usage === "object" && !Array.isArray(usage) + ? (usage as Record).cost + : undefined; + const cost = typeof rawCost === "number" ? rawCost : undefined; + + // Sanitized like the poll-threshold configs: a negative/non-integer cap + // is treated as the default (createServer warns at startup). + const rawCap = record.openRouterVideo?.maxContentBytes; + const cap = + rawCap !== undefined && Number.isInteger(rawCap) && rawCap >= 0 + ? rawCap + : OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES; + + const urlArray = Array.isArray(urls) ? urls : undefined; + 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`, + ); + } + const firstRaw = urlArray?.[0]; + const firstUrl = typeof firstRaw === "string" && firstRaw ? firstRaw : undefined; + if (!firstUrl) { + // Capture FAILURE: persist nothing, leave the job a live + // proxy (the finally clears `capturing`) so the next completed poll — + // whose upstream body may carry usable URLs — retries. Distinguish + // "no unsigned_urls at all" from "unsigned_urls present but [0] is + // unusable" (non-string, or an empty string) — the two point at + // different upstream defects. + 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`, + ); + } else { + logger.warn( + `Upstream video job ${job.upstreamJobId} completed without unsigned_urls — capture skipped, nothing persisted; the next completed poll retries`, + ); + } + return; + } + let contentUrl: URL; + try { + contentUrl = new URL(firstUrl); + } catch { + // Capture failure — persist nothing, retry on the next completed poll. + logger.warn( + `Upstream unsigned_urls[0] is not a valid URL (${firstUrl.slice(0, 100)}) — capture skipped, nothing persisted; the next completed poll retries`, + ); + return; + } + { + // The client's Bearer is forwarded ONLY to the configured provider + // origin — an off-origin content host (CDN or a hostile envelope) + // must not receive it. + const providerBase = record.providers.openrouter; + let providerOrigin: string | undefined; + try { + providerOrigin = new URL(providerBase ?? job.upstreamPollingUrl).origin; + } catch { + try { + providerOrigin = new URL(job.upstreamPollingUrl).origin; + } catch { + // Double parse failure (unreachable in practice — the polling URL + // was origin-validated at submit). The relay has already left, so + // there is no response to 502 — log, persist nothing, and let the + // finally close the capturing window (the job stays a live + // proxy; a later poll retries the capture). + 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`, + ); + return; + } + } + const headers = buildForwardHeaders(req); + if (contentUrl.origin !== providerOrigin) { + delete headers.authorization; + logger.warn( + `Upstream unsigned_urls[0] origin ${contentUrl.origin} differs from the provider origin ${providerOrigin} — fetching content WITHOUT the client's Authorization header`, + ); + } + try { + // Headers gated by upstreamTimeoutMs; the body streams under + // bodyTimeoutMs IDLE semantics so a long steady download (a >30s + // video render) is never aborted by a total deadline. + const contentRes = await fetchHeadersWithTimeout(contentUrl, headers, record); + if (!contentRes.ok) { + // Bounded body sample (idle semantics + the error cap, like the + // content proxy's failure path) — a bare status is not diagnosable. + let sample = ""; + try { + const read = await readBodyIdle(contentRes, record, OPENROUTER_VIDEO_ERROR_BODY_CAP); + if (!read.overCap) sample = read.buf.toString("utf8").slice(0, 200); + } catch { + // Body unreadable — the status alone still names the failure. + } + throw new Error(`Content ${contentRes.status}${sample ? `: ${sample}` : ""}`); + } + const declaredLength = Number(contentRes.headers.get("content-length") ?? NaN); + if (cap > 0 && Number.isFinite(declaredLength) && declaredLength > cap) { + // Memory guard: the upstream DECLARED an over-cap size — skip the + // download entirely instead of buffering it just to discard it. + // Over-cap is the ONE degraded persist (see the function doc): a + // retry would always re-exceed the cap, so the b64-less fixture is + // deliberate and permanent. + void contentRes.body?.cancel().catch(() => {}); + 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)`, + ); + warnings.push( + `Declared content length (${declaredLength} bytes) exceeded maxContentBytes (${cap}) — download skipped, b64 omitted`, + ); + } else { + // The cap is enforced DURING the streamed read: on exceed the + // read aborts and nothing oversized is retained in memory — + // the same-session job serves the placeholder, exactly like + // the declared-length skip above (the same deliberate over-cap + // persist). + const read = await readBodyIdle(contentRes, record, cap); + if (read.overCap) { + 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)`, + ); + warnings.push( + `Captured video exceeded maxContentBytes (${cap}) — download aborted, b64 omitted`, + ); + } else { + capturedB64 = read.buf.toString("base64"); + } + } + } catch (err) { + // Capture failure (headers, status, or body) — transient by nature: + // persist nothing, leave the job live, warn; the next completed poll + // retries (was: persist a b64-less fixture forever). + const msg = err instanceof Error ? err.message : "Unknown capture error"; + logger.warn( + `OpenRouter video content capture failed (${msg}) — nothing persisted; the job keeps proxying live and the next completed poll retries`, + ); + return; + } + } + + const video: VideoResponse["video"] = { + id: job.upstreamJobId, + status: "completed", + ...(capturedB64 !== undefined ? { b64: capturedB64 } : {}), + ...(cost !== undefined ? { cost } : {}), + }; + // World-generation guard: a fixtures reset (POST + // /__aimock/reset/fixtures) landing during the multi-second capture + // above clears BOTH the fixtures array and the job map + // (performFixturesReset) — so map identity is a valid proxy for "same + // world": if `jobs.get(key)` no longer returns this job, the world this + // capture belongs to is gone and persisting would push a stale fixture + // into the NEXT world's array (and write a file the new world never + // asked for). Checked immediately before persistFixture with no await + // in between. + if (jobs.get(key) !== job) { + 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`, + ); + return; + } + // A persist failure cannot ride an X-AIMock-Record-Error header here — + // the relay left before the capture started (the failed branch, which + // persists synchronously, still sets the header). persistFixture logs + // the failure; the replay mutation below still happens so the in-memory + // session serves the captured bytes. + persistFixture({ + record, + providerKey: "openrouter", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + warnings, + logger, + }); + + // Mutate the map entry into a terminal replay job: later polls and the + // content handler serve it locally from here on. An over-cap capture + // never keeps bytes in memory (the streamed read aborted at the cap), + // so the replayed content is the placeholder MP4. Identity-guarded: a + // TTL-evicted (or otherwise replaced) entry is never resurrected. + if (jobs.get(key) === job) { + jobs.set(key, { + kind: "replay", + jobId: job.jobId, + status: "completed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + } + } catch (err) { + // Nothing may reject unhandled out of the detached capture. An + // unexpected throw (every expected failure is handled above) leaves the + // job a live proxy; a later completed poll retries the capture. + const msg = err instanceof Error ? err.message : String(err); + logger.error( + `OpenRouter video capture for job ${job.upstreamJobId} failed unexpectedly (${msg}) — fixture not persisted; the job keeps proxying live`, + ); + } finally { + // Close the capturing window when the map still holds this record job + // (a capture step threw before the replay mutation) — a latched + // `capturing` would make every later poll early-relay and the job + // could never be captured. On the success path the entry was just + // replaced; the detached record object deliberately KEEPS + // capturing=true (see the function doc). + if (jobs.get(key) === job) { + job.capturing = false; + } + } +} diff --git a/src/recorder.ts b/src/recorder.ts index c011195a..81ef740a 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -40,8 +40,33 @@ const STRIP_HEADERS = new Set([ // Not relevant for LLM APIs; avoid leaking or mismatched encoding "cookie", "accept-encoding", + // Mock-internal control headers — meaningless (and potentially confusing + // or leaky) on a real provider's wire. x-aimock-chaos-* is stripped by + // prefix in buildForwardHeaders below. + "x-test-id", + "x-aimock-strict", + "x-aimock-context", ]); +/** + * Build the header set forwarded to an upstream provider from an incoming + * request: everything except hop-by-hop, client-set, and mock-internal + * headers (STRIP_HEADERS, plus the x-aimock-chaos-* prefix family). Shared + * by the generic recorder proxy and the OpenRouter-video live lifecycle + * proxy. + */ +export function buildForwardHeaders(req: http.IncomingMessage): Record { + const out: Record = {}; + for (const [name, val] of Object.entries(req.headers)) { + const lower = name.toLowerCase(); + if (val === undefined || STRIP_HEADERS.has(lower) || lower.startsWith("x-aimock-chaos-")) { + continue; + } + out[name] = Array.isArray(val) ? val.join(", ") : val; + } + return out; +} + /** * Captured upstream response, exposed to the `beforeWriteResponse` hook so * callers can decide whether to relay it or mutate it (e.g. chaos injection). @@ -108,6 +133,18 @@ export type PersistFixtureResult = | { kind: "written"; filepath: string } | { kind: "failed"; error: string }; +/** + * Make an arbitrary string safe to set as an HTTP header value. Node's + * `res.setHeader` throws ERR_INVALID_CHAR on anything outside Latin-1 (plus + * control characters) — and persist errors embed filesystem paths verbatim, + * so a Unicode fixture path would turn a recoverable record failure into a + * 500. Out-of-range characters are replaced with `?` rather than stripped so + * the value's shape stays legible. + */ +export function sanitizeHeaderValue(value: string): string { + return value.replace(/[^\t\x20-\x7e\x80-\xff]/g, "?"); +} + /** * Write a built fixture to disk (snapshot vs. timestamp file layout) and, when * the match is non-empty, register it in the in-memory cache so subsequent @@ -134,15 +171,18 @@ export function persistFixture(opts: { const m = fixture.match; const isEmptyMatch = m.userMessage === undefined && m.inputText === undefined && m.endpoint === undefined; - if (isEmptyMatch) { - logger.warn("Recorded fixture has empty match criteria — skipping in-memory registration"); - } if (record.proxyOnly) { logger.info(`Proxied ${providerKey} request (proxy-only mode)`); return { kind: "skipped" }; } + // Warned only past the proxy-only early-return: under proxy-only nothing is + // persisted or registered, so an empty match has no consequence there. + if (isEmptyMatch) { + logger.warn("Recorded fixture has empty match criteria — skipping in-memory registration"); + } + const fixturePath = record.fixturePath ?? "./fixtures/recorded"; let isSnapshotMode = testId !== DEFAULT_TEST_ID; let filepath: string; @@ -186,7 +226,26 @@ export function persistFixture(opts: { if (mergeExisting && fs.existsSync(filepath)) { try { const existing = JSON.parse(fs.readFileSync(filepath, "utf-8")); - fileContent = { fixtures: [...(existing.fixtures ?? []), fixture] }; + // Guard the spread: a non-array `fixtures` (e.g. a string from a + // hand-edited file) would silently spread into single characters and + // mangle the merged file. Treat it like the corrupt-JSON case below. + const existingFixtures = Array.isArray(existing.fixtures) + ? (existing.fixtures as unknown[]) + : undefined; + if (existingFixtures === undefined && existing.fixtures !== undefined) { + logger.warn( + `Existing fixture file ${filepath} has a non-array "fixtures" — discarding it and starting fresh`, + ); + } + fileContent = { fixtures: [...(existingFixtures ?? []), fixture] }; + // Carry an existing _warning forward — a later clean capture merging + // into the same snapshot file must not erase an earlier capture's + // warning (e.g. an over-cap b64 omission). Split the "; "-joined + // string back into its elements first so a re-emitted warning dedupes + // against them ("A; B" + "A" must stay "A; B", not "A; B; A"). + if (typeof existing._warning === "string" && existing._warning) { + fileWarnings.unshift(...existing._warning.split("; ")); + } } catch (mergeErr) { const msg = mergeErr instanceof Error ? mergeErr.message : "unknown"; logger.warn(`Could not read existing fixture file ${filepath} (${msg}) — overwriting`); @@ -196,7 +255,9 @@ export function persistFixture(opts: { fileContent = { fixtures: [fixture] }; } if (fileWarnings.length > 0) { - fileContent._warning = fileWarnings.join("; "); + // Exact-duplicate warnings (e.g. repeated over-cap captures merging into + // the same snapshot file) collapse to one entry. + fileContent._warning = [...new Set(fileWarnings)].join("; "); } // Atomic write: write to temp file then rename to avoid read-modify-write // races. Keep synchronous — for streamed responses the HTTP reply is @@ -273,12 +334,7 @@ export async function proxyAndRecord( defaults.logger.warn(`NO FIXTURE MATCH — proxying to ${upstreamUrl}${pathname}`); // Forward all request headers except hop-by-hop and client-set ones. - const forwardHeaders: Record = {}; - for (const [name, val] of Object.entries(req.headers)) { - if (val !== undefined && !STRIP_HEADERS.has(name)) { - forwardHeaders[name] = Array.isArray(val) ? val.join(", ") : val; - } - } + const forwardHeaders = buildForwardHeaders(req); const requestBody = rawBody ?? JSON.stringify(request); @@ -592,7 +648,7 @@ export async function proxyAndRecord( }); if (persistResult.kind === "failed") { if (!res.headersSent) { - res.setHeader("X-AIMock-Record-Error", persistResult.error); + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); } else { defaults.logger.warn(`Cannot set X-AIMock-Record-Error header — headers already sent`); } @@ -686,7 +742,12 @@ export class StreamingFrameDecoder { } } -function clampTimeout(value: number | undefined, fallback: number): number { +/** + * Sanitize a configured timeout: non-finite or non-positive values fall back + * to the default. Shared with the OpenRouter-video live lifecycle proxy so + * `record.upstreamTimeoutMs` is clamped identically on every proxy path. + */ +export function clampTimeout(value: number | undefined, fallback: number): number { if (value == null || !Number.isFinite(value) || value <= 0) return fallback; return value; } diff --git a/src/server.ts b/src/server.ts index 7c13c3df..108cd39f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -63,6 +63,7 @@ import { handleOpenRouterVideoContent, handleOpenRouterVideoModels, OpenRouterVideoJobMap, + OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES, } from "./openrouter-video.js"; import { handleElevenLabsAudio, handleElevenLabsTTS } from "./elevenlabs-audio.js"; import { handleFalQueue, falJobs } from "./fal-audio.js"; @@ -1141,6 +1142,18 @@ export async function createServer( } } + // Validate the recorded-b64 cap: the capture path treats a negative or + // non-integer record.openRouterVideo.maxContentBytes as the default rather + // than letting `cap > 0` checks misbehave on negatives or NaN. + { + const cap = options?.record?.openRouterVideo?.maxContentBytes; + if (cap !== undefined && (!Number.isInteger(cap) || cap < 0)) { + logger.warn( + `record.openRouterVideo.maxContentBytes (${cap}) is not a non-negative integer — using the default cap (${OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES})`, + ); + } + } + // Programmatic default: finite caps so long-running embedders don't inherit // an unbounded journal / fixture-count map. Callers that need unbounded // retention (e.g. short-lived test harnesses) can opt in by passing 0. @@ -1347,7 +1360,7 @@ export async function createServer( const openRouterVideoContentMatch = pathname.match(OPENROUTER_VIDEO_CONTENT_RE); if (openRouterVideoContentMatch && req.method === "GET") { try { - handleOpenRouterVideoContent( + await handleOpenRouterVideoContent( req, res, openRouterVideoContentMatch[1], @@ -1393,7 +1406,7 @@ export async function createServer( // status RE, whose [^/]+ segment would otherwise capture "models") if (pathname === OPENROUTER_VIDEO_MODELS_PATH && req.method === "GET") { try { - handleOpenRouterVideoModels(req, res, fixtures, journal, defaults, setCorsHeaders); + await handleOpenRouterVideoModels(req, res, fixtures, journal, defaults, setCorsHeaders); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video models: ${msg}`); @@ -1431,10 +1444,11 @@ export async function createServer( const openRouterVideoStatusMatch = pathname.match(OPENROUTER_VIDEO_STATUS_RE); if (openRouterVideoStatusMatch && req.method === "GET") { try { - handleOpenRouterVideoStatus( + await handleOpenRouterVideoStatus( req, res, openRouterVideoStatusMatch[1], + fixtures, journal, defaults, setCorsHeaders, diff --git a/src/types.ts b/src/types.ts index bb6335b5..0a7f8dca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -505,12 +505,14 @@ export interface JournalEntry { /** * What was going to serve this request. "fixture" = a fixture matched (or * would have, before chaos intervened). "proxy" = no fixture matched and - * proxy was configured. "internal" = chaos-path entries where the request - * was served by aimock's own synthetic logic — neither a matched fixture - * nor a configured proxy (e.g. chaos on the OpenRouter video lifecycle - * endpoints; their normal 200/400/401/404 entries omit source). Absent - * when the distinction doesn't apply (e.g. 404/503 fallback where nothing - * was going to serve). + * proxy was configured. "internal" = entries where the request was served + * by aimock's own synthetic logic — neither a matched fixture nor a + * configured proxy: chaos-path entries (e.g. chaos on the OpenRouter + * video lifecycle endpoints; in REPLAY mode their normal 200/400/401/404 + * entries omit source, while record-mode 200s on those endpoints carry + * source:"proxy") AND the OpenRouter video models listing synthesized as + * the fallback after a FAILED proxy attempt. Absent when the distinction + * doesn't apply (e.g. 404/503 fallback where nothing was going to serve). */ source?: "fixture" | "proxy" | "internal"; interrupted?: boolean; @@ -594,7 +596,8 @@ export type RecordProviderKey = | "ollama" | "cohere" | "elevenlabs" - | "fal"; + | "fal" + | "openrouter"; export interface RecordConfig { providers: Partial>; @@ -614,11 +617,30 @@ export interface RecordConfig { * the poll cadence and timeout here if upstream is unusually slow or fast. */ fal?: FalRecordConfig; + /** + * OpenRouter-video-specific recording knobs for the live lifecycle proxy on + * `/api/v1/videos` (submit/poll proxied 1:1; completed jobs are captured + * eagerly as fixtures). + */ + openRouterVideo?: OpenRouterVideoRecordConfig; /** * Connection idle timeout (ms) on the upstream request socket — fires if the * socket is inactive for this duration at any point before the response body * begins. Default: 30_000 (30s). Increase for upstreams with slow initial * responses (reasoning models, queue-backed providers). + * + * Nuance on the OpenRouter video surface: the small-JSON lifecycle fetches + * (submit, status poll, models listing) apply this value as a TOTAL + * deadline on the whole fetch — indistinguishable from an idle timeout for + * envelope-sized bodies. The byte-bearing content fetches (eager capture, + * proxy-only content relay) gate only the response HEADERS on this value; + * their body progress is governed by `bodyTimeoutMs` idle semantics. + * + * Ceiling note: the fetch-based OpenRouter proxy paths run on Node's + * built-in undici dispatcher, which carries its own ~300s + * headers/body-timeout defaults — values above that ceiling do not take + * effect on those paths without installing a custom undici dispatcher + * (out of aimock's scope). */ upstreamTimeoutMs?: number; /** @@ -626,11 +648,30 @@ export interface RecordConfig { * goes silent (no bytes) for this long after the response has started. * Default: 30_000 (30s). Reasoning models under concurrent load can leave * 30s+ gaps between streaming chunks while the model is thinking; lift this - * to e.g. 180_000 in those setups. + * to e.g. 180_000 in those setups. The OpenRouter video content fetches use + * the same idle semantics: the timer re-arms on every chunk, so a steadily + * downloading multi-minute video never times out — only a silent stall does. */ bodyTimeoutMs?: number; } +export interface OpenRouterVideoRecordConfig { + /** + * Maximum decoded video size (bytes) embedded as `b64` in a recorded + * fixture. Captures larger than the cap are persisted WITHOUT `b64` (with a + * `_warning` in the fixture file) so a multi-hundred-MB video cannot bloat + * the fixture directory. The cap is also a memory guard: an upstream + * response that DECLARES an over-cap Content-Length is skipped without + * downloading, and a response with no declared length is streamed with the + * byte count enforced during the read — on exceed the download is aborted + * and nothing oversized is retained in memory. In both cases the + * same-session job serves the placeholder MP4. Default: 33554432 (32 MB + * decoded). Set 0 for unlimited. Negative or non-integer values are treated + * as the default (createServer warns at startup). + */ + maxContentBytes?: number; +} + export interface FalRecordConfig { /** Interval between status polls upstream during recording. Default: 1000ms. */ pollIntervalMs?: number;