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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathResponse
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. +

+ +
+
+ openrouter-video.test.ts ts +
+
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:

+ + +

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, 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; }