Skip to content

feat: OpenRouter async video generation endpoints (/api/v1/videos job lifecycle)#262

Merged
jpr5 merged 39 commits into
mainfrom
feat/openrouter-video
Jun 10, 2026
Merged

feat: OpenRouter async video generation endpoints (/api/v1/videos job lifecycle)#262
jpr5 merged 39 commits into
mainfrom
feat/openrouter-video

Conversation

@jpr5

@jpr5 jpr5 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Closes #261

Summary

Adds the OpenRouter async video generation surface to aimock:

  • POST /api/v1/videos — submit; matches video fixtures on prompt/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 | failed driven by the new openRouterVideo config, sharing resolveProgression/threshold semantics with falQueue. Default 0/0 seeds the job terminal at submit (envelope still reports "pending" for API fidelity). Completed polls carry unsigned_urls + usage.cost; failed polls carry the fixture error (default "Video generation failed").
  • GET /api/v1/videos/{jobId}/content — Bearer-auth-gated (401 otherwise); serves fixture b64 bytes or a minimal placeholder MP4, always Content-Type: video/mp4; index accepted-but-ignored; never advances job state.
  • GET /api/v1/videos/models — listing synthesized from loaded string-match.model video fixtures, with a built-in default set fallback (warned).

Hardening that rode along:

  • resolveProgression (shared with falQueue) now sanitizes thresholds — non-finite treated as unset, negatives/fractionals floored/clamped — fixing a latent fal bug where a NaN threshold stranded jobs short of terminal forever; createServer warns on invalid falQueue/openRouterVideo threshold values.
  • Per-instance TTL+FIFO-bounded job map (cleared on reset/close), testId-scoped job keys with testId embedded in generated URLs (the SDK fetches them with no aimock-specific headers).
  • Host/x-forwarded-host validation (incl. IPv6 literals and underscore hostnames) so generated URLs can't be path-smuggled; forwarded-proto allowlist.
  • Chaos support with the new "internal" journal source; Prometheus path-label templating ({jobId}) to keep metric cardinality bounded; journaled 500s on handler throws.
  • Careful base64 forensics on content serving (zero-byte, mod-4, length-mismatch warns with pinned false-positive guards).

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

  • 124 new behavioral tests in src/__tests__/openrouter-video.test.ts + metrics templating pins in src/__tests__/metrics.test.ts (full suite 3819 passed, drift suite green)
  • tsc --noEmit, prettier, eslint, tsdown build + publint/attw exports all green locally
  • Red-green discipline per change; routing-collision regressions pinned (Ollama /api/*, OpenAI /v1/videos, models vs {jobId})
  • CI green

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).

jpr5 added 30 commits June 10, 2026 09:29
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"
jpr5 added 7 commits June 10, 2026 12:10
- 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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@262

commit: 0f74b4f

jpr5 added 2 commits June 10, 2026 13:03
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 jpr5 merged commit f4193b5 into main Jun 10, 2026
23 checks passed
@jpr5 jpr5 deleted the feat/openrouter-video branch June 10, 2026 20:13
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.
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support OpenRouter's async video generation endpoints (/api/v1/videos job lifecycle)

1 participant