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 — 401 without it; 404 for an unknown or TTL-expired job; 400 before the job completes — and permanently for a failed job, whose content is never downloadable)
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.

openrouter-video.test.ts ts
mock.onVideo("a cat playing piano", {
  // `id` is required by the type but ignored on this surface (see below)
  video: { id: "vid_1", status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
});

// A failed job:
mock.onVideo("impossible prompt", {
  video: { id: "vid_2", status: "failed", error: "content policy violation" },
});

The fixture's video object supports:

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.

polling.test.ts ts
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 when pollsBeforeCompleted is unset, with pollsBeforeCompleted defaulting to pollsBeforeInProgress + 1 so the job passes through in_progress. An explicit { pollsBeforeInProgress: 0, pollsBeforeCompleted: 0 } still seeds the job terminal at submit. An explicit pollsBeforeCompleted lower than pollsBeforeInProgress is clamped up so in_progress is never skipped — once a job enters the progression at all (the explicit {0, 0} case above seeds terminal and never polls through it).

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) — except when the content endpoint live-proxies an upstream (under record mode's proxy-only operation, or during the brief capture window while an eager capture is in flight), where the index selects the position-aligned upstream unsigned_urls entry. Fetching content never advances job state — clients learn the content URL only from a completed status poll.

Test Isolation

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). In record mode the listing is relayed verbatim from the upstream instead (journaled source: "proxy", never recorded as a fixture), falling back to the synthesis if the upstream is unreachable. Strict mode disables the proxy — a strict request is always served the synthesized listing.

Chaos & Metrics

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

Record Mode

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

aimock.config.json json
{
  "llm": {
    "fixtures": "./fixtures",
    "record": {
      "providers": { "openrouter": "https://openrouter.ai" }
    }
  }
}
CLI usage shell
$ npx -p @copilotkit/aimock aimock -c aimock.config.json

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

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

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

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

TTL caveat: record-mode jobs live in the same bounded job map as replay jobs (1-hour TTL, 10,000 entries). Each successful proxied poll refreshes a record job's TTL, and successful content downloads refresh it too (replay and record jobs alike), so an actively-polled or actively-downloading long generation is never evicted mid-recording — but a poll arriving more than an hour after the last successful poll or download finds the job evicted and returns 404 (the upstream lifecycle is then unreachable through the mock).