diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf8910b7..3e2ecc30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,43 @@
## [Unreleased]
+### Added
+
+- **OpenRouter async video lifecycle** — mocks OpenRouter's dedicated video-generation job API
+ alongside the existing OpenAI-shaped `/v1/videos` handler; both draw from the same
+ `endpoint: "video"` fixture pool. `POST /api/v1/videos` matches fixtures on `prompt`/`model`
+ (endpoint `video`) and returns a `{ id, polling_url, status: "pending" }` job envelope (a
+ model-less submit assumes the default `bytedance/seedance-2.0` model for fixture matching); the
+ envelope reports `"pending"` for API fidelity even though, under the default thresholds, the job
+ is already seeded terminal internally at submit. `GET /api/v1/videos/{jobId}` advances
+ `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config
+ (same poll-threshold semantics as `falQueue`), adding `unsigned_urls` + `usage.cost` on
+ completion and the fixture's `error` message on failure (a default "Video generation failed"
+ message is used when the fixture has no `error` field). Because the job is server-side terminal
+ at submit by default, content is downloadable with zero polls if the client constructs the
+ content URL itself — the documented client flow still needs one status poll to learn
+ `unsigned_urls`. `GET /api/v1/videos/{jobId}/content?index=0` requires Bearer auth (401
+ otherwise) and serves the fixture's `b64` bytes — or a built-in minimal MP4 `ftyp` placeholder —
+ 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
+ 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.
+
+### Fixed
+
+- **fal queue thresholds** — the progression resolver — now shared with the OpenRouter video
+ surface — also sanitizes the pre-existing `falQueue` config: non-finite
+ `pollsBeforeInProgress`/`pollsBeforeCompleted` values (NaN, Infinity) are treated as unset instead
+ 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.
+
## [1.30.0] - 2026-06-09
### Added
diff --git a/README.md b/README.md
index 0637fed6..8c15412c 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or
- **Timing-aware recording and replay** — Recorded fixtures capture per-frame arrival timestamps; replay uses recorded timings for approximate timing reproduction based on recorded TTFT and inter-frame cadence (replay chunk count may differ from recording — TTFT and average pace are preserved, not per-token fidelity) with configurable `--replay-speed` multiplier
- **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `sequenceIndex`, `systemMessage` (gate on host-supplied agent context), or custom predicates
- **[14 LLM Providers](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime (GA + Beta shim), Claude, Gemini (REST + embedContent), Gemini Live, Gemini Interactions, Azure, Bedrock, Vertex AI, Ollama (chat + embeddings), Cohere (chat + embed), ElevenLabs TTS — full streaming support
-- **Multimedia APIs** — [image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [image editing](https://aimock.copilotkit.dev/images) (/v1/images/edits), [text-to-speech](https://aimock.copilotkit.dev/speech) (OpenAI + ElevenLabs), [audio transcription](https://aimock.copilotkit.dev/transcription), [audio translation](https://aimock.copilotkit.dev/transcription) (/v1/audio/translations), [video generation](https://aimock.copilotkit.dev/video), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle)
+- **Multimedia APIs** — [image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [image editing](https://aimock.copilotkit.dev/images) (/v1/images/edits), [text-to-speech](https://aimock.copilotkit.dev/speech) (OpenAI + ElevenLabs), [audio transcription](https://aimock.copilotkit.dev/transcription), [audio translation](https://aimock.copilotkit.dev/transcription) (/v1/audio/translations), [video generation](https://aimock.copilotkit.dev/video), [OpenRouter video generation](https://aimock.copilotkit.dev/openrouter-video) (/api/v1/videos with async job lifecycle), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle)
- **[MCP](https://aimock.copilotkit.dev/mcp-mock) / [A2A](https://aimock.copilotkit.dev/a2a-mock) / [AG-UI](https://aimock.copilotkit.dev/agui-mock) / [Vector](https://aimock.copilotkit.dev/vector-mock)** — Mock every protocol your AI agents use
- **[Chaos Testing](https://aimock.copilotkit.dev/chaos-testing)** — 500 errors, malformed JSON, mid-stream disconnects at any probability
- **Per-Request Strict Mode** — `X-AIMock-Strict` header overrides the server-level `--strict` flag per request (`true`/`1` = strict, `false`/`0` = lenient)
diff --git a/docs/fal-ai/index.html b/docs/fal-ai/index.html
index b114b49e..9312b2b6 100644
--- a/docs/fal-ai/index.html
+++ b/docs/fal-ai/index.html
@@ -274,7 +274,9 @@
Polling Realism
If pollsBeforeCompleted is set lower than pollsBeforeInProgress,
- it is clamped up so IN_PROGRESS is never skipped.
+ it is clamped up so IN_PROGRESS is never skipped. Thresholds are also
+ sanitized: non-finite values are treated as unset, negative or fractional values are
+ floored and clamped to non-negative integers, and invalid values warn at server start.
diff --git a/docs/fixtures/index.html b/docs/fixtures/index.html
index dd8f089b..83e9b96c 100644
--- a/docs/fixtures/index.html
+++ b/docs/fixtures/index.html
@@ -342,8 +342,13 @@
Response Types
Video
- video.id, video.status, video.url?
- Generated video URL with async polling
+ video.id, video.status, video.url?, video.error?, video.b64?, video.cost?
+
+ Generated video with async polling — error is the failure message
+ surfaced by async video jobs, b64 is base64-encoded video bytes served
+ by content-download endpoints, cost is the generation cost surfaced in
+ usage envelopes
+
diff --git a/docs/openrouter-video/index.html b/docs/openrouter-video/index.html
new file mode 100644
index 00000000..1f510b38
--- /dev/null
+++ b/docs/openrouter-video/index.html
@@ -0,0 +1,264 @@
+
+
+
+
+
+ OpenRouter Video — aimock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OpenRouter Video
+
+ aimock mocks OpenRouter's dedicated video-generation job API under
+ /api/v1/videos — submit a job, poll it through
+ pending → in_progress → completed | failed, and download the bytes.
+ It draws from the same endpoint: "video" fixture pool as the OpenAI-shaped
+ /v1/videos handler.
+
+
+ Endpoints
+
+
+
+ Method
+ Path
+ Response
+
+
+
+
+ POST
+ /api/v1/videos
+
+ { id, polling_url, status: "pending" } job envelope; the matched
+ fixture's video drives the job's terminal state
+
+
+
+ GET
+ /api/v1/videos/{jobId}
+
+ { id, status } — plus unsigned_urls +
+ usage.cost once completed, or error once
+ failed
+
+
+
+ GET
+ /api/v1/videos/{jobId}/content
+
+ The video bytes as video/mp4 (Bearer auth required; 400 before the job
+ completes)
+
+
+
+ GET
+ /api/v1/videos/models
+ { data: […] } video-model listing
+
+
+
+
+ Fixture Authoring
+
+ Submits are matched against endpoint: "video" fixtures on the request's
+ prompt (via match.userMessage) and model (via
+ match.model). A submit without a model assumes the default
+ bytedance/seedance-2.0 for matching, so a fixture restricted to that model
+ still matches model-less submits.
+
+
+
+
+
mock .onVideo ("a cat playing piano" , {
+ video : { status : "completed" , b64 : "AAAAGGZ0eXBpc29t..." , cost : 0.12 },
+});
+
+// A failed job:
+mock .onVideo ("impossible prompt" , {
+ video : { status : "failed" , error : "content policy violation" },
+});
+
+
+ The fixture's video object supports:
+
+
+ status — "completed" or "failed" sets the
+ job's terminal state (any other status is coerced to completed, with a warning)
+
+
+ id — ignored on this surface (the job id is always a server-minted
+ UUID); the /v1/videos surface does use it
+
+ error? — failure message surfaced on a failed status poll
+
+ b64? — base64-encoded video bytes served by the content endpoint
+
+
+ cost? — generation cost surfaced as usage.cost on
+ completion
+
+
+ url? — ignored on this surface (the content endpoint serves bytes,
+ not a redirect); use b64
+
+
+
+ Polling Realism
+
+ By default a submitted job is seeded terminal internally — the submit envelope still
+ reports "pending" for API fidelity, but content is downloadable with zero
+ polls and the first status poll reports the terminal status. To exercise client code that
+ reacts to intermediate states, pass openRouterVideo with poll thresholds. The
+ semantics are identical to falQueue , mapped onto pending / in_progress /
+ completed | failed.
+
+
+
+
+
const mock = new LLMock ({
+ port : 0 ,
+ openRouterVideo : { pollsBeforeInProgress : 1 , pollsBeforeCompleted : 2 },
+});
+
+// Submit → { id, polling_url, status: "pending" }
+// poll 1 → in_progress
+// poll 2 → completed, unsigned_urls + usage.cost
+// content → 200 video/mp4 bytes
+
+
+
+
+ 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
+ pollsBeforeCompleted defaulting to
+ pollsBeforeInProgress + 1 so the job passes through
+ in_progress. An explicit pollsBeforeCompleted lower than
+ pollsBeforeInProgress is clamped up so in_progress is never
+ skipped.
+
+
+
+
+ Thresholds are sanitized: non-finite values (NaN, Infinity) are
+ treated as unset, and negative or fractional values are floored and clamped to
+ non-negative integers. createServer warns at startup on invalid values.
+
+
+ Authentication
+
+ Only the content endpoint enforces auth:
+ GET /api/v1/videos/{jobId}/content requires a Bearer
+ Authorization header (any non-empty credential) and returns 401 otherwise,
+ matching the real API. Status polls and the models listing are served without auth —
+ a deliberate divergence to keep test polling loops friction-free.
+
+
+ Content Serving
+
+ The content endpoint serves the fixture's b64 bytes when present, or a
+ 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.
+
+
+ Test Isolation
+
+ Generated URLs (polling_url, unsigned_urls) embed the request's
+ testId as a ?testId= query param. The @openrouter/sdk fetches
+ these URLs with standard Authorization but no aimock-specific headers, so the testId must
+ travel in the URL for job state to resolve to the right test scope. The default testId is
+ omitted to keep single-tenant URLs clean.
+
+
+ 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).
+
+
+ Chaos & Metrics
+
+ Chaos injection applies to all four routes; journal entries
+ for chaos served without a matched fixture carry 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.
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/sidebar.js b/docs/sidebar.js
index 4abfd062..89422204 100644
--- a/docs/sidebar.js
+++ b/docs/sidebar.js
@@ -36,6 +36,7 @@
{ label: "Text-to-Speech", href: "/speech" },
{ label: "Audio Transcription", href: "/transcription" },
{ label: "Video Generation", href: "/video" },
+ { label: "OpenRouter Video", href: "/openrouter-video" },
{ label: "fal.ai", href: "/fal-ai" },
],
},
diff --git a/docs/video/index.html b/docs/video/index.html
index 38c0910a..ccc73c5a 100644
--- a/docs/video/index.html
+++ b/docs/video/index.html
@@ -78,6 +78,11 @@ Endpoints
+
+ OpenRouter's dedicated video-generation surface (/api/v1/videos) is
+ documented separately at OpenRouter Video .
+
+
Async Polling Pattern
Video generation is asynchronous. The POST endpoint returns a job ID, and the
diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts
index b3be3e4a..1688988b 100644
--- a/src/__tests__/metrics.test.ts
+++ b/src/__tests__/metrics.test.ts
@@ -1,5 +1,8 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
+import fs from "node:fs";
import http from "node:http";
+import os from "node:os";
+import path from "node:path";
import * as metricsModule from "../metrics.js";
import { createMetricsRegistry, normalizePathLabel, type MetricsRegistry } from "../metrics.js";
import { createServer, type ServerInstance } from "../server.js";
@@ -49,9 +52,10 @@ async function httpPost(
async function httpGet(
url: string,
+ headers?: Record,
): Promise<{ status: number; body: string; headers: Record }> {
return new Promise((resolve, reject) => {
- const req = http.request(url, { method: "GET" }, (res) => {
+ const req = http.request(url, { method: "GET", headers }, (res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () =>
@@ -433,6 +437,30 @@ describe("normalizePathLabel", () => {
),
).toBe("/v1/projects/{p}/locations/{l}/publishers/google/models/{m}:streamGenerateContent");
});
+
+ it("normalizes OpenRouter video status path", () => {
+ expect(normalizePathLabel("/api/v1/videos/0b126396-2b78-4f08-a2a0-0e8de15c1b5a")).toBe(
+ "/api/v1/videos/{jobId}",
+ );
+ });
+
+ it("normalizes OpenRouter video content path", () => {
+ expect(normalizePathLabel("/api/v1/videos/0b126396-2b78-4f08-a2a0-0e8de15c1b5a/content")).toBe(
+ "/api/v1/videos/{jobId}/content",
+ );
+ });
+
+ it("leaves the OpenRouter video models listing path unchanged", () => {
+ expect(normalizePathLabel("/api/v1/videos/models")).toBe("/api/v1/videos/models");
+ });
+
+ it("leaves the OpenRouter video submit path unchanged", () => {
+ expect(normalizePathLabel("/api/v1/videos")).toBe("/api/v1/videos");
+ });
+
+ it("normalizes OpenAI video status path", () => {
+ expect(normalizePathLabel("/v1/videos/video_abc123")).toBe("/v1/videos/{id}");
+ });
});
describe("MetricsRegistry: all three types serialized together", () => {
@@ -569,7 +597,8 @@ describe("integration: /metrics endpoint", () => {
// Require both labels: action AND source. The source label is part of the
// public metric contract (added when chaos was extended to proxy mode) and
// an unasserted label is a regression hazard — future callers that forget
- // to pass source would serialize `source=""` and pass a bare action match.
+ // to pass source would produce a series without the source label, which
+ // would pass a bare action match but fails this regex.
expect(res.body).toMatch(
/aimock_chaos_triggered_total\{[^}]*action="drop"[^}]*source="fixture"[^}]*\} 1/,
);
@@ -583,13 +612,14 @@ describe("integration: /metrics endpoint", () => {
[{ match: { userMessage: "hi" }, response: { content: "upstream" } }],
{ port: 0 },
);
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-metrics-proxy-source-"));
try {
instance = await createServer([], {
metrics: true,
chaos: { dropRate: 1.0 },
record: {
providers: { openai: upstream.url },
- fixturePath: "/tmp/aimock-metrics-proxy-source",
+ fixturePath: fixtureDir,
proxyOnly: true,
},
});
@@ -602,6 +632,7 @@ describe("integration: /metrics endpoint", () => {
);
} finally {
await new Promise((resolve) => upstream.server.close(() => resolve()));
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
}
});
@@ -630,6 +661,42 @@ describe("integration: /metrics endpoint", () => {
);
});
+ it("OpenRouter video lifecycle paths are templated (no per-job label cardinality)", async () => {
+ const fixtures: Fixture[] = [
+ {
+ match: { userMessage: "metrics video", endpoint: "video" },
+ response: { video: { id: "vid_mx", status: "completed", b64: "AAAA" } },
+ },
+ ];
+ instance = await createServer(fixtures, { metrics: true });
+
+ const submit = await httpPost(`${instance.url}/api/v1/videos`, {
+ model: "m/v",
+ prompt: "metrics video",
+ });
+ expect(submit.status).toBe(200);
+ const { id } = JSON.parse(submit.body) as { id: string };
+
+ // Default 0/0 progression: the first poll reports completed.
+ expect((await httpGet(`${instance.url}/api/v1/videos/${id}`)).status).toBe(200);
+ expect(
+ (
+ await httpGet(`${instance.url}/api/v1/videos/${id}/content?index=0`, {
+ Authorization: "Bearer test",
+ })
+ ).status,
+ ).toBe(200);
+
+ const res = await httpGet(`${instance.url}/metrics`);
+ // Boundary-aware: a bare toContain would be substring-satisfied by the
+ // {jobId} line.
+ expect(res.body).toMatch(/path="\/api\/v1\/videos"[,}]/);
+ expect(res.body).toContain('path="/api/v1/videos/{jobId}"');
+ expect(res.body).toContain('path="/api/v1/videos/{jobId}/content"');
+ // The job UUID must never appear as a label value.
+ expect(res.body).not.toContain(id);
+ });
+
it("tracks fixtures loaded gauge", async () => {
const fixtures: Fixture[] = [
{ match: { userMessage: "a" }, response: { content: "1" } },
@@ -702,5 +769,10 @@ describe("integration: /metrics endpoint", () => {
// still return 200 — proof that the catch block in res.on("finish") swallows the error.
const res2 = await httpPost(`${instance.url}/v1/chat/completions`, chatRequest("hello"));
expect(res2.status).toBe(200);
+
+ // Guard against vacuous green: the faulty registry must actually have been
+ // exercised. If the spy stopped intercepting createMetricsRegistry, the real
+ // registry would serve both requests and callCount would stay 0.
+ expect(callCount).toBeGreaterThanOrEqual(2);
});
});
diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts
new file mode 100644
index 00000000..b73d91d1
--- /dev/null
+++ b/src/__tests__/openrouter-video.test.ts
@@ -0,0 +1,2881 @@
+import { describe, test, expect, 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 { SKIPPED_BY_STATE_RE } from "./helpers/strict-matchers.js";
+
+// ─── 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)", () => {
+ expect(resolveProgression(undefined)).toEqual({
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 0,
+ });
+ });
+
+ test("inProgress-only config defaults completed to one poll later", () => {
+ expect(resolveProgression({ pollsBeforeInProgress: 2 })).toEqual({
+ pollsBeforeInProgress: 2,
+ pollsBeforeCompleted: 3,
+ });
+ });
+
+ test("clamps completed >= inProgress", () => {
+ expect(resolveProgression({ pollsBeforeInProgress: 3, pollsBeforeCompleted: 1 })).toEqual({
+ pollsBeforeInProgress: 3,
+ pollsBeforeCompleted: 3,
+ });
+ });
+
+ test("treats non-finite thresholds as unset (NaN can never propagate)", () => {
+ // A NaN threshold would make advanceJob's `pollCount >= NaN` comparison
+ // permanently false — a polling client would never reach terminal.
+ expect(resolveProgression({ pollsBeforeCompleted: NaN })).toEqual({
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 0,
+ });
+ expect(resolveProgression({ pollsBeforeInProgress: NaN })).toEqual({
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 0,
+ });
+ expect(resolveProgression({ pollsBeforeCompleted: Infinity })).toEqual({
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 0,
+ });
+ });
+
+ test("clamps negative thresholds to 0 (explicit-0 progression contract)", () => {
+ // {pollsBeforeInProgress: -1} must behave as an explicit 0 — progression
+ // enabled — not resolve completed to 0 (seed-terminal).
+ expect(resolveProgression({ pollsBeforeInProgress: -1 })).toEqual({
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 1,
+ });
+ expect(resolveProgression({ pollsBeforeInProgress: 2, pollsBeforeCompleted: -5 })).toEqual({
+ pollsBeforeInProgress: 2,
+ pollsBeforeCompleted: 2,
+ });
+ });
+
+ test("floors fractional thresholds to integers", () => {
+ expect(resolveProgression({ pollsBeforeInProgress: 1.9 })).toEqual({
+ pollsBeforeInProgress: 1,
+ pollsBeforeCompleted: 2,
+ });
+ });
+});
+
+describe("VideoResponse extended fields", () => {
+ test("accepts error, b64, and cost on the video object", () => {
+ const failed: VideoResponse = {
+ video: { id: "v1", status: "failed", error: "policy violation" },
+ };
+ const completed: VideoResponse = {
+ video: { id: "v2", status: "completed", b64: "AAAA", cost: 0.05 },
+ };
+ expect(failed.video.error).toBe("policy violation");
+ expect(completed.video.b64).toBe("AAAA");
+ expect(completed.video.cost).toBe(0.05);
+ });
+
+ test("openRouterVideo progression config is accepted in server options", async () => {
+ const mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
+ });
+ await mock.start();
+ try {
+ expect(mock.url).toMatch(/^http:/);
+ } finally {
+ await mock.stop();
+ }
+ });
+});
+
+// ─── Task 2: POST /api/v1/videos (submit) ───────────────────────────────────
+
+describe("POST /api/v1/videos (OpenRouter submit)", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("fixture match returns {id, polling_url, status: pending}", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "a sunset over the ocean", endpoint: "video" },
+ response: {
+ video: { id: "vid_or_1", status: "completed", url: "https://example.com/v.mp4" },
+ },
+ });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
+ body: JSON.stringify({
+ model: "bytedance/seedance-2.0",
+ prompt: "a sunset over the ocean",
+ }),
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(typeof data.id).toBe("string");
+ expect(data.id.length).toBeGreaterThan(0);
+ expect(data.status).toBe("pending");
+ expect(data.polling_url).toBe(`${mock.url}/api/v1/videos/${data.id}`);
+ });
+
+ test("matches on model when the fixture restricts it", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { model: "bytedance/seedance-2.0", endpoint: "video" },
+ response: { video: { id: "vid_m", status: "completed" } },
+ });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "anything" }),
+ });
+ expect(res.status).toBe(200);
+ });
+
+ test("malformed JSON body returns 400 invalid_json", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{not json",
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.code).toBe("invalid_json");
+ });
+
+ test("missing prompt returns 400", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.message).toContain("prompt");
+ });
+
+ test("no fixture match returns OpenRouter-shaped 404 in non-strict mode", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "no such fixture" }),
+ });
+ expect(res.status).toBe(404);
+ const data = await res.json();
+ expect(data.error.code).toBe(404);
+ expect(typeof data.error.message).toBe("string");
+ });
+
+ test("no fixture match returns 503 in strict mode", async () => {
+ mock = new LLMock({ port: 0, strict: true });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "no such fixture" }),
+ });
+ expect(res.status).toBe(503);
+ const data = await res.json();
+ expect(data.error.code).toBe("no_fixture_match");
+ });
+
+ test("error fixture returns the configured status", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "rate me", endpoint: "video" },
+ response: { error: { message: "rate limited", type: "rate_limit_error" }, status: 429 },
+ });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "rate me" }),
+ });
+ expect(res.status).toBe(429);
+ const data = await res.json();
+ expect(data.error.message).toBe("rate limited");
+ });
+
+ test("status poll after submit reaches completed with unsigned_urls and usage", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "poll me", endpoint: "video" },
+ response: { video: { id: "vid_p", status: "completed", cost: 0.05 } },
+ });
+ 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: "poll me" }),
+ });
+ const { id } = await submit.json();
+
+ const poll = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(poll.status).toBe(200);
+ const data = await poll.json();
+ expect(data.id).toBe(id);
+ expect(data.status).toBe("completed");
+ expect(data.unsigned_urls).toEqual([`${mock.url}/api/v1/videos/${id}/content?index=0`]);
+ expect(data.usage).toEqual({ cost: 0.05 });
+ });
+
+ test("usage.cost defaults to 0 when the fixture omits it", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "free", endpoint: "video" },
+ response: { video: { id: "vid_f", 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: "m/v", prompt: "free" }),
+ });
+ const { id } = await submit.json();
+ const data = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(data.usage).toEqual({ cost: 0 });
+ });
+
+ test("failed fixture polls to failed with error message", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "doomed", endpoint: "video" },
+ response: { video: { id: "vid_x", status: "failed", error: "content policy violation" } },
+ });
+ 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: "doomed" }),
+ });
+ const { id } = await submit.json();
+
+ const data = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(data.status).toBe("failed");
+ expect(data.error).toBe("content policy violation");
+ expect(data.unsigned_urls).toBeUndefined();
+ });
+
+ test("failed fixture without error message uses default", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "doomed quietly", endpoint: "video" },
+ response: { video: { id: "vid_q", status: "failed" } },
+ });
+ 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: "doomed quietly" }),
+ });
+ const { id } = await submit.json();
+ const data = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(data.status).toBe("failed");
+ expect(data.error).toBe("Video generation failed");
+ });
+
+ test("status poll for unknown job returns 404", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos/nonexistent-job`);
+ expect(res.status).toBe(404);
+ const data = await res.json();
+ expect(data.error.code).toBe(404);
+ });
+
+ test("configured progression advances pending → in_progress → completed", async () => {
+ mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
+ });
+ mock.addFixture({
+ match: { userMessage: "staged", endpoint: "video" },
+ response: { video: { id: "vid_s", 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: "m/v", prompt: "staged" }),
+ });
+ const { id, status } = await submit.json();
+ expect(status).toBe("pending");
+
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("completed");
+ });
+
+ test("equal thresholds still pass through in_progress for one poll", async () => {
+ mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 2, pollsBeforeCompleted: 2 },
+ });
+ mock.addFixture({
+ match: { userMessage: "equal", endpoint: "video" },
+ response: { video: { id: "vid_e", 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: "m/v", prompt: "equal" }),
+ });
+ const { id } = await submit.json();
+
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("pending");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("in_progress");
+ const poll3 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll3.status).toBe("completed");
+ });
+
+ test("progression applies to failed jobs too", async () => {
+ mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
+ });
+ mock.addFixture({
+ match: { userMessage: "staged fail", endpoint: "video" },
+ response: { video: { id: "vid_sf", status: "failed", error: "boom" } },
+ });
+ 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: "staged fail" }),
+ });
+ const { id } = await submit.json();
+
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("failed");
+ expect(poll2.error).toBe("boom");
+ });
+
+ test("non-video fixture response returns 500", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "text only", endpoint: "video" },
+ response: { content: "not a video" },
+ });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "text only" }),
+ });
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.error.type).toBe("server_error");
+ expect(data.error.message).toBe("Fixture response is not a video type");
+ });
+});
+
+// ─── Task 4: GET /api/v1/videos/{jobId}/content — download ──────────────────
+
+describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ async function submitJob(prompt: string): Promise {
+ if (!mock) throw new Error("mock not started");
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ return id;
+ }
+
+ test("requires Authorization header (401 OpenRouter shape)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "auth me", endpoint: "video" },
+ response: { video: { id: "vid_a", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await submitJob("auth me");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`);
+ expect(res.status).toBe(401);
+ const data = await res.json();
+ expect(data.error.message).toBe("No auth credentials found");
+ expect(data.error.code).toBe(401);
+ });
+
+ test("404 for unknown job", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos/nope/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(404);
+ const data = await res.json();
+ expect(data.error.code).toBe(404);
+ });
+
+ test("non-completed job returns a JSON error", async () => {
+ mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 5, pollsBeforeCompleted: 10 },
+ });
+ mock.addFixture({
+ match: { userMessage: "slow", endpoint: "video" },
+ response: { video: { id: "vid_sl", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await submitJob("slow");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(400);
+ expect(res.headers.get("content-type") ?? "").toContain("application/json");
+ const data = await res.json();
+ expect(data.error.message).toContain("not completed");
+ });
+
+ test("content fetches never advance job state (no poll budget consumed)", async () => {
+ mock = new LLMock({ port: 0, openRouterVideo: { pollsBeforeCompleted: 1 } });
+ mock.addFixture({
+ match: { userMessage: "no advance", endpoint: "video" },
+ response: { video: { id: "vid_na", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await submitJob("no advance");
+
+ // Two content fetches against the not-yet-completed job: both must 400
+ // (API fidelity; diverges from fal's advance-on-result queue semantics).
+ for (let i = 0; i < 2; i++) {
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(400);
+ expect((await res.json()).error.message).toContain("not completed");
+ }
+
+ // First real status poll: had the content fetches advanced the job, it
+ // would already be past the pollsBeforeCompleted: 1 threshold. Instead
+ // poll 1 reports in_progress — content fetches consumed no poll budget.
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ });
+
+ test("serves base64 fixture bytes as video/mp4", async () => {
+ const bytes = Buffer.from("mock video bytes");
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "bytes", endpoint: "video" },
+ response: {
+ video: { id: "vid_b", status: "completed", b64: bytes.toString("base64") },
+ },
+ });
+ await mock.start();
+ const id = await submitJob("bytes");
+ // Status poll before download mirrors the real client flow. Under the
+ // default 0/0 progression the job is already seeded terminal at submit,
+ // so this poll is not what makes the job completed.
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer();
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("video/mp4");
+ expect(res.headers.get("content-length")).toBe(String(bytes.length));
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(bytes)).toBe(true);
+ });
+
+ test("serves built-in mp4 placeholder when fixture has no b64", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "placeholder", endpoint: "video" },
+ response: { video: { id: "vid_ph", status: "completed" } },
+ });
+ await mock.start();
+ const id = await submitJob("placeholder");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("video/mp4");
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.length).toBeGreaterThan(0);
+ expect(res.headers.get("content-length")).toBe(String(body.length));
+ // ftyp box marker at byte offset 4
+ expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp");
+ });
+
+ test("replies video/mp4 even when the client sends Accept: application/octet-stream", async () => {
+ // The @openrouter/sdk (Speakeasy-generated) sends Accept:
+ // application/octet-stream but the real endpoint replies video/mp4.
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "accept octet", endpoint: "video" },
+ response: { video: { id: "vid_ao", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await submitJob("accept octet");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test", Accept: "application/octet-stream" },
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("video/mp4");
+ });
+});
+
+// ─── Task 5: GET /api/v1/videos/models — model listing ──────────────────────
+
+describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("synthesizes the listing from video fixtures with string models", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { model: "bytedance/seedance-2.0", endpoint: "video" },
+ response: { video: { id: "v1", status: "completed" } },
+ });
+ mock.addFixture({
+ match: { model: "openai/sora-2", endpoint: "video" },
+ response: { video: { id: "v2", status: "completed" } },
+ });
+ // Non-video fixture model must NOT appear
+ mock.addFixture({
+ match: { model: "gpt-4o", userMessage: "hi" },
+ response: { content: "hello" },
+ });
+ // Regex-model video fixture must NOT appear (string models only)
+ mock.addFixture({
+ match: { model: /kling/, endpoint: "video" },
+ response: { video: { id: "v3", 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();
+ const ids = data.data.map((m: { id: string }) => m.id);
+ // Order-insensitive: ids come from Set iteration, not a documented order.
+ expect(ids).toHaveLength(2);
+ expect(ids).toEqual(expect.arrayContaining(["bytedance/seedance-2.0", "openai/sora-2"]));
+ for (const entry of data.data) {
+ expect(typeof entry.name).toBe("string");
+ expect(Array.isArray(entry.supported_durations)).toBe(true);
+ expect(Array.isArray(entry.supported_resolutions)).toBe(true);
+ expect(Array.isArray(entry.supported_aspect_ratios)).toBe(true);
+ }
+ });
+
+ test("falls back to built-in defaults and is not swallowed by the status handler", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ // If the status RE captured "models" as a jobId, this would be the status
+ // handler's 404 shape: { error: { message: "Video job models not found",
+ // code: 404 } }. Assert that exact shape is absent and that the built-in
+ // default model listing comes back instead (no video fixtures loaded).
+ const res = await fetch(`${mock.url}/api/v1/videos/models`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.error).toBeUndefined();
+ expect(JSON.stringify(data)).not.toContain("Video job models not found");
+ expect(Array.isArray(data.data)).toBe(true);
+ expect(data.data.length).toBeGreaterThan(0);
+ expect(typeof data.data[0].id).toBe("string");
+ });
+
+ test("warns when video fixtures exist but none contribute a string model", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ // RegExp-model video fixture: real video fixtures are loaded, yet the
+ // listing silently falls back to the default model set.
+ mock.addFixture({
+ match: { model: /kling/, endpoint: "video" },
+ response: { video: { id: "vid_rx", 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.length).toBeGreaterThan(0);
+ expect(
+ warnSpy.mock.calls.some((c) =>
+ c.join(" ").includes("No video fixture contributes a string model"),
+ ),
+ ).toBe(true);
+ });
+});
+
+// ─── Task 6: cross-cutting conformance ──────────────────────────────────────
+
+describe("OpenRouter video — strict mode diagnostics", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("strict 503 reports sequence/turn skip via shared matcher", async () => {
+ mock = new LLMock({ port: 0, strict: true });
+ mock.addFixture({
+ match: { userMessage: "once only", endpoint: "video", sequenceIndex: 0 },
+ response: { video: { id: "vid_seq", status: "completed" } },
+ });
+ await mock.start();
+
+ const first = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "once only" }),
+ });
+ expect(first.status).toBe(200);
+
+ const second = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "once only" }),
+ });
+ expect(second.status).toBe(503);
+ const data = await second.json();
+ expect(data.error.message).toMatch(SKIPPED_BY_STATE_RE);
+ });
+});
+
+describe("OpenRouter video — journal coverage", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("every lifecycle path journals an entry", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "journal me", endpoint: "video" },
+ response: { video: { id: "vid_j", status: "completed", b64: "AAAA" } },
+ });
+ 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: "journal me" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ // Consume every response body so no connection is left dangling.
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer();
+ await (
+ await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ })
+ ).arrayBuffer();
+ await (await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`)).arrayBuffer(); // 401
+ await (await fetch(`${mock.url}/api/v1/videos/unknown-job`)).arrayBuffer(); // 404
+ await (await fetch(`${mock.url}/api/v1/videos/models`)).arrayBuffer();
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "no match here" }),
+ })
+ ).arrayBuffer(); // 404 no-match
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{nope",
+ })
+ ).arrayBuffer(); // 400 malformed
+
+ const entries = mock.journal.getAll();
+ const byPathStatus = entries.map((e) => `${e.method} ${e.path} ${e.response.status}`);
+ expect(byPathStatus).toContain(`POST /api/v1/videos 200`);
+ expect(byPathStatus).toContain(`GET /api/v1/videos/${id} 200`);
+ expect(byPathStatus).toContain(`GET /api/v1/videos/${id}/content?index=0 200`);
+ expect(byPathStatus).toContain(`GET /api/v1/videos/${id}/content?index=0 401`);
+ expect(byPathStatus).toContain(`GET /api/v1/videos/unknown-job 404`);
+ expect(byPathStatus).toContain(`GET /api/v1/videos/models 200`);
+ expect(byPathStatus).toContain(`POST /api/v1/videos 404`);
+ expect(byPathStatus).toContain(`POST /api/v1/videos 400`);
+ });
+
+ test("a handler throw on submit journals a 500 entry", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "factory boom", endpoint: "video" },
+ response: () => {
+ throw new Error("factory boom");
+ },
+ });
+ 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: "factory boom" }),
+ });
+ expect(res.status).toBe(500);
+ await res.arrayBuffer();
+
+ // Without catch-block journaling the request is invisible — and the
+ // throwing fixture's sequence slot was already consumed with no trace.
+ const entries = mock.journal.getAll();
+ expect(
+ entries.some(
+ (e) => e.method === "POST" && e.path === "/api/v1/videos" && e.response.status === 500,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("OpenRouter video — chaos injection", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("chaos drop header applies to submit, status, and content", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "chaotic", endpoint: "video" },
+ response: { video: { id: "vid_c", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+
+ // Establish a real job first (no chaos)
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "chaotic" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const chaosHeaders = { "x-aimock-chaos-drop": "1" };
+ const droppedSubmit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...chaosHeaders },
+ body: JSON.stringify({ model: "m/v", prompt: "chaotic" }),
+ });
+ expect(droppedSubmit.status).toBe(500);
+ expect((await droppedSubmit.json()).error.code).toBe("chaos_drop");
+
+ const droppedStatus = await fetch(`${mock.url}/api/v1/videos/${id}`, {
+ headers: chaosHeaders,
+ });
+ expect(droppedStatus.status).toBe(500);
+ expect((await droppedStatus.json()).error.code).toBe("chaos_drop");
+
+ const droppedContent = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test", ...chaosHeaders },
+ });
+ expect(droppedContent.status).toBe(500);
+ expect((await droppedContent.json()).error.code).toBe("chaos_drop");
+ });
+});
+
+describe("OpenRouter video — chaos source label and models route", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("submit chaos with no fixture journals source internal (surface never proxies)", async () => {
+ mock = new LLMock({ port: 0 });
+ 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: "no fixture here" }),
+ });
+ expect(dropped.status).toBe(500);
+ expect((await dropped.json()).error.code).toBe("chaos_drop");
+
+ 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 header applies to the models route", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const dropped = await fetch(`${mock.url}/api/v1/videos/models`, {
+ headers: { "x-aimock-chaos-drop": "1" },
+ });
+ expect(dropped.status).toBe(500);
+ expect((await dropped.json()).error.code).toBe("chaos_drop");
+
+ // Without the header the route still serves the listing.
+ const ok = await fetch(`${mock.url}/api/v1/videos/models`);
+ expect(ok.status).toBe(200);
+ });
+});
+
+// ─── CR findings: logger observability ──────────────────────────────────────
+
+describe("OpenRouter video — logger observability", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("warns when a processing fixture is coerced to completed", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "still processing", endpoint: "video" },
+ response: { video: { id: "vid_pr", status: "processing" } },
+ });
+ 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: "still processing" }),
+ });
+ expect(res.status).toBe(200);
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("processing"))).toBe(true);
+ });
+
+ test("warns about the record-mode gap on no-match when record is configured", async () => {
+ mock = new LLMock({
+ port: 0,
+ logLevel: "warn",
+ record: { providers: { openai: "http://127.0.0.1:9" } },
+ });
+ 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("record mode is not supported")),
+ ).toBe(true);
+ });
+
+ test("no record warn fires when record is not configured", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ await mock.start();
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "no fixture" }),
+ });
+ expect(
+ warnSpy.mock.calls.some((c) => c.join(" ").includes("record mode is not supported")),
+ ).toBe(false);
+ });
+
+ test("no-match debug log includes model and prompt snippet", async () => {
+ mock = new LLMock({ port: 0, logLevel: "debug" });
+ await mock.start();
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "acme/video-x", prompt: "a very specific prompt" }),
+ });
+ expect(
+ logSpy.mock.calls.some((c) => {
+ const line = c.join(" ");
+ return (
+ line.includes("No fixture matched") &&
+ line.includes("acme/video-x") &&
+ line.includes("a very specific prompt")
+ );
+ }),
+ ).toBe(true);
+ });
+
+ test("warns when fixture b64 decodes to zero bytes but still serves the decode", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "corrupt bytes", endpoint: "video" },
+ // "!!!!" is non-empty but contains no valid base64 characters — the
+ // decode yields a 0-byte buffer.
+ response: { video: { id: "vid_cb", status: "completed", b64: "!!!!" } },
+ });
+ 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: "corrupt bytes" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ // Status poll mirrors the client flow; under default 0/0 the job is
+ // already seeded terminal at submit, so this poll doesn't complete it.
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer();
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.length).toBe(0); // the decode is served as-is, just warned about
+ // "!!!!" also trips the invalid-character length-mismatch warn — assert the
+ // zero-byte warn specifically rather than any base64-related warn.
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("zero bytes"))).toBe(true);
+ });
+
+ test("warns when b64 contains invalid characters even if the decode is non-empty", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "partial corrupt", endpoint: "video" },
+ // Node's lenient decoder skips the "!!!" run and still yields bytes —
+ // a zero-byte-only guard misses this silently-truncating corruption.
+ response: { video: { id: "vid_pc", status: "completed", b64: "AAAA!!!tail" } },
+ });
+ 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: "partial corrupt" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.length).toBeGreaterThan(0); // the lossy decode is still served
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).toBe(true);
+ });
+
+ test("does not warn on valid non-canonical base64 (nonzero trailing bits)", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "non canonical", endpoint: "video" },
+ // "QR" is valid base64 for the single byte 0x41, but non-canonical: its
+ // final character carries nonzero discarded trailing bits, so it
+ // re-encodes as "QQ". A byte-exact round-trip comparison would falsely
+ // flag it as corrupt.
+ response: { video: { id: "vid_nc", status: "completed", b64: "QR" } },
+ });
+ 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: "non canonical" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(Buffer.from([0x41]))).toBe(true);
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).toBe(false);
+ });
+
+ test("does not warn on concatenated base64 with interior padding", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "interior padding", endpoint: "video" },
+ // "QQ==QQ==" is two valid padded groups concatenated. Node's decoder
+ // treats the first "=" as a terminator and decodes only "QQ==" (one
+ // byte) — counting the post-padding characters as data chars would
+ // falsely flag the payload as corrupt.
+ response: { video: { id: "vid_ip", status: "completed", b64: "QQ==QQ==" } },
+ });
+ 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: "interior padding" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(Buffer.from([0x41]))).toBe(true);
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).toBe(false);
+ });
+
+ test("warns when sanitized b64 length is congruent to 1 mod 4", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "mod4 one", endpoint: "video" },
+ // "QQQQQ" is malformed base64: Node silently drops the trailing "Q"
+ // and the floor formula agrees with the truncated decode, so the
+ // length-mismatch check alone never fires.
+ response: { video: { id: "vid_m1", status: "completed", b64: "QQQQQ" } },
+ });
+ 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: "mod4 one" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200); // the truncated decode is still served
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.length).toBe(3);
+ expect(
+ warnSpy.mock.calls.some((c) =>
+ c.join(" ").includes("payload is malformed or contains invalid characters"),
+ ),
+ ).toBe(true);
+ });
+
+ test("warns when fixture sets video.url but no b64 (placeholder served)", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "url only", endpoint: "video" },
+ // The author set a url expecting it to control the bytes — the
+ // OpenRouter content endpoint ignores it and serves the placeholder.
+ response: {
+ video: { id: "vid_uo", status: "completed", url: "https://example.com/v.mp4" },
+ },
+ });
+ 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: "url only" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp"); // placeholder
+ expect(
+ warnSpy.mock.calls.some((c) => {
+ const line = c.join(" ");
+ return line.includes("url is ignored") && line.includes("b64");
+ }),
+ ).toBe(true);
+ });
+
+ test("warns when fixture b64 is the empty string (placeholder served)", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "empty b64", endpoint: "video" },
+ // b64: "" is falsy — without a dedicated warn the handler silently
+ // serves the placeholder as if b64 were absent entirely.
+ response: { video: { id: "vid_e64", status: "completed", b64: "" } },
+ });
+ 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: "empty b64" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.subarray(4, 8).toString("ascii")).toBe("ftyp"); // placeholder
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("empty b64"))).toBe(true);
+ });
+
+ test("warns when a failed fixture has an empty error and serves the default", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "doomed silently", endpoint: "video" },
+ // error: "" is an authoring mistake — serving a literal "" error would
+ // give polling clients an empty failure reason.
+ response: { video: { id: "vid_ee", status: "failed", error: "" } },
+ });
+ 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: "doomed silently" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const data = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(data.status).toBe("failed");
+ expect(data.error).toBe("Video generation failed");
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("empty error"))).toBe(true);
+ });
+
+ test("warns when a rejected x-forwarded-host falls back to the Host header", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "rejected host", endpoint: "video" },
+ response: { video: { id: "vid_rj", status: "completed" } },
+ });
+ 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", "X-Forwarded-Host": "evil.com/path" },
+ body: JSON.stringify({ model: "m/v", prompt: "rejected host" }),
+ });
+ const envelope = await submit.json();
+ // Falls back to the Host header, which is itself validated (the test's
+ // real host[:port] passes; a junk Host would fall back to localhost).
+ expect(envelope.polling_url.startsWith(`${mock.url}/api/v1/videos/`)).toBe(true);
+ expect(
+ warnSpy.mock.calls.some((c) => c.join(" ").includes("x-forwarded-host value rejected")),
+ ).toBe(true);
+ });
+
+ test("warns on an unknown fixture status and treats the job as completed", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "typo status", endpoint: "video" },
+ response: {
+ // JSON-authored fixtures bypass the compile-time union — simulate a typo.
+ video: { id: "vid_ts", status: "FAILED" as VideoResponse["video"]["status"] },
+ },
+ });
+ 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: "m/v", prompt: "typo status" }),
+ });
+ expect(submit.status).toBe(200);
+ const { id } = (await submit.json()) as { id: string };
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes('"FAILED"'))).toBe(true);
+
+ const poll = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll.status).toBe("completed");
+ });
+
+ test("submit handler throw is logged via logger.error and returns 500", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "boom", endpoint: "video" },
+ response: () => {
+ throw new Error("factory boom");
+ },
+ });
+ await mock.start();
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "boom" }),
+ });
+ expect(res.status).toBe(500);
+ expect(errorSpy.mock.calls.some((c) => c.join(" ").includes("openrouter-video submit"))).toBe(
+ true,
+ );
+ });
+
+ test("submit error 500s carry CORS headers", async () => {
+ mock = new LLMock({ port: 0, logLevel: "silent" });
+ mock.addFixture({
+ match: { userMessage: "cors boom", endpoint: "video" },
+ response: () => {
+ throw new Error("factory boom");
+ },
+ });
+ await mock.start();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ // CORS headers are set on the submit route before the body is read, so
+ // even a failure thrown from readBody (or the handler) produces a 500
+ // that a browser client can actually inspect rather than an opaque
+ // CORS-blocked failure. The readBody rejection paths destroy the socket
+ // (no observable response), so the assertable proxy is a handler throw.
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "cors boom" }),
+ });
+ expect(res.status).toBe(500);
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
+ });
+});
+
+describe("OpenRouter video — full lifecycle integration", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("submit → pending → in_progress → completed → download", async () => {
+ const bytes = Buffer.from("the generated video");
+ mock = new LLMock({
+ port: 0,
+ openRouterVideo: { pollsBeforeInProgress: 2, pollsBeforeCompleted: 3 },
+ });
+ mock.addFixture({
+ match: { userMessage: "full lifecycle", endpoint: "video" },
+ response: {
+ video: {
+ id: "vid_fl",
+ status: "completed",
+ b64: bytes.toString("base64"),
+ cost: 0.12,
+ },
+ },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
+ body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "full lifecycle" }),
+ });
+ expect(submit.status).toBe(200);
+ const envelope = await submit.json();
+ expect(envelope.status).toBe("pending");
+
+ const poll1 = await (await fetch(envelope.polling_url)).json();
+ expect(poll1.status).toBe("pending");
+ const poll2 = await (await fetch(envelope.polling_url)).json();
+ expect(poll2.status).toBe("in_progress");
+ const poll3 = await (await fetch(envelope.polling_url)).json();
+ expect(poll3.status).toBe("completed");
+ expect(poll3.usage).toEqual({ cost: 0.12 });
+ expect(poll3.unsigned_urls).toHaveLength(1);
+
+ const download = await fetch(poll3.unsigned_urls[0], {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(download.status).toBe(200);
+ expect(download.headers.get("content-type")).toBe("video/mp4");
+ const body = Buffer.from(await download.arrayBuffer());
+ expect(body.equals(bytes)).toBe(true);
+ });
+
+ test("failed lifecycle: submit → poll failed → download rejected", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "fail lifecycle", endpoint: "video" },
+ response: { video: { id: "vid_flf", status: "failed", error: "nsfw content" } },
+ });
+ 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: "fail lifecycle" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.status).toBe("pending");
+
+ const poll = await (await fetch(envelope.polling_url)).json();
+ expect(poll.status).toBe("failed");
+ expect(poll.error).toBe("nsfw content");
+ expect(poll.unsigned_urls).toBeUndefined();
+
+ const download = await fetch(`${mock.url}/api/v1/videos/${envelope.id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(download.status).toBe(400);
+ });
+
+ test("download without auth is rejected even for a completed job", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "auth lifecycle", endpoint: "video" },
+ response: { video: { id: "vid_al", status: "completed", b64: "AAAA" } },
+ });
+ 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: "auth lifecycle" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ // Status poll mirrors the client flow; under default 0/0 the job is
+ // already seeded terminal (completed) at submit.
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer();
+
+ const unauthorized = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`);
+ expect(unauthorized.status).toBe(401);
+ expect((await unauthorized.json()).error.message).toBe("No auth credentials found");
+
+ const authorized = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(authorized.status).toBe(200);
+ });
+});
+
+// ─── CR findings: input validation (400, not 500/mismatch) ─────────────────
+
+describe("OpenRouter video — request body validation", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("JSON body `null` returns 400 invalid_request_error (not a raw 500)", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "null",
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ });
+
+ test("JSON array body returns 400 invalid_request_error", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "[1,2]",
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ });
+
+ test("non-string prompt returns 400", async () => {
+ mock = new LLMock({ port: 0 });
+ 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: 123 }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ // Present-but-invalid prompt: "Missing required parameter" would be
+ // wrong — the parameter is there. Mirrors the model path's message style.
+ expect(data.error.message).toBe(
+ "Invalid type for parameter: 'prompt' must be a non-empty string",
+ );
+ });
+
+ test("empty-string prompt returns 400 with a non-empty-string message", async () => {
+ mock = new LLMock({ port: 0 });
+ 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: "" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ expect(data.error.message).toBe(
+ "Invalid type for parameter: 'prompt' must be a non-empty string",
+ );
+ });
+
+ test("non-string model returns 400", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 123, prompt: "a sunset" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ expect(data.error.message).toContain("model");
+ });
+
+ test("empty-string model returns 400 invalid_request_error", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "", prompt: "a sunset" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.type).toBe("invalid_request_error");
+ expect(data.error.message).toContain("model");
+ });
+
+ test("validation 400s journal the parsed request body (malformed JSON stays null)", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 123, prompt: "a sunset" }),
+ })
+ ).arrayBuffer(); // non-string model
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v" }),
+ })
+ ).arrayBuffer(); // missing prompt
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{nope",
+ })
+ ).arrayBuffer(); // malformed JSON
+
+ const entries = mock.journal.getAll().filter((e) => e.response.status === 400);
+ expect(entries).toHaveLength(3);
+ // Field-validation 400s journal a structurally valid synthetic body
+ // (JournalEntry.body is ChatCompletionRequest | null): raw fields ride
+ // along, messages is always an array, and a non-string model is
+ // JSON-encoded so the field stays a string.
+ expect(entries[0].body).toMatchObject({ model: "123", prompt: "a sunset", messages: [] });
+ expect(entries[1].body).toMatchObject({ model: "m/v", messages: [] });
+ expect(entries[2].body).toBeNull();
+ });
+});
+
+// ─── CR findings: testId embedded in generated URLs ────────────────────────
+
+describe("OpenRouter video — testId scoping of generated URLs", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ function addFixtureFor(prompt: string): void {
+ if (!mock) throw new Error("mock not started");
+ mock.addFixture({
+ match: { userMessage: prompt, endpoint: "video" },
+ response: { video: { id: "vid_tid", status: "completed", b64: "AAAA" } },
+ });
+ }
+
+ test("polling_url carries testId and resolves the job without the header", async () => {
+ mock = new LLMock({ port: 0 });
+ addFixtureFor("scoped poll");
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Test-Id": "test-a" },
+ body: JSON.stringify({ model: "m/v", prompt: "scoped poll" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url).toContain("testId=test-a");
+
+ // Bare fetch — no X-Test-Id header — must still find the job via the URL.
+ const poll = await fetch(envelope.polling_url);
+ expect(poll.status).toBe(200);
+ const data = await poll.json();
+ expect(data.id).toBe(envelope.id);
+ });
+
+ test("unsigned_urls carry testId and resolve content without the header", async () => {
+ mock = new LLMock({ port: 0 });
+ addFixtureFor("scoped content");
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Test-Id": "test-a" },
+ body: JSON.stringify({ model: "m/v", prompt: "scoped content" }),
+ });
+ const envelope = await submit.json();
+
+ const poll = await fetch(envelope.polling_url);
+ const data = await poll.json();
+ expect(data.status).toBe("completed");
+ expect(data.unsigned_urls[0]).toContain("testId=test-a");
+
+ // The @openrouter/sdk fetches unsigned URLs directly without custom
+ // headers — only Authorization. The testId in the URL must scope it.
+ const download = await fetch(data.unsigned_urls[0], {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(download.status).toBe(200);
+ expect(download.headers.get("content-type")).toBe("video/mp4");
+ });
+
+ test("job submitted under one testId is invisible to another", async () => {
+ mock = new LLMock({ port: 0 });
+ addFixtureFor("isolated");
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Test-Id": "test-a" },
+ body: JSON.stringify({ model: "m/v", prompt: "isolated" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const crossPoll = await fetch(`${mock.url}/api/v1/videos/${id}`, {
+ headers: { "X-Test-Id": "test-b" },
+ });
+ expect(crossPoll.status).toBe(404);
+ });
+
+ test("default testId leaves URLs clean (no testId param)", async () => {
+ mock = new LLMock({ port: 0 });
+ addFixtureFor("clean urls");
+ 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: "clean urls" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url).not.toContain("testId=");
+
+ const data = await (await fetch(envelope.polling_url)).json();
+ expect(data.unsigned_urls[0]).not.toContain("testId=");
+ });
+});
+
+// ─── CR findings: x-forwarded-proto/host in generated URLs ─────────────────
+
+describe("OpenRouter video — x-forwarded-proto/host", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("generated URLs use https when x-forwarded-proto says so", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "behind proxy", endpoint: "video" },
+ response: { video: { id: "vid_xp", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "https" },
+ body: JSON.stringify({ model: "m/v", prompt: "behind proxy" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("https://")).toBe(true);
+
+ // Poll over plain http (the mock listens on http) with the header set —
+ // unsigned_urls must come back https too.
+ const httpPollUrl = envelope.polling_url.replace(/^https:/, "http:");
+ const data = await (
+ await fetch(httpPollUrl, { headers: { "X-Forwarded-Proto": "https" } })
+ ).json();
+ expect(data.unsigned_urls[0].startsWith("https://")).toBe(true);
+ });
+
+ test("URLs stay http without the header", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "direct", endpoint: "video" },
+ response: { video: { id: "vid_dh", 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: "m/v", prompt: "direct" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("http://")).toBe(true);
+ });
+
+ test("non-http(s) x-forwarded-proto values fall back to http", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "weird proto", endpoint: "video" },
+ response: { video: { id: "vid_wp", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "ws" },
+ body: JSON.stringify({ model: "m/v", prompt: "weird proto" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("http://")).toBe(true);
+ });
+
+ test("polling_url uses x-forwarded-host when present (first value wins)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "forwarded host", endpoint: "video" },
+ response: { video: { id: "vid_fh", status: "completed" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Forwarded-Host": "mock.example.com, inner.proxy.local",
+ },
+ body: JSON.stringify({ model: "m/v", prompt: "forwarded host" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("http://mock.example.com/")).toBe(true);
+ });
+
+ test("x-forwarded-proto and x-forwarded-host combine in generated URLs", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "proxied host and proto", endpoint: "video" },
+ response: { video: { id: "vid_fhp", status: "completed" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Forwarded-Proto": "https",
+ "X-Forwarded-Host": "mock.example.com",
+ },
+ body: JSON.stringify({ model: "m/v", prompt: "proxied host and proto" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("https://mock.example.com/")).toBe(true);
+ });
+
+ test("bracketed IPv6 x-forwarded-host literals are honored", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "v6 host", endpoint: "video" },
+ response: { video: { id: "vid_v6", status: "completed" } },
+ });
+ await mock.start();
+
+ for (const host of ["[::1]", "[::1]:8080"]) {
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Host": host },
+ body: JSON.stringify({ model: "m/v", prompt: "v6 host" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith(`http://${host}/api/v1/videos/`)).toBe(true);
+ }
+ });
+
+ test("junk x-forwarded-host values fall back to the Host header", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "junk host", endpoint: "video" },
+ response: { video: { id: "vid_jh", status: "completed" } },
+ });
+ await mock.start();
+
+ // Spaces, slashes (path smuggling), and userinfo are not valid in a bare
+ // host[:port] — each must be rejected in favor of the Host header.
+ for (const junk of ["mock example com", "evil.com/path", "user@evil.com"]) {
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Host": junk },
+ body: JSON.stringify({ model: "m/v", prompt: "junk host" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith(`${mock.url}/api/v1/videos/`)).toBe(true);
+ }
+ });
+
+ test("a junk Host header fallback is validated and falls back to localhost", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "junk Host", endpoint: "video" },
+ response: { video: { id: "vid_jHf", status: "completed" } },
+ });
+ await mock.start();
+
+ // fetch forbids overriding the Host header, so issue a raw http.request
+ // with a path-smuggling Host value — the same junk shape the
+ // x-forwarded-host validation rejects.
+ const port = Number(new URL(mock.url).port);
+ const envelope = await new Promise<{ polling_url: string }>((resolve, reject) => {
+ const data = JSON.stringify({ model: "m/v", prompt: "junk Host" });
+ const req = http.request(
+ {
+ hostname: "127.0.0.1",
+ port,
+ path: "/api/v1/videos",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(data),
+ Host: "evil.com/x",
+ },
+ },
+ (res) => {
+ let body = "";
+ res.on("data", (chunk: Buffer) => (body += chunk));
+ res.on("end", () => resolve(JSON.parse(body) as { polling_url: string }));
+ },
+ );
+ req.on("error", reject);
+ req.write(data);
+ req.end();
+ });
+ // The smuggled path segment must not survive into the generated URL.
+ expect(envelope.polling_url.includes("/x/")).toBe(false);
+ expect(envelope.polling_url.startsWith("http://localhost/api/v1/videos/")).toBe(true);
+ });
+
+ test("an underscore Host header is accepted (docker-compose/k8s service names)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "underscore Host", endpoint: "video" },
+ response: { video: { id: "vid_uH", status: "completed" } },
+ });
+ await mock.start();
+
+ // fetch forbids overriding the Host header, so issue a raw http.request.
+ // Underscore hostnames are routine in docker-compose/k8s networks
+ // (e.g. my_project_aimock); rejecting them would silently produce dead
+ // "localhost" URLs.
+ const port = Number(new URL(mock.url).port);
+ const envelope = await new Promise<{ polling_url: string }>((resolve, reject) => {
+ const data = JSON.stringify({ model: "m/v", prompt: "underscore Host" });
+ const req = http.request(
+ {
+ hostname: "127.0.0.1",
+ port,
+ path: "/api/v1/videos",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(data),
+ Host: "ai_mock:4010",
+ },
+ },
+ (res) => {
+ let body = "";
+ res.on("data", (chunk: Buffer) => (body += chunk));
+ res.on("end", () => resolve(JSON.parse(body) as { polling_url: string }));
+ },
+ );
+ req.on("error", reject);
+ req.write(data);
+ req.end();
+ });
+ expect(envelope.polling_url.startsWith("http://ai_mock:4010/api/v1/videos/")).toBe(true);
+ });
+});
+
+// ─── CR findings: Bearer scheme validation on /content ─────────────────────
+
+describe("OpenRouter video — Bearer scheme validation", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ async function completedJob(prompt: string): Promise {
+ if (!mock) throw new Error("mock not started");
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ // Status poll mirrors the client flow; under default 0/0 the job is
+ // already seeded terminal (completed) at submit.
+ await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer();
+ return id;
+ }
+
+ test("non-Bearer Authorization scheme is rejected with 401", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "scheme check", endpoint: "video" },
+ response: { video: { id: "vid_sc", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await completedJob("scheme check");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Basic xyz" },
+ });
+ expect(res.status).toBe(401);
+ const data = await res.json();
+ expect(data.error.message).toBe("No auth credentials found");
+ expect(data.error.code).toBe(401);
+ });
+
+ test("minimal Bearer credential is accepted", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "bearer ok", endpoint: "video" },
+ response: { video: { id: "vid_bk", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await completedJob("bearer ok");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer k" },
+ });
+ expect(res.status).toBe(200);
+ });
+
+ test("lowercase bearer scheme is accepted (RFC 7235 schemes are case-insensitive)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "lower bearer", endpoint: "video" },
+ response: { video: { id: "vid_lb", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await completedJob("lower bearer");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "bearer sk-x" },
+ });
+ expect(res.status).toBe(200);
+ });
+
+ test("Bearer with an empty credential is rejected with 401", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "empty bearer", endpoint: "video" },
+ response: { video: { id: "vid_eb", status: "completed", b64: "AAAA" } },
+ });
+ await mock.start();
+ const id = await completedJob("empty bearer");
+
+ // fetch trims header whitespace, so "Bearer " arrives as "Bearer" — a
+ // scheme with no credential either way.
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer" },
+ });
+ expect(res.status).toBe(401);
+ expect((await res.json()).error.message).toBe("No auth credentials found");
+ });
+});
+
+// ─── CR findings: server close clears per-instance video state ─────────────
+
+describe("server close clears video job state", () => {
+ test("close() empties both videoStates and openRouterVideoJobs", async () => {
+ const fixtures = [
+ {
+ match: { userMessage: "openai close", endpoint: "video" as const },
+ response: { video: { id: "vid_close_oa", status: "completed" as const } },
+ },
+ {
+ match: { userMessage: "openrouter close", endpoint: "video" as const },
+ response: { video: { id: "vid_close_or", status: "completed" as const } },
+ },
+ ];
+ const instance = await createServer(fixtures, { port: 0 });
+ // Idempotent close so the finally block can't double-close (which would
+ // reject with ERR_SERVER_NOT_RUNNING) after a successful in-test close.
+ let closed = false;
+ const closeServer = (): Promise => {
+ if (closed) return Promise.resolve();
+ closed = true;
+ return new Promise((resolve, reject) => {
+ instance.server.close((err) => (err ? reject(err) : resolve()));
+ });
+ };
+
+ try {
+ // Populate both per-instance maps (consume both bodies so no
+ // connection is left dangling across the close below).
+ await (
+ await fetch(`${instance.url}/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "sora-2", prompt: "openai close" }),
+ })
+ ).arrayBuffer();
+ await (
+ await fetch(`${instance.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "openrouter close" }),
+ })
+ ).arrayBuffer();
+ expect(instance.videoStates.size).toBeGreaterThan(0);
+ expect(instance.openRouterVideoJobs.size).toBeGreaterThan(0);
+
+ await closeServer();
+ expect(instance.openRouterVideoJobs.size).toBe(0);
+ expect(instance.videoStates.size).toBe(0);
+ } finally {
+ // No-op when the test already closed; prevents a leaked server when a
+ // mid-test assertion fails.
+ await closeServer();
+ }
+ });
+});
+
+describe("OpenRouter video — routing collision regression", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("Ollama /api/chat still routes to the Ollama handler", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "ollama hello" },
+ response: { content: "hello from ollama" },
+ });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "llama3",
+ messages: [{ role: "user", content: "ollama hello" }],
+ stream: false,
+ }),
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.message.content).toBe("hello from ollama");
+ });
+
+ test("Ollama /api/embeddings still routes to the Ollama handler", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/embeddings`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "nomic-embed-text", prompt: "embed me" }),
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(Array.isArray(data.embedding)).toBe(true);
+ });
+
+ test("OpenAI /v1/videos lifecycle is unaffected", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "openai video", endpoint: "video" },
+ response: {
+ video: { id: "vid_oa", status: "completed", url: "https://example.com/oa.mp4" },
+ },
+ });
+ await mock.start();
+
+ const create = await fetch(`${mock.url}/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "sora-2", prompt: "openai video" }),
+ });
+ expect(create.status).toBe(200);
+ const created = await create.json();
+ // OpenAI-shaped response: status/url on the video object, no polling_url
+ expect(created.id).toBe("vid_oa");
+ expect(created.polling_url).toBeUndefined();
+ expect(created.url).toBe("https://example.com/oa.mp4");
+
+ const status = await fetch(`${mock.url}/v1/videos/vid_oa`);
+ expect(status.status).toBe(200);
+ const statusBody = await status.json();
+ expect(statusBody.status).toBe("completed");
+ });
+});
+
+// ─── Review coverage: job-map internals, reset plumbing, config + headers ───
+
+describe("OpenRouterVideoJobMap (unit)", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ function makeJob(id: string) {
+ return {
+ jobId: id,
+ status: "pending" as const,
+ pollCount: 0,
+ pollsBeforeInProgress: 0,
+ pollsBeforeCompleted: 0,
+ video: { id, status: "completed" as const },
+ };
+ }
+
+ test("entries expire after the 1h TTL (lazy eviction on get)", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+ const map = new OpenRouterVideoJobMap();
+ map.set("t:job1", makeJob("job1"));
+ expect(map.get("t:job1")).toBeDefined();
+
+ // At exactly the TTL boundary the entry is still alive (strict >).
+ vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z"));
+ expect(map.get("t:job1")).toBeDefined();
+ expect(map.size).toBe(1);
+
+ // One tick past the TTL: get() lazily evicts and returns undefined.
+ vi.setSystemTime(new Date("2026-01-01T01:00:00.001Z"));
+ expect(map.get("t:job1")).toBeUndefined();
+ expect(map.size).toBe(0);
+ });
+
+ test("evicts the oldest entries FIFO beyond the 10k capacity", () => {
+ const map = new OpenRouterVideoJobMap();
+ const CAP = OPENROUTER_VIDEO_MAX_ENTRIES;
+ const job = makeJob("shared"); // entries may share one job object — keeps this fast
+ for (let i = 0; i <= CAP; i++) {
+ map.set(`t:job${i}`, job);
+ }
+ expect(map.size).toBe(CAP);
+ expect(map.get("t:job0")).toBeUndefined(); // oldest entry evicted FIFO
+ expect(map.get("t:job1")).toBeDefined();
+ expect(map.get(`t:job${CAP}`)).toBeDefined(); // newest entry retained
+ });
+});
+
+describe("OpenRouter video — reset plumbing", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ async function submitJob(prompt: string): Promise {
+ if (!mock) throw new Error("mock not started");
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+ return id;
+ }
+
+ test("POST /__aimock/reset/fixtures clears job state (old jobId polls 404)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "reset http", endpoint: "video" },
+ response: { video: { id: "vid_rh", status: "completed" } },
+ });
+ await mock.start();
+ const id = await submitJob("reset http");
+
+ const before = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(before.status).toBe(200);
+ await before.arrayBuffer();
+
+ const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" });
+ expect(reset.status).toBe(200);
+ await reset.arrayBuffer();
+
+ const after = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(after.status).toBe(404);
+ expect((await after.json()).error.code).toBe(404);
+ });
+
+ test("LLMock.reset() clears job state (old jobId polls 404)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "reset api", endpoint: "video" },
+ response: { video: { id: "vid_ra", status: "completed" } },
+ });
+ await mock.start();
+ const id = await submitJob("reset api");
+
+ const before = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(before.status).toBe(200);
+ await before.arrayBuffer();
+
+ mock.reset();
+
+ const after = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(after.status).toBe(404);
+ expect((await after.json()).error.code).toBe(404);
+ });
+});
+
+describe("OpenRouter video — config and header overrides", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("completed-only config ({pollsBeforeCompleted}) passes through in_progress", async () => {
+ mock = new LLMock({ port: 0, openRouterVideo: { pollsBeforeCompleted: 2 } });
+ mock.addFixture({
+ match: { userMessage: "completed only", endpoint: "video" },
+ response: { video: { id: "vid_co", 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: "m/v", prompt: "completed only" }),
+ });
+ const { id, status } = (await submit.json()) as { id: string; status: string };
+ expect(status).toBe("pending");
+
+ // pollsBeforeInProgress defaults to 0, so the first poll is already
+ // in_progress; the second crosses the explicit completed threshold.
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("completed");
+ });
+
+ test("{pollsBeforeCompleted: 1} lands terminal on poll 2 (in_progress consumes poll 1)", async () => {
+ mock = new LLMock({ port: 0, openRouterVideo: { pollsBeforeCompleted: 1 } });
+ mock.addFixture({
+ match: { userMessage: "late by one", endpoint: "video" },
+ response: { video: { id: "vid_lbo", 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: "m/v", prompt: "late by one" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ // pollsBeforeInProgress is unset (0), so the in_progress branch consumes
+ // poll 1 even though the configured completed threshold is 1 — the
+ // terminal status lands one poll later than configured.
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("completed");
+ });
+
+ test("X-AIMock-Strict header turns a no-match 404 into a 503 on a strict-off server", async () => {
+ mock = new LLMock({ port: 0 }); // strict defaults off
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-AIMock-Strict": "true" },
+ body: JSON.stringify({ model: "m/v", prompt: "nothing matches this" }),
+ });
+ expect(res.status).toBe(503);
+ const data = await res.json();
+ expect(data.error.code).toBe("no_fixture_match");
+ });
+});
+
+// ─── Round-2 polish: pins of existing behavior ──────────────────────────────
+
+describe("OpenRouter video — pinned existing behavior", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("X-Test-Id header wins over the ?testId= query param (getTestId precedence)", async () => {
+ // getTestId (helpers.ts) checks the x-test-id header before falling back
+ // to the ?testId= query param — the header wins when both are present.
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "precedence", endpoint: "video" },
+ response: { video: { id: "vid_prec", status: "completed" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Test-Id": "test-a" },
+ body: JSON.stringify({ model: "m/v", prompt: "precedence" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ // Header test-b beats query test-a → the job (scoped to test-a) is not
+ // visible → 404.
+ const headerWins = await fetch(`${mock.url}/api/v1/videos/${id}?testId=test-a`, {
+ headers: { "X-Test-Id": "test-b" },
+ });
+ expect(headerWins.status).toBe(404);
+ await headerWins.arrayBuffer();
+
+ // Header test-a beats query test-b → the job resolves despite the
+ // mismatched query param.
+ const headerMatches = await fetch(`${mock.url}/api/v1/videos/${id}?testId=test-b`, {
+ headers: { "X-Test-Id": "test-a" },
+ });
+ expect(headerMatches.status).toBe(200);
+ await headerMatches.arrayBuffer();
+ });
+
+ test("X-AIMock-Strict: false downgrades a strict server's no-match 503 to 404", async () => {
+ mock = new LLMock({ port: 0, strict: true });
+ await mock.start();
+
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-AIMock-Strict": "false" },
+ body: JSON.stringify({ model: "m/v", prompt: "nothing matches this" }),
+ });
+ expect(res.status).toBe(404);
+ const data = await res.json();
+ expect(data.error.code).toBe(404);
+ });
+
+ test("multipart/form-data submit returns 400 invalid_json (no multipart parsing)", async () => {
+ // Unlike the OpenAI-shaped /v1/videos handler (which parses multipart
+ // bodies for openai SDK >= 6.28.0), this surface only parses JSON — the
+ // @openrouter/sdk sends JSON today. Watch-item: if the SDK ever shifts to
+ // multipart video-create requests, this pin will flag the gap.
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const form = new FormData();
+ form.set("model", "m/v");
+ form.set("prompt", "a sunset over the ocean");
+ const res = await fetch(`${mock.url}/api/v1/videos`, { method: "POST", body: form });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error.code).toBe("invalid_json");
+ });
+
+ test("'processing'-status fixture runs the full lifecycle to completed + download", async () => {
+ // terminalStatus coerces a non-failed fixture status — including
+ // "processing" — to completed, so the lifecycle still terminates.
+ const bytes = Buffer.from("processing fixture bytes");
+ mock = new LLMock({ port: 0, logLevel: "silent" }); // silence the coercion warn
+ mock.addFixture({
+ match: { userMessage: "processing lifecycle", endpoint: "video" },
+ response: {
+ video: { id: "vid_prl", status: "processing", b64: bytes.toString("base64") },
+ },
+ });
+ 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: "processing lifecycle" }),
+ });
+ expect(submit.status).toBe(200);
+ const envelope = await submit.json();
+ expect(envelope.status).toBe("pending");
+
+ const poll = await (await fetch(envelope.polling_url)).json();
+ expect(poll.status).toBe("completed");
+ expect(poll.unsigned_urls).toHaveLength(1);
+
+ const download = await fetch(poll.unsigned_urls[0], {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(download.status).toBe(200);
+ expect(download.headers.get("content-type")).toBe("video/mp4");
+ const body = Buffer.from(await download.arrayBuffer());
+ expect(body.equals(bytes)).toBe(true);
+ });
+
+ test("post-terminal polls of a completed job are idempotent (urls + usage persist)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "idempotent done", endpoint: "video" },
+ response: { video: { id: "vid_idc", status: "completed", cost: 0.07 } },
+ });
+ 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: "idempotent done" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const bodies: unknown[] = [];
+ for (let i = 0; i < 3; i++) {
+ const poll = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(poll.status).toBe(200);
+ bodies.push(await poll.json());
+ }
+ expect(bodies[0]).toEqual({
+ id,
+ status: "completed",
+ unsigned_urls: [`${mock.url}/api/v1/videos/${id}/content?index=0`],
+ usage: { cost: 0.07 },
+ });
+ expect(bodies[1]).toEqual(bodies[0]);
+ expect(bodies[2]).toEqual(bodies[0]);
+ });
+
+ test("post-terminal polls of a failed job are idempotent (error persists)", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "idempotent fail", endpoint: "video" },
+ response: { video: { id: "vid_idf", status: "failed", error: "quota exceeded" } },
+ });
+ 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: "idempotent fail" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const bodies: unknown[] = [];
+ for (let i = 0; i < 3; i++) {
+ const poll = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(poll.status).toBe(200);
+ bodies.push(await poll.json());
+ }
+ expect(bodies[0]).toEqual({ id, status: "failed", error: "quota exceeded" });
+ expect(bodies[1]).toEqual(bodies[0]);
+ expect(bodies[2]).toEqual(bodies[0]);
+ });
+
+ test("content fetch with Bearer immediately after submit succeeds under default 0/0", async () => {
+ // Under the default 0/0 progression the job is seeded terminal at submit,
+ // so content is downloadable without any status poll ever happening. Pins
+ // the documented divergence from fal's advance-on-result semantics: the
+ // content endpoint never advances job state, and with 0/0 it doesn't
+ // need to.
+ const bytes = Buffer.from("no poll needed");
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "no poll", endpoint: "video" },
+ response: { video: { id: "vid_np", status: "completed", b64: bytes.toString("base64") } },
+ });
+ 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: "no poll" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("video/mp4");
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(bytes)).toBe(true);
+ });
+});
+
+// ─── CR round 8: journal sanitization, threshold validation, header edge cases ─
+
+describe("OpenRouter video — validation journal body sanitization", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("validation-400 journal body strips underscore-prefixed client keys", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ // A client body carrying _endpointType/_context must not be able to
+ // plant values journal consumers treat as trusted handler-set
+ // discriminators.
+ const res = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: 1, model: "m/v", _endpointType: "chat", _context: "x" }),
+ });
+ expect(res.status).toBe(400);
+ await res.arrayBuffer();
+
+ const entry = mock.journal
+ .getAll()
+ .find((e) => e.path === "/api/v1/videos" && e.response.status === 400);
+ expect(entry).toBeDefined();
+ const body = entry!.body as unknown as Record;
+ expect(body._endpointType).toBeUndefined();
+ expect(body._context).toBeUndefined();
+ // The raw prompt field and the normalized model are still preserved.
+ expect(body.prompt).toBe(1);
+ expect(body.model).toBe("m/v");
+ });
+});
+
+describe("OpenRouter video — progression threshold sanitization (e2e)", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("NaN pollsBeforeCompleted cannot strand a job short of terminal", async () => {
+ mock = new LLMock({ port: 0, openRouterVideo: { pollsBeforeCompleted: NaN } });
+ mock.addFixture({
+ match: { userMessage: "nan threshold", endpoint: "video" },
+ response: { video: { id: "vid_nan", 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: "m/v", prompt: "nan threshold" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ // Without sanitization `pollCount >= NaN` is always false — the job
+ // never terminates and a polling client hangs forever. The job must
+ // reach a terminal status within a bounded number of polls.
+ let status = "";
+ for (let i = 0; i < 5; i++) {
+ const poll = (await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json()) as {
+ status: string;
+ };
+ status = poll.status;
+ if (status === "completed" || status === "failed") break;
+ }
+ expect(status).toBe("completed");
+ });
+
+ test("negative pollsBeforeInProgress behaves as explicit 0 (progression enabled)", async () => {
+ mock = new LLMock({ port: 0, openRouterVideo: { pollsBeforeInProgress: -1 } });
+ mock.addFixture({
+ match: { userMessage: "negative threshold", endpoint: "video" },
+ response: { video: { id: "vid_neg", 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: "m/v", prompt: "negative threshold" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ // -1 clamps to an explicit 0: progression is enabled (poll 1 is
+ // in_progress, poll 2 completed) instead of seed-terminal at submit.
+ const poll1 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll1.status).toBe("in_progress");
+ const poll2 = await (await fetch(`${mock.url}/api/v1/videos/${id}`)).json();
+ expect(poll2.status).toBe("completed");
+ });
+
+ test("createServer warns on non-finite/negative poll thresholds", async () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ mock = new LLMock({
+ port: 0,
+ logLevel: "warn",
+ openRouterVideo: { pollsBeforeCompleted: NaN },
+ falQueue: { pollsBeforeInProgress: -1 },
+ });
+ await mock.start();
+
+ expect(
+ warnSpy.mock.calls.some((c) => {
+ const line = c.join(" ");
+ return (
+ line.includes("openRouterVideo.pollsBeforeCompleted") &&
+ line.includes("treating as unset")
+ );
+ }),
+ ).toBe(true);
+ expect(
+ warnSpy.mock.calls.some((c) => {
+ const line = c.join(" ");
+ return (
+ line.includes("falQueue.pollsBeforeInProgress") &&
+ line.includes("clamping to a non-negative integer")
+ );
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("OpenRouter video — CR round 8 content/header polish", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("warns when a padding-only b64 decodes to zero bytes", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "padding only", endpoint: "video" },
+ // "=" is truthy, sanitizes to "" (everything from the first "=" is
+ // dropped), and decodes to 0 bytes — dodging both the empty-string
+ // warn and the length-mismatch warn.
+ response: { video: { id: "vid_pad", status: "completed", b64: "=" } },
+ });
+ 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: "padding only" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.length).toBe(0); // the zero-byte decode is still served
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("zero bytes"))).toBe(true);
+ });
+
+ test("empty x-forwarded-host header is ignored without a rejection warn", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "empty fwd host", endpoint: "video" },
+ response: { video: { id: "vid_efh", status: "completed" } },
+ });
+ 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", "X-Forwarded-Host": "" },
+ body: JSON.stringify({ model: "m/v", prompt: "empty fwd host" }),
+ });
+ const envelope = await submit.json();
+ // An empty value is treated as absent: Host is used, no spurious warn.
+ expect(envelope.polling_url.startsWith(`${mock.url}/api/v1/videos/`)).toBe(true);
+ expect(
+ warnSpy.mock.calls.some((c) => c.join(" ").includes("x-forwarded-host value rejected")),
+ ).toBe(false);
+ });
+
+ test("leading-comma x-forwarded-host list honors the first non-empty entry", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "comma fwd host", endpoint: "video" },
+ response: { video: { id: "vid_cfh", status: "completed" } },
+ });
+ 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",
+ "X-Forwarded-Host": ", real.example.com:8080",
+ },
+ body: JSON.stringify({ model: "m/v", prompt: "comma fwd host" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("http://real.example.com:8080/api/v1/videos/")).toBe(
+ true,
+ );
+ expect(
+ warnSpy.mock.calls.some((c) => c.join(" ").includes("x-forwarded-host value rejected")),
+ ).toBe(false);
+ });
+
+ test("rejected x-forwarded-host warn includes the offending value", async () => {
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "named bad host", endpoint: "video" },
+ response: { video: { id: "vid_nbh", status: "completed" } },
+ });
+ await mock.start();
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ await (
+ await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Host": "evil.com/path" },
+ body: JSON.stringify({ model: "m/v", prompt: "named bad host" }),
+ })
+ ).arrayBuffer();
+
+ expect(
+ warnSpy.mock.calls.some((c) => {
+ const line = c.join(" ");
+ return line.includes("x-forwarded-host value rejected") && line.includes('"evil.com/path"');
+ }),
+ ).toBe(true);
+ });
+
+ test("warns when a non-zero content index is requested (still serves index 0)", async () => {
+ const bytes = Buffer.from("only video");
+ mock = new LLMock({ port: 0, logLevel: "warn" });
+ mock.addFixture({
+ match: { userMessage: "indexed", endpoint: "video" },
+ response: { video: { id: "vid_idx", status: "completed", b64: bytes.toString("base64") } },
+ });
+ 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: "indexed" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=3`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200); // behavior unchanged — index-0 bytes served
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(bytes)).toBe(true);
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("index"))).toBe(true);
+
+ // index=0 (the canonical generated URL) must stay warn-free.
+ warnSpy.mockClear();
+ await (
+ await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ })
+ ).arrayBuffer();
+ expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("index"))).toBe(false);
+ });
+
+ test("mutating the fixture's video object after submit does not affect the job", async () => {
+ const original = Buffer.from("original bytes");
+ const video: VideoResponse["video"] = {
+ id: "vid_mut",
+ status: "completed",
+ b64: original.toString("base64"),
+ };
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "mutate me", endpoint: "video" },
+ // A factory returning a shared object — later mutation must not
+ // retroactively change an in-flight job's stored video.
+ response: () => ({ video }),
+ });
+ 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: "mutate me" }),
+ });
+ const { id } = (await submit.json()) as { id: string };
+
+ video.b64 = Buffer.from("tampered bytes").toString("base64");
+
+ const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, {
+ headers: { Authorization: "Bearer test" },
+ });
+ expect(res.status).toBe(200);
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body.equals(original)).toBe(true);
+ });
+
+ test("models listing excludes an empty-string match.model", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { model: "", endpoint: "video" },
+ response: { video: { id: "vid_em", 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()) as { data: Array<{ id: string }> };
+ expect(data.data.some((m) => m.id === "")).toBe(false);
+ // With no usable string model the listing falls back to the default set.
+ expect(data.data.length).toBeGreaterThan(0);
+ });
+});
+
+describe("OpenRouter video — CR round 8 contract pins", () => {
+ let mock: LLMock | undefined;
+
+ afterEach(async () => {
+ await mock?.stop();
+ mock = undefined;
+ });
+
+ test("a chaos-dropped submit consumes the fixture's sequence slot", async () => {
+ mock = new LLMock({ port: 0, strict: true });
+ mock.addFixture({
+ match: { userMessage: "slot once", endpoint: "video", sequenceIndex: 0 },
+ response: { video: { id: "vid_slot", status: "completed" } },
+ });
+ 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: "slot once" }),
+ });
+ expect(dropped.status).toBe(500);
+ expect((await dropped.json()).error.code).toBe("chaos_drop");
+
+ // The match count incremented before chaos rolled, so an identical clean
+ // submit finds the sequence slot already consumed → strict 503.
+ const second = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "m/v", prompt: "slot once" }),
+ });
+ expect(second.status).toBe(503);
+ const data = await second.json();
+ expect(data.error.message).toMatch(SKIPPED_BY_STATE_RE);
+ });
+
+ test("comma-joined x-forwarded-proto list honors the first value", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "proto list", endpoint: "video" },
+ response: { video: { id: "vid_pl", status: "completed" } },
+ });
+ await mock.start();
+
+ const submit = await fetch(`${mock.url}/api/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "https, http" },
+ body: JSON.stringify({ model: "m/v", prompt: "proto list" }),
+ });
+ const envelope = await submit.json();
+ expect(envelope.polling_url.startsWith("https://")).toBe(true);
+ });
+
+ test("a chaos-dropped status poll journals source internal", async () => {
+ mock = new LLMock({ port: 0 });
+ await mock.start();
+
+ const dropped = await fetch(`${mock.url}/api/v1/videos/some-job`, {
+ headers: { "x-aimock-chaos-drop": "1" },
+ });
+ expect(dropped.status).toBe(500);
+ expect((await dropped.json()).error.code).toBe("chaos_drop");
+
+ const entry = mock.journal
+ .getAll()
+ .find((e) => e.path === "/api/v1/videos/some-job" && e.response.chaosAction === "drop");
+ expect(entry).toBeDefined();
+ expect(entry!.response.source).toBe("internal");
+ });
+});
diff --git a/src/fal.ts b/src/fal.ts
index 1c3c05eb..07273cf6 100644
--- a/src/fal.ts
+++ b/src/fal.ts
@@ -165,21 +165,31 @@ export function videoResponseToFalJson(response: VideoResponse): Record= threshold` comparison permanently false (a
+ // polling client never reaches terminal), and a negative would break the
+ // documented "explicit 0 enables progression" contract. Non-finite values
+ // are treated as unset; anything else is floored and clamped to >= 0.
+ const sanitize = (value: number | undefined): number | undefined =>
+ typeof value === "number" && Number.isFinite(value)
+ ? Math.max(0, Math.floor(value))
+ : undefined;
+ const explicitInProgress = sanitize(config?.pollsBeforeInProgress);
+ const explicitCompleted = sanitize(config?.pollsBeforeCompleted);
+ const pollsBeforeInProgress = explicitInProgress ?? 0;
// When only pollsBeforeInProgress is set, default pollsBeforeCompleted to one
// poll later so the job actually passes through IN_PROGRESS. When the caller
// sets both explicitly, clamp completed >= inProgress so a misconfigured
// pair (e.g. completed < inProgress) can't silently skip the IN_PROGRESS
// transition. When neither is set, both stay 0 (completes on submit).
let pollsBeforeCompleted: number;
- if (explicitCompleted != null) {
+ if (explicitCompleted !== undefined) {
pollsBeforeCompleted = Math.max(pollsBeforeInProgress, explicitCompleted);
- } else if (config?.pollsBeforeInProgress != null) {
+ } else if (explicitInProgress !== undefined) {
pollsBeforeCompleted = pollsBeforeInProgress + 1;
} else {
pollsBeforeCompleted = 0;
diff --git a/src/llmock.ts b/src/llmock.ts
index 02e376e2..e74d6e23 100644
--- a/src/llmock.ts
+++ b/src/llmock.ts
@@ -386,6 +386,7 @@ export class LLMock {
if (this.serverInstance) {
this.serverInstance.journal.clear();
this.serverInstance.videoStates.clear();
+ this.serverInstance.openRouterVideoJobs.clear();
}
return this;
}
diff --git a/src/metrics.ts b/src/metrics.ts
index e6b63fd1..48cb6e09 100644
--- a/src/metrics.ts
+++ b/src/metrics.ts
@@ -222,6 +222,11 @@ const AZURE_RE = /^\/openai\/deployments\/([^/]+)\/(chat\/completions|embeddings
const ELEVENLABS_TTS_RE = /^\/v1\/text-to-speech\/([^/]+)$/;
const VERTEX_RE =
/^\/v1\/projects\/([^/]+)\/locations\/([^/]+)\/publishers\/google\/models\/([^:]+):(.+)$/;
+// Exported: server.ts route dispatch matches the same OpenRouter and OpenAI
+// video paths.
+export const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/;
+export const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/;
+export const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/;
/**
* Normalize parametric API paths to route patterns for use as metric labels.
@@ -257,6 +262,21 @@ export function normalizePathLabel(pathname: string): string {
return "/v1/text-to-speech/{voice_id}";
}
+ // OpenRouter video: /api/v1/videos/{jobId}[/content] — jobIds are random
+ // UUIDs, so raw paths would mint unbounded label cardinality. The static
+ // /api/v1/videos/models listing route must not collapse into {jobId}.
+ if (OPENROUTER_VIDEO_CONTENT_RE.test(pathname)) {
+ return "/api/v1/videos/{jobId}/content";
+ }
+ if (pathname !== "/api/v1/videos/models" && OPENROUTER_VIDEO_STATUS_RE.test(pathname)) {
+ return "/api/v1/videos/{jobId}";
+ }
+
+ // OpenAI video status: /v1/videos/{id}
+ if (OPENAI_VIDEO_STATUS_RE.test(pathname)) {
+ return "/v1/videos/{id}";
+ }
+
// Static path — return as-is
return pathname;
}
diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts
new file mode 100644
index 00000000..41019dd3
--- /dev/null
+++ b/src/openrouter-video.ts
@@ -0,0 +1,914 @@
+import type * as http from "node:http";
+import crypto from "node:crypto";
+import type { ChatCompletionRequest, Fixture, HandlerDefaults, VideoResponse } from "./types.js";
+import type { Logger } from "./logger.js";
+import {
+ isVideoResponse,
+ isErrorResponse,
+ serializeErrorResponse,
+ flattenHeaders,
+ getTestId,
+ resolveResponse,
+ resolveStrictMode,
+ strictOverrideField,
+ getContext,
+ strictNoMatchMessage,
+ strictNoMatchLogLine,
+} from "./helpers.js";
+import { DEFAULT_TEST_ID } from "./constants.js";
+import { matchFixtureDiagnostic } from "./router.js";
+import { writeErrorResponse } from "./sse-writer.js";
+import type { Journal } from "./journal.js";
+import { applyChaos } from "./chaos.js";
+import { resolveProgression } from "./fal.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.
+ */
+
+interface OpenRouterVideoRequest {
+ model?: string;
+ prompt?: string;
+ [key: string]: unknown;
+}
+
+const DEFAULT_OPENROUTER_VIDEO_MODEL = "bytedance/seedance-2.0";
+
+// ─── OpenRouterVideoJobMap (TTL + bounded) ──────────────────────────────────
+
+export const OPENROUTER_VIDEO_MAX_ENTRIES = 10_000;
+const OPENROUTER_VIDEO_TTL_MS = 3_600_000; // 1 hour
+
+type OpenRouterVideoStatus = "pending" | "in_progress" | "completed" | "failed";
+
+interface OpenRouterVideoJob {
+ jobId: string;
+ status: OpenRouterVideoStatus;
+ /** Number of status polls the caller has made against this job. */
+ pollCount: number;
+ /** Poll-count threshold for `pending → in_progress` transition. */
+ pollsBeforeInProgress: number;
+ /** Poll-count threshold for the transition to the terminal status. */
+ pollsBeforeCompleted: number;
+ /** The matched fixture's video object (terminal status, bytes, cost, error). */
+ video: VideoResponse["video"];
+}
+
+interface OpenRouterVideoEntry {
+ job: OpenRouterVideoJob;
+ createdAt: number;
+}
+
+/**
+ * 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.
+ * Keys are `${testId}:${jobId}`.
+ */
+export class OpenRouterVideoJobMap {
+ private readonly entries = new Map();
+
+ get(key: string): OpenRouterVideoJob | undefined {
+ const entry = this.entries.get(key);
+ if (!entry) return undefined;
+ if (Date.now() - entry.createdAt > OPENROUTER_VIDEO_TTL_MS) {
+ this.entries.delete(key);
+ return undefined;
+ }
+ return entry.job;
+ }
+
+ set(key: string, job: OpenRouterVideoJob): void {
+ 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;
+ const iter = this.entries.keys();
+ for (let i = 0; i < excess; i++) {
+ const next = iter.next();
+ if (!next.done) this.entries.delete(next.value);
+ }
+ }
+ }
+
+ delete(key: string): boolean {
+ return this.entries.delete(key);
+ }
+
+ clear(): void {
+ this.entries.clear();
+ }
+
+ get size(): number {
+ return this.entries.size;
+ }
+}
+
+// ─── Job progression ────────────────────────────────────────────────────────
+
+/**
+ * Maps the fixture's terminal video status onto the job lifecycle. Anything
+ * that is not "failed" — including a fixture authored as "processing" — is
+ * treated as completed, since this surface always drives jobs to a terminal
+ * state. handleOpenRouterVideoCreate warns when a "processing" fixture is
+ * coerced this way.
+ */
+function terminalStatus(job: OpenRouterVideoJob): OpenRouterVideoStatus {
+ return job.video.status === "failed" ? "failed" : "completed";
+}
+
+/**
+ * Mutates a job in place to advance its state on a status poll.
+ * `pending → in_progress → completed | failed` based on poll-count thresholds.
+ * No-op once terminal. The in_progress threshold is checked first so a job
+ * 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 {
+ if (job.status === "completed" || job.status === "failed") return;
+
+ job.pollCount += 1;
+ if (job.status === "pending" && job.pollCount >= job.pollsBeforeInProgress) {
+ job.status = "in_progress";
+ } else if (job.pollCount >= job.pollsBeforeCompleted) {
+ job.status = terminalStatus(job);
+ }
+}
+
+/**
+ * First non-empty value of a possibly array-typed, possibly comma-joined
+ * header. An empty header value or a leading-comma list (", host") would
+ * otherwise yield "" — triggering a spurious rejection warn and discarding
+ * valid later entries — so empty segments are skipped.
+ */
+function firstForwardedValue(header: string | string[] | undefined): string | undefined {
+ const raw = Array.isArray(header) ? header.join(",") : header;
+ if (raw === undefined) return undefined;
+ for (const segment of raw.split(",")) {
+ const trimmed = segment.trim();
+ if (trimmed) return trimmed;
+ }
+ return undefined;
+}
+
+// Conservative host[:port] shape for x-forwarded-host. Spaces, slashes,
+// userinfo, or any other URL-structure character would corrupt (or smuggle
+// paths into) the generated URLs the value is interpolated into. Underscores
+// are admitted: the value feeds URL-string interpolation, not DNS validation,
+// and underscore hostnames are routine in docker-compose/k8s networks
+// (e.g. my_project_aimock:4010).
+const FORWARDED_HOST_RE = /^[a-zA-Z0-9._-]+(:\d+)?$/;
+// Bracketed IPv6 literal host[:port], e.g. [::1] or [::1]:8080 — the bare
+// RE above cannot admit ":" inside the host without also admitting junk.
+const FORWARDED_HOST_IPV6_RE = /^\[[0-9a-fA-F:.]+\](:\d+)?$/;
+
+function requestBase(req: http.IncomingMessage, logger: Logger): string {
+ // Honor x-forwarded-proto and x-forwarded-host so generated URLs survive a
+ // TLS-terminating or host-rewriting proxy in front of the mock. First
+ // non-empty value wins on comma-joined lists.
+ const candidate = firstForwardedValue(req.headers["x-forwarded-proto"])?.toLowerCase();
+ // Allowlist http/https — any other value (ws, junk header data) falls back.
+ const proto = candidate === "http" || candidate === "https" ? candidate : "http";
+ // Like the proto allowlist, a forwarded host that doesn't look like a bare
+ // host[:port] (or a bracketed IPv6 literal) falls back to the Host header —
+ // with a warn, so a misconfigured proxy isn't silently ignored.
+ const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]);
+ // The Host fallback gets the same host[:port] validation — a junk Host
+ // (e.g. "evil.com/path") could otherwise smuggle URL structure into the
+ // generated URLs. No warn: a missing/odd Host is transport-level noise,
+ // unlike a misconfigured proxy's x-forwarded-host.
+ const rawHost = req.headers.host;
+ let host =
+ rawHost !== undefined &&
+ (FORWARDED_HOST_RE.test(rawHost) || FORWARDED_HOST_IPV6_RE.test(rawHost))
+ ? rawHost
+ : "localhost";
+ if (fwdHost !== undefined) {
+ if (FORWARDED_HOST_RE.test(fwdHost) || FORWARDED_HOST_IPV6_RE.test(fwdHost)) {
+ host = fwdHost;
+ } else {
+ logger.warn(
+ `x-forwarded-host value rejected, falling back to Host header: ${JSON.stringify(fwdHost.slice(0, 100))}`,
+ );
+ }
+ }
+ return `${proto}://${host}`;
+}
+
+/**
+ * Query-string suffix embedding the request's testId into generated URLs
+ * (polling_url, unsigned_urls). The @openrouter/sdk fetches these URLs with
+ * standard Authorization but no aimock-specific headers (no x-test-id) — so
+ * the testId must travel in the URL for getTestId's `?testId=` fallback to
+ * resolve the right job scope. The default testId is omitted to keep
+ * single-tenant URLs clean.
+ */
+function testIdSuffix(testId: string, sep: "?" | "&"): string {
+ return testId === DEFAULT_TEST_ID ? "" : `${sep}testId=${encodeURIComponent(testId)}`;
+}
+
+/**
+ * Synthesizes a structurally valid journal body for field-validation 400s.
+ * JournalEntry.body is typed `ChatCompletionRequest | null`, so the raw
+ * parsed body cannot be journaled as-is — journal consumers may walk
+ * `body.messages`. The result is ChatCompletionRequest-shaped: `model` is a
+ * string (a non-string value is JSON-encoded so the field stays a string
+ * without dropping what the caller sent) and `messages` is an empty array
+ * (there is no validated prompt to wrap). Raw request fields (including
+ * `prompt`) are preserved via the index signature only on this validation
+ * path — the success path's syntheticReq is built from scratch and does not
+ * carry them.
+ */
+function validationJournalBody(videoReq: OpenRouterVideoRequest): ChatCompletionRequest {
+ const rawModel = videoReq.model;
+ const model =
+ typeof rawModel === "string"
+ ? rawModel
+ : rawModel === undefined
+ ? ""
+ : JSON.stringify(rawModel);
+ // Underscore-prefixed keys (`_endpointType`, `_context`, ...) are reserved
+ // for handler-set discriminators that journal consumers treat as trusted —
+ // strip them from the raw client body so a request cannot spoof them.
+ const sanitized = Object.fromEntries(
+ Object.entries(videoReq).filter(([key]) => !key.startsWith("_")),
+ );
+ return { ...sanitized, model, messages: [] };
+}
+
+// ─── GET /api/v1/videos/{jobId} — status poll ───────────────────────────────
+
+export function handleOpenRouterVideoStatus(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ jobId: string,
+ journal: Journal,
+ defaults: HandlerDefaults,
+ setCorsHeaders: (res: http.ServerResponse) => void,
+ jobs: OpenRouterVideoJobMap,
+): void {
+ setCorsHeaders(res);
+ const path = req.url ?? `/api/v1/videos/${jobId}`;
+ const method = req.method ?? "GET";
+
+ if (
+ applyChaos(
+ res,
+ null,
+ defaults.chaos,
+ req.headers,
+ journal,
+ { method, path, headers: flattenHeaders(req.headers), body: null },
+ "internal",
+ defaults.registry,
+ defaults.logger,
+ )
+ )
+ return;
+
+ const testId = getTestId(req);
+ const job = jobs.get(`${testId}:${jobId}`);
+
+ if (!job) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 404, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 404,
+ JSON.stringify({ error: { message: `Video job ${jobId} not found`, code: 404 } }),
+ );
+ return;
+ }
+
+ advanceJob(job);
+
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 200, fixture: null },
+ });
+
+ const body: Record = { id: job.jobId, status: job.status };
+ if (job.status === "completed") {
+ body.unsigned_urls = [
+ `${requestBase(req, defaults.logger)}/api/v1/videos/${job.jobId}/content?index=0${testIdSuffix(testId, "&")}`,
+ ];
+ body.usage = { cost: job.video.cost ?? 0 };
+ } else if (job.status === "failed") {
+ if (job.video.error === "") {
+ // An explicit-but-empty error is an authoring mistake — warn (mirroring
+ // the empty-b64 warn) instead of serving an empty failure reason.
+ defaults.logger.warn(
+ `Video fixture for job ${job.jobId} has an empty error message — using the default`,
+ );
+ }
+ body.error = job.video.error || "Video generation failed";
+ }
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(body));
+}
+
+// ─── GET /api/v1/videos/{jobId}/content — download ──────────────────────────
+
+// Minimal valid-prefix MP4 placeholder served when a completed fixture has no
+// `b64` payload: a bare 24-byte `ftyp` box (major brand isom, minor 0x200,
+// compatible brands isom + mp42). Enough for clients that sniff the container
+// signature without requiring real video bytes in every fixture.
+const PLACEHOLDER_MP4 = Buffer.from([
+ 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x00,
+ 0x69, 0x73, 0x6f, 0x6d, 0x6d, 0x70, 0x34, 0x32,
+]);
+
+// 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(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ jobId: string,
+ journal: Journal,
+ defaults: HandlerDefaults,
+ setCorsHeaders: (res: http.ServerResponse) => void,
+ jobs: OpenRouterVideoJobMap,
+): void {
+ setCorsHeaders(res);
+ const path = req.url ?? `/api/v1/videos/${jobId}/content`;
+ const method = req.method ?? "GET";
+
+ if (
+ applyChaos(
+ res,
+ null,
+ defaults.chaos,
+ req.headers,
+ journal,
+ { method, path, headers: flattenHeaders(req.headers), body: null },
+ "internal",
+ defaults.registry,
+ defaults.logger,
+ )
+ )
+ return;
+
+ // The real endpoint requires Bearer auth even though the unsigned URL is
+ // otherwise self-contained — the @openrouter/sdk fetches it with the key.
+ // RFC 7235 auth schemes are case-insensitive; the credential must be
+ // non-empty.
+ const authorization = req.headers.authorization;
+ if (!authorization || !/^bearer\s+\S/i.test(authorization)) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 401, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 401,
+ JSON.stringify({ error: { message: "No auth credentials found", code: 401 } }),
+ );
+ return;
+ }
+
+ const testId = getTestId(req);
+ const job = jobs.get(`${testId}:${jobId}`);
+
+ if (!job) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 404, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 404,
+ JSON.stringify({ error: { message: `Video job ${jobId} not found`, code: 404 } }),
+ );
+ return;
+ }
+
+ if (job.status !== "completed") {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 400, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 400,
+ JSON.stringify({
+ error: {
+ message: `Video job ${jobId} is not completed (status: ${job.status})`,
+ code: 400,
+ },
+ }),
+ );
+ 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");
+ 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`,
+ );
+ }
+
+ let bytes: Buffer;
+ if (job.video.b64) {
+ bytes = Buffer.from(job.video.b64, "base64");
+ // Node's base64 decoder is lenient — invalid characters are skipped and
+ // the first "=" terminates the decode — so a corrupt payload silently
+ // truncates instead of erroring. Compare the decoded byte count against
+ // what the sanitized input length should yield (every 4 chars → 3 bytes,
+ // floor for a partial final group) and warn on mismatch. Sanitization
+ // mirrors the decoder: whitespace stripped, base64url normalized, and
+ // everything from the first "=" on dropped (counting post-padding
+ // characters as data chars would false-flag concatenated-but-valid
+ // payloads like "QQ==QQ==", which Node decodes as just their first
+ // group). A length check — rather than byte-exact re-encode equality —
+ // tolerates valid non-canonical base64 whose final character carries
+ // nonzero discarded trailing bits (e.g. "QR" re-encodes as "QQ") while
+ // still catching skipped-character corruption. The decode is served as-is.
+ const sanitized = job.video.b64
+ .replace(/\s+/g, "")
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .replace(/=.*$/, "");
+ const expectedBytes = Math.floor((sanitized.length * 3) / 4);
+ if (bytes.length === 0) {
+ // 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
+ // decodes to nothing; the zero-byte body is still served as-is.
+ defaults.logger.warn(
+ `Video fixture b64 for job ${jobId} decoded to zero bytes — the fixture is not controlling the served content`,
+ );
+ }
+ if (sanitized.length % 4 === 1) {
+ // A length ≡ 1 (mod 4) is base64 the mismatch check cannot catch: the
+ // 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) {
+ 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 === "") {
+ // 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.
+ defaults.logger.warn(
+ `Video fixture for job ${jobId} has an empty b64 — serving the placeholder MP4`,
+ );
+ }
+ if (job.video.url) {
+ // 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.
+ defaults.logger.warn(
+ `Video fixture for job ${jobId} sets video.url but no b64 — url is ignored on the OpenRouter content endpoint; use b64 to control the served bytes (serving the placeholder MP4)`,
+ );
+ }
+ bytes = PLACEHOLDER_MP4;
+ }
+
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 200, fixture: null },
+ });
+
+ // Always video/mp4 — the real endpoint serves video/mp4 even when the
+ // client (e.g. the Speakeasy-generated @openrouter/sdk) sends
+ // Accept: application/octet-stream.
+ res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": bytes.length });
+ res.end(bytes);
+}
+
+// ─── GET /api/v1/videos/models — model listing ──────────────────────────────
+
+const DEFAULT_OPENROUTER_VIDEO_MODELS = [DEFAULT_OPENROUTER_VIDEO_MODEL, "openai/sora-2"];
+
+function modelEntry(id: string): Record {
+ return {
+ id,
+ name: id,
+ supported_durations: [4, 8],
+ supported_resolutions: ["720p", "1080p"],
+ supported_aspect_ratios: ["16:9", "9:16", "1:1"],
+ supported_frame_images: [],
+ supported_sizes: [],
+ generate_audio: false,
+ seed: true,
+ pricing_skus: [],
+ };
+}
+
+/**
+ * Synthesizes the OpenRouter video model listing from loaded fixtures —
+ * video-endpoint fixtures with a string `match.model` (mirrors the Ollama
+ * `/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.
+ */
+export function handleOpenRouterVideoModels(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ fixtures: Fixture[],
+ journal: Journal,
+ defaults: HandlerDefaults,
+ setCorsHeaders: (res: http.ServerResponse) => void,
+): void {
+ setCorsHeaders(res);
+ const path = req.url ?? "/api/v1/videos/models";
+ const method = req.method ?? "GET";
+
+ if (
+ applyChaos(
+ res,
+ null,
+ defaults.chaos,
+ req.headers,
+ journal,
+ { method, path, headers: flattenHeaders(req.headers), body: null },
+ "internal",
+ defaults.registry,
+ defaults.logger,
+ )
+ )
+ return;
+
+ const modelIds = new Set();
+ let sawVideoFixture = false;
+ for (const f of fixtures) {
+ if (f.match.endpoint === "video") {
+ sawVideoFixture = true;
+ if (f.match.model && typeof f.match.model === "string") {
+ modelIds.add(f.match.model);
+ }
+ }
+ }
+ if (modelIds.size === 0 && sawVideoFixture) {
+ // Video fixtures are loaded but none has a string match.model (e.g. all
+ // RegExp models or onVideo registrations) — the listing silently serves
+ // the default set, which can surprise fixture authors.
+ defaults.logger.warn(
+ "No video fixture contributes a string model — serving the default video model set",
+ );
+ }
+ const ids = modelIds.size > 0 ? [...modelIds] : DEFAULT_OPENROUTER_VIDEO_MODELS;
+
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 200, fixture: null },
+ });
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ data: ids.map((id) => modelEntry(id)) }));
+}
+
+// ─── POST /api/v1/videos — submit ───────────────────────────────────────────
+
+export async function handleOpenRouterVideoCreate(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ raw: string,
+ fixtures: Fixture[],
+ journal: Journal,
+ defaults: HandlerDefaults,
+ setCorsHeaders: (res: http.ServerResponse) => void,
+ jobs: OpenRouterVideoJobMap,
+): Promise {
+ setCorsHeaders(res);
+ const path = req.url ?? "/api/v1/videos";
+ const method = req.method ?? "POST";
+
+ let videoReq: OpenRouterVideoRequest;
+ try {
+ videoReq = JSON.parse(raw) as OpenRouterVideoRequest;
+ } catch (parseErr) {
+ const detail = parseErr instanceof Error ? parseErr.message : "unknown";
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 400, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 400,
+ JSON.stringify({
+ error: {
+ message: `Malformed JSON: ${detail}`,
+ type: "invalid_request_error",
+ code: "invalid_json",
+ },
+ }),
+ );
+ return;
+ }
+
+ // Reject bodies that parsed but are not a JSON object (null, arrays,
+ // numbers, strings) before touching any fields — mirrors fal's parseBody
+ // guard so callers get a 400 instead of a raw TypeError 500.
+ if (videoReq === null || typeof videoReq !== "object" || Array.isArray(videoReq)) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 400, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 400,
+ JSON.stringify({
+ error: {
+ message: "Request body must be a JSON object",
+ type: "invalid_request_error",
+ },
+ }),
+ );
+ return;
+ }
+
+ // Field-validation 400s journal the parsed body (unlike the malformed-JSON
+ // and non-object paths above, where there is no meaningful object to log).
+ const parsedBody = validationJournalBody(videoReq);
+
+ if (typeof videoReq.prompt !== "string" || !videoReq.prompt) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: parsedBody,
+ response: { status: 400, fixture: null },
+ });
+ // Distinguish an absent prompt from one that is present but unusable
+ // (non-string or empty) — "missing" would be wrong for the latter. The
+ // invalid-type message mirrors the model check below.
+ const message =
+ videoReq.prompt === undefined
+ ? "Missing required parameter: 'prompt'"
+ : "Invalid type for parameter: 'prompt' must be a non-empty string";
+ writeErrorResponse(
+ res,
+ 400,
+ JSON.stringify({
+ error: { message, type: "invalid_request_error" },
+ }),
+ );
+ return;
+ }
+
+ // An empty-string model is as unusable as a non-string one — it matches no
+ // fixture and is not a real model id — so both get the same 400.
+ if (videoReq.model !== undefined && (typeof videoReq.model !== "string" || !videoReq.model)) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: parsedBody,
+ response: { status: 400, fixture: null },
+ });
+ writeErrorResponse(
+ res,
+ 400,
+ JSON.stringify({
+ error: {
+ message: "Invalid type for parameter: 'model' must be a non-empty string",
+ type: "invalid_request_error",
+ },
+ }),
+ );
+ return;
+ }
+
+ const syntheticReq: ChatCompletionRequest = {
+ // Model-less submits assume the default model — a fixture restricted to
+ // DEFAULT_OPENROUTER_VIDEO_MODEL will match them.
+ model: videoReq.model ?? DEFAULT_OPENROUTER_VIDEO_MODEL,
+ messages: [{ role: "user", content: videoReq.prompt }],
+ _endpointType: "video",
+ _context: getContext(req),
+ };
+
+ const testId = getTestId(req);
+ const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(
+ fixtures,
+ syntheticReq,
+ journal.getFixtureMatchCountsForTest(testId),
+ defaults.requestTransform,
+ );
+
+ if (fixture) {
+ // Match count increments BEFORE applyChaos below by design (mirrors
+ // handleCompletions): a chaos-dropped submit still consumes the fixture's
+ // sequence slot.
+ journal.incrementFixtureMatchCount(fixture, fixtures, testId);
+ defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`);
+ } else {
+ const snippet = videoReq.prompt.slice(0, 80);
+ defaults.logger.debug(
+ `No fixture matched for request (model=${syntheticReq.model}, msg="${snippet}")`,
+ );
+ }
+
+ // Chaos deliberately rolls AFTER body validation and fixture matching
+ // (mirrors handleCompletions) — unlike the GET endpoints above, where chaos
+ // rolls first.
+ if (
+ applyChaos(
+ res,
+ fixture,
+ defaults.chaos,
+ 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",
+ defaults.registry,
+ defaults.logger,
+ )
+ )
+ return;
+
+ if (!fixture) {
+ if (defaults.record) {
+ defaults.logger.warn(
+ "record mode is not supported for /api/v1/videos — returning 404/503 no-match response",
+ );
+ }
+ const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
+ if (effectiveStrict) {
+ const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);
+ defaults.logger.error(strictNoMatchLogLine(method, path, skippedBySequenceOrTurn));
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: syntheticReq,
+ response: {
+ status: 503,
+ fixture: null,
+ ...strictOverrideField(defaults.strict, req.headers),
+ },
+ });
+ writeErrorResponse(
+ res,
+ 503,
+ JSON.stringify({
+ error: {
+ message: strictMessage,
+ type: "invalid_request_error",
+ code: "no_fixture_match",
+ },
+ }),
+ );
+ return;
+ }
+
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: syntheticReq,
+ response: {
+ status: 404,
+ fixture: null,
+ ...strictOverrideField(defaults.strict, req.headers),
+ },
+ });
+ writeErrorResponse(
+ res,
+ 404,
+ JSON.stringify({ error: { message: "No fixture matched", code: 404 } }),
+ );
+ return;
+ }
+
+ const response = await resolveResponse(fixture, syntheticReq);
+
+ if (isErrorResponse(response)) {
+ const status = response.status ?? 500;
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: syntheticReq,
+ response: { status, fixture },
+ });
+ writeErrorResponse(res, status, serializeErrorResponse(response), {
+ retryAfter: response.retryAfter,
+ });
+ return;
+ }
+
+ if (!isVideoResponse(response)) {
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: syntheticReq,
+ response: { status: 500, fixture },
+ });
+ writeErrorResponse(
+ res,
+ 500,
+ JSON.stringify({
+ error: { message: "Fixture response is not a video type", type: "server_error" },
+ }),
+ );
+ return;
+ }
+
+ journal.add({
+ method,
+ path,
+ headers: flattenHeaders(req.headers),
+ body: syntheticReq,
+ response: { status: 200, fixture },
+ });
+
+ // A fixture authored with any non-terminal status — "processing" or a
+ // status outside the union entirely (JSON fixtures bypass the compile-time
+ // check) — has no terminal state to converge on; terminalStatus coerces it
+ // to completed. Keep the behavior (jobs always terminate) but surface the
+ // coercion. Widen to string first: the runtime value may not be in the union.
+ const fixtureStatus: string = response.video.status;
+ if (fixtureStatus === "processing") {
+ defaults.logger.warn(
+ `Video fixture has status "processing" — treated as completed for /api/v1/videos jobs`,
+ );
+ } else if (fixtureStatus !== "completed" && fixtureStatus !== "failed") {
+ defaults.logger.warn(
+ `Video fixture has unknown status "${fixtureStatus}" — treating as completed for /api/v1/videos jobs`,
+ );
+ }
+
+ const jobId = crypto.randomUUID();
+ const progression = resolveProgression(defaults.openRouterVideo);
+ const job: OpenRouterVideoJob = {
+ jobId,
+ status: "pending",
+ pollCount: 0,
+ pollsBeforeInProgress: progression.pollsBeforeInProgress,
+ pollsBeforeCompleted: progression.pollsBeforeCompleted,
+ // Shallow-copy so later mutation of the fixture/factory response object
+ // cannot retroactively change an in-flight job's stored video.
+ video: { ...response.video },
+ };
+ // Default 0/0 progression seeds the job terminal at submit (mirrors fal's
+ // COMPLETED-on-submit initial status) — content is downloadable with zero
+ // polls; the first poll merely reports the already-terminal status. The
+ // submit envelope still reports "pending" like the real API.
+ if (progression.pollsBeforeCompleted === 0) {
+ job.status = terminalStatus(job);
+ }
+ jobs.set(`${testId}:${jobId}`, job);
+
+ 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",
+ }),
+ );
+}
diff --git a/src/server.ts b/src/server.ts
index 8c8499a5..7c13c3df 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -57,6 +57,13 @@ import { handleImages, handleImageEdit, handleImageVariations } from "./images.j
import { handleSpeech } from "./speech.js";
import { handleTranscription } from "./transcription.js";
import { handleVideoCreate, handleVideoStatus, VideoStateMap } from "./video.js";
+import {
+ handleOpenRouterVideoCreate,
+ handleOpenRouterVideoStatus,
+ handleOpenRouterVideoContent,
+ handleOpenRouterVideoModels,
+ OpenRouterVideoJobMap,
+} from "./openrouter-video.js";
import { handleElevenLabsAudio, handleElevenLabsTTS } from "./elevenlabs-audio.js";
import { handleFalQueue, falJobs } from "./fal-audio.js";
import { handleFal, falQueueStates } from "./fal.js";
@@ -71,7 +78,13 @@ import { handleWebSocketRealtime } from "./ws-realtime.js";
import { handleWebSocketGeminiLive } from "./ws-gemini-live.js";
import { Logger } from "./logger.js";
import { applyChaosAction, evaluateChaos } from "./chaos.js";
-import { createMetricsRegistry, normalizePathLabel } from "./metrics.js";
+import {
+ createMetricsRegistry,
+ normalizePathLabel,
+ OPENAI_VIDEO_STATUS_RE,
+ OPENROUTER_VIDEO_CONTENT_RE,
+ OPENROUTER_VIDEO_STATUS_RE,
+} from "./metrics.js";
import { proxyAndRecord } from "./recorder.js";
export interface ServerInstance {
@@ -80,6 +93,7 @@ export interface ServerInstance {
url: string;
defaults: HandlerDefaults;
videoStates: VideoStateMap;
+ openRouterVideoJobs: OpenRouterVideoJobMap;
}
const COMPLETIONS_PATH = "/v1/chat/completions";
@@ -101,7 +115,6 @@ const SPEECH_PATH = "/v1/audio/speech";
const TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions";
const TRANSLATIONS_PATH = "/v1/audio/translations";
const VIDEOS_PATH = "/v1/videos";
-const VIDEOS_STATUS_RE = /^\/v1\/videos\/([^/]+)$/;
const GEMINI_PREDICT_RE = /^\/v1beta\/models\/([^:]+):predict$/;
const ELEVENLABS_SOUND_GENERATION_PATH = "/v1/sound-generation";
const ELEVENLABS_TTS_RE = /^\/v1\/text-to-speech\/([^/]+)$/;
@@ -172,6 +185,14 @@ const OLLAMA_EMBEDDINGS_PATH = "/api/embeddings";
const OLLAMA_EMBED_PATH = "/api/embed";
const OLLAMA_TAGS_PATH = "/api/tags";
+// OpenRouter async video lifecycle (/api/v1/videos). Dispatch order matters:
+// content RE → models exact → status RE → submit exact. The status RE's
+// `[^/]+` segment would otherwise swallow the `models` listing path. The
+// content/status REs are shared with metrics.ts path-label normalization
+// (imported above).
+const OPENROUTER_VIDEOS_PATH = "/api/v1/videos";
+const OPENROUTER_VIDEO_MODELS_PATH = "/api/v1/videos/models";
+
const HEALTH_PATH = "/health";
const READY_PATH = "/ready";
const MODELS_PATH = "/v1/models";
@@ -226,11 +247,13 @@ function performFixturesReset(
fixtures: Fixture[],
journal: Journal,
videoStates: VideoStateMap,
+ openRouterVideoJobs: OpenRouterVideoJobMap,
defaults: HandlerDefaults,
): void {
fixtures.length = 0;
journal.clear();
videoStates.clear();
+ openRouterVideoJobs.clear();
falJobs.clear();
falQueueStates.clear();
resetInteractionCounter();
@@ -251,6 +274,7 @@ async function handleControlAPI(
fixtures: Fixture[],
journal: Journal,
videoStates: VideoStateMap,
+ openRouterVideoJobs: OpenRouterVideoJobMap,
defaults: HandlerDefaults,
): Promise {
if (!pathname.startsWith(CONTROL_PREFIX)) return false;
@@ -333,7 +357,7 @@ async function handleControlAPI(
// POST /__aimock/reset/fixtures — full reset (fixtures + journal + match counts)
if (subPath === "/reset/fixtures" && req.method === "POST") {
- performFixturesReset(fixtures, journal, videoStates, defaults);
+ performFixturesReset(fixtures, journal, videoStates, openRouterVideoJobs, defaults);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ reset: true }));
return true;
@@ -350,7 +374,7 @@ async function handleControlAPI(
// POST /__aimock/reset — DEPRECATED alias for /reset/fixtures (full reset)
if (subPath === "/reset" && req.method === "POST") {
- performFixturesReset(fixtures, journal, videoStates, defaults);
+ performFixturesReset(fixtures, journal, videoStates, openRouterVideoJobs, defaults);
const deprecation =
"POST /__aimock/reset is deprecated; use POST /__aimock/reset/fixtures (full reset) or POST /__aimock/reset/journal (journal only)";
defaults.logger.warn(
@@ -1078,6 +1102,9 @@ export async function createServer(
get falQueue() {
return serverOptions.falQueue;
},
+ get openRouterVideo() {
+ return serverOptions.openRouterVideo;
+ },
};
// Validate chaos config rates
@@ -1094,6 +1121,26 @@ export async function createServer(
}
}
+ // Validate poll-progression thresholds (resolveProgression treats
+ // non-finite values as unset and floors/clamps the rest to >= 0 integers)
+ for (const { name, config } of [
+ { name: "falQueue", config: options?.falQueue },
+ { name: "openRouterVideo", config: options?.openRouterVideo },
+ ]) {
+ if (!config) continue;
+ for (const field of ["pollsBeforeInProgress", "pollsBeforeCompleted"] as const) {
+ const value = config[field];
+ if (value === undefined) continue;
+ if (!Number.isFinite(value)) {
+ logger.warn(`${name}.${field} (${value}) is not a finite number — treating as unset`);
+ } else if (!Number.isInteger(value) || value < 0) {
+ logger.warn(
+ `${name}.${field} (${value}) is not a non-negative integer — flooring/clamping to a non-negative integer`,
+ );
+ }
+ }
+ }
+
// 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.
@@ -1102,6 +1149,7 @@ export async function createServer(
fixtureCountsMaxTestIds: options?.fixtureCountsMaxTestIds ?? 500,
});
const videoStates = new VideoStateMap();
+ const openRouterVideoJobs = new OpenRouterVideoJobMap();
// Share journal and metrics registry with mounted services
if (mounts) {
@@ -1175,7 +1223,16 @@ export async function createServer(
// Control API — must be checked before mounts and path rewrites
if (pathname.startsWith(CONTROL_PREFIX)) {
- await handleControlAPI(req, res, pathname, fixtures, journal, videoStates, defaults);
+ await handleControlAPI(
+ req,
+ res,
+ pathname,
+ fixtures,
+ journal,
+ videoStates,
+ openRouterVideoJobs,
+ defaults,
+ );
return;
}
@@ -1280,6 +1337,199 @@ export async function createServer(
return;
}
+ // OpenRouter async video lifecycle (/api/v1/videos). Like the Ollama
+ // /api/* routes above, dispatched before normalizeCompatPath. Order:
+ // content RE → models exact → status RE → submit exact (the status RE's
+ // `[^/]+` segment would otherwise swallow the `models` listing path; the
+ // content path's extra `/content` segment can never match it).
+
+ // GET /api/v1/videos/{jobId}/content — download the generated bytes
+ const openRouterVideoContentMatch = pathname.match(OPENROUTER_VIDEO_CONTENT_RE);
+ if (openRouterVideoContentMatch && req.method === "GET") {
+ try {
+ handleOpenRouterVideoContent(
+ req,
+ res,
+ openRouterVideoContentMatch[1],
+ journal,
+ defaults,
+ setCorsHeaders,
+ openRouterVideoJobs,
+ );
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Internal error";
+ defaults.logger.error(`openrouter-video content: ${msg}`);
+ if (!res.headersSent) {
+ // Journal the failed request so it isn't invisible to consumers —
+ // guarded on headersSent so a throw after a successful journal +
+ // response does not double-journal. Wrapped so journaling can
+ // never mask the 500 write below.
+ try {
+ journal.add({
+ method: req.method ?? "GET",
+ path: req.url ?? pathname,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 500, fixture: null },
+ });
+ } catch (jErr) {
+ defaults.logger.warn(
+ `openrouter-video content: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`,
+ );
+ }
+ writeErrorResponse(
+ res,
+ 500,
+ JSON.stringify({ error: { message: msg, type: "server_error" } }),
+ );
+ } else if (!res.writableEnded) {
+ res.destroy();
+ }
+ }
+ return;
+ }
+
+ // GET /api/v1/videos/models — video model listing (must precede the
+ // 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);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Internal error";
+ defaults.logger.error(`openrouter-video models: ${msg}`);
+ if (!res.headersSent) {
+ // Journal the failed request so it isn't invisible to consumers —
+ // guarded on headersSent so a throw after a successful journal +
+ // response does not double-journal. Wrapped so journaling can
+ // never mask the 500 write below.
+ try {
+ journal.add({
+ method: req.method ?? "GET",
+ path: req.url ?? pathname,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 500, fixture: null },
+ });
+ } catch (jErr) {
+ defaults.logger.warn(
+ `openrouter-video models: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`,
+ );
+ }
+ writeErrorResponse(
+ res,
+ 500,
+ JSON.stringify({ error: { message: msg, type: "server_error" } }),
+ );
+ } else if (!res.writableEnded) {
+ res.destroy();
+ }
+ }
+ return;
+ }
+
+ // GET /api/v1/videos/{jobId} — poll job status
+ const openRouterVideoStatusMatch = pathname.match(OPENROUTER_VIDEO_STATUS_RE);
+ if (openRouterVideoStatusMatch && req.method === "GET") {
+ try {
+ handleOpenRouterVideoStatus(
+ req,
+ res,
+ openRouterVideoStatusMatch[1],
+ journal,
+ defaults,
+ setCorsHeaders,
+ openRouterVideoJobs,
+ );
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Internal error";
+ defaults.logger.error(`openrouter-video status: ${msg}`);
+ if (!res.headersSent) {
+ // Journal the failed request so it isn't invisible to consumers —
+ // guarded on headersSent so a throw after a successful journal +
+ // response does not double-journal. Wrapped so journaling can
+ // never mask the 500 write below.
+ try {
+ journal.add({
+ method: req.method ?? "GET",
+ path: req.url ?? pathname,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 500, fixture: null },
+ });
+ } catch (jErr) {
+ defaults.logger.warn(
+ `openrouter-video status: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`,
+ );
+ }
+ writeErrorResponse(
+ res,
+ 500,
+ JSON.stringify({ error: { message: msg, type: "server_error" } }),
+ );
+ } else if (!res.writableEnded) {
+ res.destroy();
+ }
+ }
+ return;
+ }
+
+ // POST /api/v1/videos — submit a video generation job
+ if (pathname === OPENROUTER_VIDEOS_PATH && req.method === "POST") {
+ // CORS headers before the body is read: a readBody throw (e.g. the
+ // body-size cap) lands in the catch below, which must not write a 500
+ // that is opaque to browser clients. The handler re-applies the same
+ // headers (setHeader is idempotent).
+ setCorsHeaders(res);
+ try {
+ const raw = await readBody(req);
+ await handleOpenRouterVideoCreate(
+ req,
+ res,
+ raw,
+ fixtures,
+ journal,
+ defaults,
+ setCorsHeaders,
+ openRouterVideoJobs,
+ );
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Internal error";
+ defaults.logger.error(`openrouter-video submit: ${msg}`);
+ if (!res.headersSent) {
+ // Journal the failed request so it isn't invisible to consumers —
+ // on submit a throw may have already consumed a fixture-sequence
+ // slot, which would otherwise leave no trace. Guarded on
+ // headersSent so a throw after a successful journal + response
+ // does not double-journal (the guard only covers post-write
+ // throws — a throw between the handler's journal.add and its
+ // writeHead would still double-journal, though that window is
+ // effectively throw-free today). Wrapped so journaling can never
+ // mask the 500 write below.
+ try {
+ journal.add({
+ method: req.method ?? "POST",
+ path: req.url ?? pathname,
+ headers: flattenHeaders(req.headers),
+ body: null,
+ response: { status: 500, fixture: null },
+ });
+ } catch (jErr) {
+ defaults.logger.warn(
+ `openrouter-video submit: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`,
+ );
+ }
+ writeErrorResponse(
+ res,
+ 500,
+ JSON.stringify({ error: { message: msg, type: "server_error" } }),
+ );
+ } else if (!res.writableEnded) {
+ res.destroy();
+ }
+ }
+ return;
+ }
+
// Azure OpenAI: /openai/deployments/{id}/{operation} → /v1/{operation} (chat/completions, embeddings)
// Must be checked BEFORE the generic /openai/ prefix strip
let azureDeploymentId: string | undefined;
@@ -1701,7 +1951,7 @@ export async function createServer(
}
// GET /v1/videos/{id} — Video Status Check
- const videoStatusMatch = pathname.match(VIDEOS_STATUS_RE);
+ const videoStatusMatch = pathname.match(OPENAI_VIDEO_STATUS_RE);
if (videoStatusMatch && req.method === "GET") {
const videoId = videoStatusMatch[1];
handleVideoStatus(req, res, videoId, journal, defaults, setCorsHeaders, videoStates);
@@ -2445,6 +2695,8 @@ export async function createServer(
ws.close(1001, "Server shutting down");
}
activeConnections.clear();
+ videoStates.clear();
+ openRouterVideoJobs.clear();
originalClose(callback);
return this;
} as typeof server.close;
@@ -2466,7 +2718,7 @@ export async function createServer(
}
}
- resolve({ server, journal, url, defaults, videoStates });
+ resolve({ server, journal, url, defaults, videoStates, openRouterVideoJobs });
});
});
}
diff --git a/src/types.ts b/src/types.ts
index d34c92dd..bb6335b5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -255,6 +255,12 @@ export interface VideoResponse {
id: string;
status: "processing" | "completed" | "failed";
url?: string;
+ /** Failure message surfaced by async video jobs (e.g. OpenRouter `error`). */
+ error?: string;
+ /** Base64-encoded video bytes served by content-download endpoints. */
+ b64?: string;
+ /** Generation cost surfaced in usage envelopes (e.g. OpenRouter `usage.cost`). */
+ cost?: number;
};
}
@@ -499,8 +505,12 @@ 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. Absent when the distinction doesn't apply (e.g.
- * 404/503 fallback where nothing was going to serve).
+ * 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).
*/
source?: "fixture" | "proxy" | "internal";
interrupted?: boolean;
@@ -688,13 +698,32 @@ export interface MockServerOptions {
* legacy `/fal/queue/...` audio handler is unaffected.
*/
falQueue?: FalQueueConfig;
+ /**
+ * Configure OpenRouter async video job polling progression
+ * (`pending → in_progress → completed | failed` on `GET /api/v1/videos/{id}`).
+ * Same threshold semantics as `falQueue`. By default (0/0) the job is
+ * seeded terminal at submit — content is downloadable with zero polls, and
+ * the first status poll merely reports the already-terminal status.
+ */
+ openRouterVideo?: FalQueueConfig;
}
+/**
+ * Poll-progression thresholds, documented below in fal queue terms; when used
+ * as `openRouterVideo` the states map to `pending` / `in_progress` /
+ * `completed | failed`.
+ */
export interface FalQueueConfig {
/**
- * Status polls before transitioning `IN_QUEUE → IN_PROGRESS`. Default: 0.
- * When `pollsBeforeCompleted` is also unset, the job completes synchronously
- * on submit (no IN_QUEUE / IN_PROGRESS polls emitted).
+ * Status polls before transitioning `IN_QUEUE → IN_PROGRESS`. Unset and an
+ * explicit `0` differ: when BOTH fields are unset the job completes
+ * synchronously on submit (no IN_QUEUE / IN_PROGRESS polls emitted), but
+ * explicitly setting this field — even to `0` — enables progression when
+ * `pollsBeforeCompleted` is unset, with `pollsBeforeCompleted` defaulting
+ * to `pollsBeforeInProgress + 1` so the job passes through IN_PROGRESS
+ * (an explicit `pollsBeforeCompleted: 0` completes on submit only when
+ * `pollsBeforeInProgress` is `0` or unset — otherwise the clamp below
+ * lifts it to `pollsBeforeInProgress`).
*/
pollsBeforeInProgress?: number;
/**
@@ -702,7 +731,11 @@ export interface FalQueueConfig {
* `pollsBeforeInProgress` is also unset (no progression), otherwise
* `pollsBeforeInProgress + 1` so the job spends one poll in IN_PROGRESS.
* An explicit value lower than `pollsBeforeInProgress` is clamped up so
- * IN_PROGRESS is never skipped.
+ * IN_PROGRESS is never skipped. When equal to a nonzero
+ * `pollsBeforeInProgress` — or when `pollsBeforeInProgress` is unset or 0
+ * and this field is 1 (the IN_PROGRESS branch consumes the first poll) —
+ * the job still spends one poll in IN_PROGRESS, so the terminal status
+ * lands one poll later than configured.
*/
pollsBeforeCompleted?: number;
}
@@ -720,4 +753,5 @@ export interface HandlerDefaults {
strict?: boolean;
requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest;
falQueue?: FalQueueConfig;
+ openRouterVideo?: FalQueueConfig;
}