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.
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:
-
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/videossurface 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 asusage.coston completion -
url?— ignored on this surface (the content endpoint serves bytes, not a redirect); useb64
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
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.
{
"llm": {
"fixtures": "./fixtures",
"record": {
"providers": { "openrouter": "https://openrouter.ai" }
}
}
}
$ 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
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).