feat: OpenRouter async video generation endpoints (/api/v1/videos job lifecycle)#262
Merged
Conversation
Adds optional error/b64/cost to VideoResponse.video, plumbs an openRouterVideo progression config (FalQueueConfig semantics) through MockServerOptions and HandlerDefaults, and exports resolveProgression from fal.ts for reuse by the OpenRouter async video lifecycle handlers.
Adds src/openrouter-video.ts with a TTL+bounded per-testId job map
(mirroring FalQueueStateMap) and handleOpenRouterVideoCreate for
POST /api/v1/videos: JSON-only body validation, fixture matching via
the video endpoint type, chaos/strict-503/OpenRouter-shaped 404 and
journal coverage on every path, and a {id, polling_url, status:
pending} submit envelope. Job state is reset by the control API,
LLMock.reset(), and server close.
Adds handleOpenRouterVideoStatus for GET /api/v1/videos/{jobId}:
advances pending → in_progress → completed|failed per the
openRouterVideo poll thresholds (fal advanceJob semantics — equal
thresholds still spend one poll in in_progress; default 0/0 reaches
the terminal status on the first poll). Completed polls add
unsigned_urls and usage.cost; failed polls add the fixture's error
message (default "Video generation failed"). Chaos, 404, and
journaling on every path.
Adds handleOpenRouterVideoContent for GET
/api/v1/videos/{jobId}/content: 401 OpenRouter-shaped error without
an Authorization header, 404 for unknown jobs, JSON 400 for
non-completed jobs, and the fixture's base64 bytes (or a built-in
24-byte ftyp placeholder) served as Content-Type video/mp4 — always,
including when the client sends Accept: application/octet-stream,
matching the live endpoint. Chaos and journaling on every path.
Adds handleOpenRouterVideoModels for GET /api/v1/videos/models,
synthesizing {data:[...]} entries from loaded video-endpoint fixtures
with string models (mirroring the Ollama /api/tags synthesis) and
falling back to a default model set when none are loaded. Dispatched
before the status RE so the literal models segment is not captured
as a job id, with a regression test pinning that ordering.
Covers strict-mode 503 with the shared sequence/turn-skip matcher, journal coverage across every lifecycle path, chaos drop injection on submit/status/content, full submit→poll→download and failure/auth lifecycles, and routing collision regressions (Ollama /api/chat + /api/embeddings, OpenAI /v1/videos, and /api/v1/videos/models vs the status RE).
Non-object/null JSON bodies, non-string prompt, and non-string model now return 400 invalid_request_error instead of leaking a raw TypeError 500 or falling through to fixture matching. The /content endpoint now requires the Bearer scheme (not just header presence), matching the documented behavior.
polling_url and unsigned_urls now embed a testId query param when the request runs under a non-default test scope, since the @openrouter/sdk fetches these URLs bare (no custom headers) and would otherwise resolve the wrong job map. Generated URLs also honor x-forwarded-proto so they survive TLS-terminating proxies.
The no-fixture submit path journaled chaos with source "proxy" but this surface never proxies — it is now "internal" per the JournalEntry contract. /api/v1/videos/models now threads HandlerDefaults and rolls chaos like the status handler, so drop/malformed/disconnect headers take effect there too.
Warn when a "processing" fixture is coerced to completed, when a no-match occurs while record mode is configured (recording is not wired for this surface), and when a fixture's non-empty b64 decodes to zero bytes. The no-match debug line now includes the model and a prompt snippet, mirroring handleCompletions.
The close override cleared openRouterVideoJobs but left the sibling per-instance videoStates map populated; both are now cleared symmetrically. OpenRouterVideoJob.createdAt was never read — TTL eviction uses the entry wrapper's own createdAt.
…mments CHANGELOG [Unreleased]: note the shared endpoint:"video" fixture pool with the OpenAI /v1/videos handler, the built-in default model fallback when no video fixtures are loaded, the accepted-but-ignored index query param, the re-scoped "same poll-threshold semantics as falQueue" wording, and that the content endpoint does not advance job state (URLs come from a completed poll — API fidelity, diverging from fal's advance-on-result). server.ts: the dispatch-order comment claimed the status RE would swallow both /models and /content; only /models is at risk (the content path's extra segment can never match), matching the route-constant comment above. openrouter-video.ts: document on the content handler that index is ignored and that fetching content deliberately does not advance job state.
Hygiene: stop the options-acceptance LLMock in a finally block; consume the
unread fetch bodies in the journal-coverage and auth-lifecycle tests; assert
the error type/message on the non-video-fixture 500; merge the duplicate
models-route tests and assert the status-handler 404 shape is specifically
absent; make the fixture-derived models assertion order-insensitive; declare
the mock?.stop()-guarded locals as LLMock | undefined; fix the stale
"reach completed via a status poll" comment (default 0/0 seeds the job
terminal at submit).
Coverage (pins existing behavior): reset plumbing via both
POST /__aimock/reset/fixtures and LLMock.reset() (old jobId polls 404);
OpenRouterVideoJobMap unit tests for lazy 1h-TTL eviction on get (fake
timers) and FIFO eviction past the 10k capacity; completed-only progression
config ({pollsBeforeCompleted} alone) passing through in_progress;
X-AIMock-Strict header override turning a no-match 404 into a 503 on a
strict-off server; Content-Length asserted on both b64 and placeholder
downloads.
normalizePathLabel had no rule for the new dynamic video routes, so every
GET /api/v1/videos/<uuid> and /<uuid>/content minted a unique path label on
aimock_requests_total and aimock_request_duration_seconds — unbounded label
cardinality. Map them to /api/v1/videos/{jobId} and
/api/v1/videos/{jobId}/content, keeping the static models listing distinct,
and cover the pre-existing OpenAI sibling /v1/videos/{id} as well.
Behavioral cluster from the round-2 CR on the OpenRouter video surface: - /content Bearer check is now RFC 7235 case-insensitive and rejects an empty credential (scheme alone no longer passes) - empty-string model is rejected with 400 invalid_request_error instead of silently matching nothing - field-validation 400s journal the parsed request body (malformed-JSON and non-object paths keep body: null) - b64 corruption warn now round-trips the lenient decode, catching payloads that silently truncate instead of only zero-byte decodes - any fixture status outside the processing/completed/failed union warns before being coerced to completed (JSON fixtures bypass the type union) - x-forwarded-proto is allowlisted to http/https; other values fall back - the model listing debug-logs when video fixtures exist but none contributes a string model and the default set is served - the four OpenRouter video route catch blocks in server.ts log handler errors via logger.error before writing the 500
CHANGELOG: the /api/v1/videos/models listing is synthesized from loaded video fixtures that specify a string match.model (regex models are excluded), falling back to a built-in default set otherwise. types.ts: bridge the FalQueueConfig field docs (written in fal queue terms) to the openRouterVideo usage, where the states map to pending / in_progress / completed | failed.
New pins of existing behavior: X-Test-Id header wins over the ?testId= query param; X-AIMock-Strict: false downgrades a strict server's no-match 503 to 404; multipart/form-data submit returns 400 invalid_json (watch-item should the @openrouter/sdk ever shift to multipart); a "processing"-status fixture runs the full lifecycle to completed + download; post-terminal status polls are idempotent for completed (urls + usage) and failed (error) jobs; content fetch with Bearer immediately after submit succeeds under the default 0/0 progression (pins the documented divergence from fal's advance-on-result). Hygiene: drop the stray createdAt from the unit-test job factory; export OPENROUTER_VIDEO_MAX_ENTRIES and use it in the FIFO-capacity test instead of a hardcoded 10_000; normalize the later describes to let mock: LLMock | undefined with mock = undefined after stop (guarding closed-over helpers); reword the stale "advance to completed" comments — under default 0/0 the job is seeded terminal at submit. Also note at the submit increment site that the match count increments before applyChaos by design (mirrors handleCompletions), so chaos-dropped submits consume sequence slots.
…, url warn, prompt 400 - field-validation 400s journal a structurally valid synthetic ChatCompletionRequest (messages: [], non-string model JSON-encoded) instead of casting the raw parsed body - b64 corruption warn compares decoded byte count against the sanitized input's implied length, so valid non-canonical base64 (nonzero trailing bits, e.g. "QR") no longer false-positives while skipped-character corruption still warns - requestBase honors x-forwarded-host (first value wins on comma-joined lists, array-header tolerant) alongside x-forwarded-proto in generated polling/content URLs - content endpoint warns when a url-only fixture is served the placeholder MP4 — the author's url is ignored on this surface - a present-but-invalid prompt now 400s with "'prompt' must be a non-empty string" instead of claiming the parameter is missing (mirrors the model path's message style)
…log match - the faulty metrics registry test now asserts callCount >= 2 after the second request, so the test fails instead of passing vacuously if the createMetricsRegistry spy stops intercepting - the models-fallback debug-log assertion matches the distinctive message substring instead of any line containing "default" and "video"
- JournalEntry.source doc now covers the "internal" union member (aimock's own synthetic logic where no fixture matched and no proxy applies, e.g. OpenRouter video lifecycle endpoints) - the 0/0-progression seed comment and CHANGELOG entry now say the job is seeded terminal at submit (content downloadable with zero polls) rather than implying the first poll causes the transition
…500 journaling - b64 corruption heuristic now mirrors Node's decoder: the first "=" terminates the decode, so post-padding characters no longer count as data chars (concatenated payloads like "QQ==QQ==" no longer false-warn), and a sanitized length congruent to 1 mod 4 — malformed base64 the length-mismatch check can never catch — now warns explicitly. - x-forwarded-host is only honored when it matches a conservative host[:port] shape; junk values (spaces, slashes, userinfo) fall back to the Host header instead of being interpolated into generated URLs. - The four /api/v1/videos route catch blocks now journal a status-500 entry so a handler throw (which on submit may have consumed a fixture-sequence slot) is no longer invisible; journaling is wrapped so it can never mask the 500.
…route REs - openRouterVideo option doc now matches the pinned default behavior: 0/0 seeds the job terminal at submit, the first poll merely reports it, and content is downloadable with zero polls. - validationJournalBody comment no longer claims to mirror the submit success path's synthetic shape — raw request fields ride along only on the validation path. - JournalEntry.source "internal" doc tightened: it appears on chaos-path entries served by aimock's own synthetic logic; normal lifecycle entries omit source. - Submit handler comments document that chaos rolls after body validation and fixture matching, unlike the GET endpoints. - OPENROUTER_VIDEO_CONTENT_RE / OPENROUTER_VIDEO_STATUS_RE are now exported from metrics.ts and imported by server.ts instead of duplicated. - FalQueueConfig.pollsBeforeCompleted doc notes the equal-thresholds case lands the terminal status one poll later.
…anup
- Boundary-aware match for path="/api/v1/videos" so the {jobId} line can't
substring-satisfy it.
- record.providers.openai placeholder is now URL-shaped instead of an API-key
lookalike.
- The server-close state test closes its server in a guarded try/finally so a
mid-test assertion failure can't leak the instance.
- The chaos proxy-source test uses a fresh mkdtemp fixture dir (cleaned up)
instead of a fixed shared /tmp path.
…aling - accept bracketed IPv6 literals ([::1], [::1]:8080) in x-forwarded-host and warn when a present-but-rejected value falls back to the Host header - warn when a fixture's b64 is the empty string before serving the placeholder MP4 - reword the mod-4 b64 warn: invalid characters also land in this branch, so the "final character is silently dropped" diagnosis was wrong for inputs like "AAAA!" - guard the /api/v1/videos* catch-block journaling on res.headersSent so a throw after a successful journal+response cannot double-journal, and log a debug line when the journal write itself fails instead of swallowing it silently
…me bodies
- pin that /content fetches never advance job state (consume no poll budget), shown red
via a temporary advanceJob mutation in the content handler
- pin {pollsBeforeCompleted: 1} landing terminal on poll 2 (in_progress consumes poll 1)
- consume response bodies at the remaining three spots that left connections dangling
(zero-bytes status poll, completedJob helper, server-close test's two submits)
…sage
- document the unset-vs-explicit-0 distinction on pollsBeforeInProgress (explicitly
setting it, even to 0, enables progression) and extend the late-by-one note on
pollsBeforeCompleted to the {pollsBeforeInProgress unset/0, pollsBeforeCompleted: 1} case
- note in the CHANGELOG that a default "Video generation failed" message is used when a
failed fixture has no error field
…edge cases - strip underscore-prefixed client keys (_endpointType, _context) from the validation-400 journal body so requests cannot spoof handler-set discriminators - sanitize resolveProgression thresholds to non-negative integers: non-finite values (NaN/Infinity) are treated as unset and negatives/fractions clamp, so a NaN threshold can no longer strand polling clients short of terminal and -1 honors the explicit-0 progression contract (also covers the falQueue surface); createServer warns on invalid thresholds next to the chaos-rate validation - warn when a non-empty b64 (e.g. padding-only "=") decodes to zero bytes - firstForwardedValue skips empty segments: an empty x-forwarded-* header or a leading-comma list no longer triggers a spurious rejection warn or discards valid later entries - include the offending (truncated, JSON-quoted) value in the x-forwarded-host rejection warn - include the caught error message in the four openrouter-video journal-failure debug logs instead of discarding it - warn when a present ?index= parses to a non-zero value (still serves index 0) - shallow-copy the fixture's video object at job creation so later fixture or factory mutation cannot retroactively change an in-flight job - exclude an empty-string match.model from the video models listing
- pollsBeforeInProgress doc: explicit 0 enables progression only when
pollsBeforeCompleted is unset ({0,0} still completes on submit)
- note in the CHANGELOG and at the fallback site that model-less submits
assume the default video model, so model-restricted fixtures can match them
- note the deliberate lifetime divergence between OpenRouterVideoJobMap
(per-server-instance) and FalQueueStateMap (module-global)
…ontracts - a chaos-dropped submit consumes the matched fixture's sequence slot (a clean identical resubmit gets the strict skip-by-state 503); verified red-by-mutation against moving the increment after applyChaos - a comma-joined x-forwarded-proto list honors the first value in generated URLs - a chaos-dropped status poll (no fixture in scope) journals source "internal"
- validate the Host-header fallback in requestBase with the same host[:port] REs as x-forwarded-host, falling back to localhost — a junk Host (evil.com/x) can no longer smuggle a path into polling_url/unsigned_urls - scan all elements of an array-typed x-forwarded-* header in firstForwardedValue (join before split), matching its docstring - warn and serve the default message when a failed fixture carries an explicit empty error instead of returning "" to polling clients - split the createServer threshold warn: non-finite values are treated as unset (resolveProgression semantics); negative/fractional values clamp - raise the four journal-write-failure logs from debug to warn — a journal failure during error handling is a double-fault worth surfacing
…change - CHANGELOG: document the falQueue behavior change from the shared resolveProgression sanitization (non-finite thresholds treated as unset, negative/fractional values clamped, createServer warns on invalid values) - types.ts: an explicit pollsBeforeCompleted: 0 completes on submit only when pollsBeforeInProgress is 0 or unset (the clamp otherwise lifts it) - server.ts: note the submit catch's headersSent guard only covers post-write throws, not the journal.add → writeHead window
…, warn wording - FORWARDED_HOST_RE admits underscores: hostnames like my_project_aimock:4010 are routine in docker-compose/k8s networks, and a rejected Host silently fell back to a dead "localhost" polling_url/unsigned_urls. The value feeds URL-string interpolation, not DNS validation. Covered by a red-green raw-Host test. - The /api/v1/videos submit route sets CORS headers before readBody, so a body-read failure that lands in the catch writes a 500 a browser client can inspect instead of an opaque CORS-blocked failure. The readBody rejection paths destroy the socket (no observable response), so the regression test asserts the handler-throw 500 carries access-control-allow-origin. - server.ts VIDEOS_STATUS_RE was byte-identical to metrics.ts OPENAI_VIDEO_STATUS_RE; export it from metrics.ts (like the OpenRouter REs) so the two cannot drift. - The poll-threshold warn said "clamping" but resolveProgression floors fractional values (1.9 -> 1); say "flooring/clamping" so the message matches the behavior.
… comment - testIdSuffix said the @openrouter/sdk fetches generated URLs "bare -- no custom headers", which read as contradicting the content endpoint's Bearer requirement. Both are true: the SDK sends standard Authorization but no aimock-specific headers (no x-test-id). Reword so the two comments cannot be read as conflicting. - The metrics test comment claimed an omitted source label "would serialize source=\"\"" -- wrong; an omitted label is simply absent from the series. Reword to say the source-less series would pass a bare action match but fails this regex.
The models-listing fallback to the default video model set is a fixture-authoring surprise; this surface's convention is warn for those, and debug is invisible at the CLI default info level. Pin test updated to spy console.warn at logLevel "warn".
…ests
"error" is not a valid LogLevel ("silent" | "warn" | "info" | "debug");
the Logger constructor silently degraded it to fully-silent — use
"silent" to match the intent. The zero-byte decode test matched the
substring "base64", which the zero-byte warn does not contain — it was
satisfied by the separate length-mismatch warn that also fires for
"!!!!"; assert "zero bytes" specifically.
Reconcile the "pending" submit envelope with the internally-terminal default seeding; clarify that zero-poll content needs a client-built URL while the documented flow polls once to learn unsigned_urls; replace "replay/strict-only" with "replay-only (no record/proxy mode yet)" since the surface works in lenient mode too; note status polls and the models listing are served without auth (only /content enforces Bearer); fix the shared-progression-resolver sentence grammar in the Fixed entry.
commit: |
Adds a dedicated docs page for the OpenRouter async video lifecycle (/api/v1/videos submit, status poll, content download, models listing), wires it into the sidebar, extends the fixtures response-fields table with the new video error/b64/cost fields, notes poll-threshold sanitization on the fal.ai page, cross-links the OpenRouter surface from the video page, and adds it to the README multimedia bullet.
The Fixture Authoring bullet lumped id with status, implying the fixture's video.id is surfaced as the job id. On this surface the job id is always a server-minted UUID, so split the bullet: status alone drives terminal state, and id is annotated as ignored (the /v1/videos surface does use it). Drop the inert id fields from the page's code examples.
jpr5
added a commit
that referenced
this pull request
Jun 10, 2026
…264) Follow-up from the #262 docs verification: `docs/video/index.html` described behavior the code does not have. Corrections, verified against `src/video.ts` and `src/types.ts` (`VideoResponse`) on main: 1. **Async Polling Pattern** — removed the false claim that aimock returns `"processing"` on the first poll then `"completed"` afterward. There is no progression: the create response and every poll echo the fixture's `video.status` verbatim. Cross-linked the OpenRouter surface (`openRouterVideo` config) for staged progression. 2. **Unit-test example** — the previous example could not pass or typecheck: the `onVideo` fixture omitted required `id`/`status`, used a nonexistent `duration` field, and asserted a nested `pollBody.video.url`. Rewritten to compile against `VideoResponse` and match the real flat poll body `{id, status, created_at, url}`. 3. **Response Format** — `created` → `created_at` on create; poll fields are flat (`url`, not `video.url`); removed nonexistent `video.duration`; added `"failed"` to the status union. 4. **JSON fixture example** — added the required `id`/`status` fields, dropped `duration`. Also fixed the Record & Replay paragraph, which repeated the lifecycle-simulation claim (recorded in-progress responses replay as `"processing"` verbatim), and re-verified the #262 cross-link paragraph still reads correctly.
This was referenced Jun 11, 2026
Merged
jpr5
added a commit
that referenced
this pull request
Jun 11, 2026
### Added - OpenRouter async video job lifecycle mock — submit, poll, content download, model listing (#262) - Record-mode live proxying for the OpenRouter video surface; captured videos replay later (#265) ### Changed - Recording proxies now strip aimock-internal control headers on every provider path (#265) ### Fixed - Recorder and fal record paths hardened — timeouts, threshold sanitizing, persist errors (#265) - attw ^0.18 fixes the test:exports crash (#263); OpenAI /v1/videos docs corrected (#264)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #261
Summary
Adds the OpenRouter async video generation surface to aimock:
POST /api/v1/videos— submit; matches video fixtures onprompt/model(+ context scoping), returns{ id, polling_url, status: "pending" }; model-less submits assume the default model (bytedance/seedance-2.0).GET /api/v1/videos/{jobId}— poll;pending → in_progress → completed | faileddriven by the newopenRouterVideoconfig, sharingresolveProgression/threshold semantics withfalQueue. Default 0/0 seeds the job terminal at submit (envelope still reports"pending"for API fidelity). Completed polls carryunsigned_urls+usage.cost; failed polls carry the fixtureerror(default"Video generation failed").GET /api/v1/videos/{jobId}/content— Bearer-auth-gated (401 otherwise); serves fixtureb64bytes or a minimal placeholder MP4, alwaysContent-Type: video/mp4;indexaccepted-but-ignored; never advances job state.GET /api/v1/videos/models— listing synthesized from loaded string-match.modelvideo fixtures, with a built-in default set fallback (warned).Hardening that rode along:
resolveProgression(shared withfalQueue) now sanitizes thresholds — non-finite treated as unset, negatives/fractionals floored/clamped — fixing a latent fal bug where aNaNthreshold stranded jobs short of terminal forever;createServerwarns on invalidfalQueue/openRouterVideothreshold values."internal"journal source; Prometheus path-label templating ({jobId}) to keep metric cardinality bounded; journaled 500s on handler throws.Replay-only for now — record/proxy mode for this surface warns and is tracked as a follow-up. Status polls and the models listing are deliberately served without auth (documented divergence).
Test plan
src/__tests__/openrouter-video.test.ts+ metrics templating pins insrc/__tests__/metrics.test.ts(full suite 3819 passed, drift suite green)tsc --noEmit, prettier, eslint, tsdown build + publint/attw exports all green locally/api/*, OpenAI/v1/videos,modelsvs{jobId})Provenance: implemented TDD-first, then converged through 10 rounds of 7-agent code review (every fix round re-confirmed; out-of-scope findings cataloged for follow-up).