From 4176fa6d1941a01cc8051d50b23f0a17232d2223 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:29:58 -0700 Subject: [PATCH 01/39] feat: extend video fixture fields and share fal progression resolver Adds optional error/b64/cost to VideoResponse.video, plumbs an openRouterVideo progression config (FalQueueConfig semantics) through MockServerOptions and HandlerDefaults, and exports resolveProgression from fal.ts for reuse by the OpenRouter async video lifecycle handlers. --- src/__tests__/openrouter-video.test.ts | 53 ++++++++++++++++++++++++++ src/fal.ts | 2 +- src/server.ts | 3 ++ src/types.ts | 14 +++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/openrouter-video.test.ts diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts new file mode 100644 index 00000000..52372c7c --- /dev/null +++ b/src/__tests__/openrouter-video.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect, afterEach } from "vitest"; +import { LLMock } from "../llmock.js"; +import { resolveProgression } from "../fal.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, + }); + }); +}); + +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(); + await mock.stop(); + }); +}); diff --git a/src/fal.ts b/src/fal.ts index 1c3c05eb..4f248ecd 100644 --- a/src/fal.ts +++ b/src/fal.ts @@ -165,7 +165,7 @@ export function videoResponseToFalJson(response: VideoResponse): Record ChatCompletionRequest; falQueue?: FalQueueConfig; + openRouterVideo?: FalQueueConfig; } From dbf15f6aa5fe42447363f43295f0badbc2d15998 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:32:58 -0700 Subject: [PATCH 02/39] feat: OpenRouter async video job map and submit endpoint Adds src/openrouter-video.ts with a TTL+bounded per-testId job map (mirroring FalQueueStateMap) and handleOpenRouterVideoCreate for POST /api/v1/videos: JSON-only body validation, fixture matching via the video endpoint type, chaos/strict-503/OpenRouter-shaped 404 and journal coverage on every path, and a {id, polling_url, status: pending} submit envelope. Job state is reset by the control API, LLMock.reset(), and server close. --- src/__tests__/openrouter-video.test.ts | 143 ++++++++++ src/llmock.ts | 1 + src/openrouter-video.ts | 353 +++++++++++++++++++++++++ src/server.ts | 66 ++++- 4 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 src/openrouter-video.ts diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 52372c7c..b2ffdb5b 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -51,3 +51,146 @@ describe("VideoResponse extended fields", () => { await mock.stop(); }); }); + +// ─── Task 2: POST /api/v1/videos (submit) ─────────────────────────────────── + +describe("POST /api/v1/videos (OpenRouter submit)", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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("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); + }); +}); 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/openrouter-video.ts b/src/openrouter-video.ts new file mode 100644 index 00000000..fccede6a --- /dev/null +++ b/src/openrouter-video.ts @@ -0,0 +1,353 @@ +import type * as http from "node:http"; +import crypto from "node:crypto"; +import type { ChatCompletionRequest, Fixture, HandlerDefaults, VideoResponse } from "./types.js"; +import { + isVideoResponse, + isErrorResponse, + serializeErrorResponse, + flattenHeaders, + getTestId, + resolveResponse, + resolveStrictMode, + strictOverrideField, + getContext, + strictNoMatchMessage, + strictNoMatchLogLine, +} from "./helpers.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) ────────────────────────────────── + +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"]; + createdAt: number; +} + +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. + * 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 ──────────────────────────────────────────────────────── + +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); + } +} + +function requestBase(req: http.IncomingMessage): string { + return `http://${req.headers.host ?? "localhost"}`; +} + +// ─── 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; + } + + if (!videoReq.prompt) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 400, fixture: null }, + }); + writeErrorResponse( + res, + 400, + JSON.stringify({ + error: { message: "Missing required parameter: 'prompt'", type: "invalid_request_error" }, + }), + ); + return; + } + + const syntheticReq: ChatCompletionRequest = { + 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) { + journal.incrementFixtureMatchCount(fixture, fixtures, testId); + defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`); + } else { + defaults.logger.debug(`No fixture matched for request`); + } + + if ( + applyChaos( + res, + fixture, + defaults.chaos, + req.headers, + journal, + { method, path, headers: flattenHeaders(req.headers), body: syntheticReq }, + fixture ? "fixture" : "proxy", + defaults.registry, + defaults.logger, + ) + ) + return; + + if (!fixture) { + 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 }, + }); + + const jobId = crypto.randomUUID(); + const progression = resolveProgression(defaults.openRouterVideo); + const job: OpenRouterVideoJob = { + jobId, + status: "pending", + pollCount: 0, + pollsBeforeInProgress: progression.pollsBeforeInProgress, + pollsBeforeCompleted: progression.pollsBeforeCompleted, + video: response.video, + createdAt: Date.now(), + }; + // Default 0/0 progression reaches the terminal status on the first poll — + // seed terminal directly (mirrors fal's COMPLETED-on-submit initial 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)}/api/v1/videos/${jobId}`, + status: "pending", + }), + ); +} diff --git a/src/server.ts b/src/server.ts index 5af0362a..d597e35a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,6 +57,7 @@ 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, 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"; @@ -80,6 +81,7 @@ export interface ServerInstance { url: string; defaults: HandlerDefaults; videoStates: VideoStateMap; + openRouterVideoJobs: OpenRouterVideoJobMap; } const COMPLETIONS_PATH = "/v1/chat/completions"; @@ -172,6 +174,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. +const OPENROUTER_VIDEOS_PATH = "/api/v1/videos"; +const OPENROUTER_VIDEO_MODELS_PATH = "/api/v1/videos/models"; +const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; +const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; + const HEALTH_PATH = "/health"; const READY_PATH = "/ready"; const MODELS_PATH = "/v1/models"; @@ -226,11 +236,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 +263,7 @@ async function handleControlAPI( fixtures: Fixture[], journal: Journal, videoStates: VideoStateMap, + openRouterVideoJobs: OpenRouterVideoJobMap, defaults: HandlerDefaults, ): Promise { if (!pathname.startsWith(CONTROL_PREFIX)) return false; @@ -333,7 +346,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 +363,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( @@ -1105,6 +1118,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) { @@ -1178,7 +1192,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; } @@ -1283,6 +1306,40 @@ 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 + // would otherwise swallow /models and /content). + + // POST /api/v1/videos — submit a video generation job + if (pathname === OPENROUTER_VIDEOS_PATH && req.method === "POST") { + 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"; + if (!res.headersSent) { + 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; @@ -2448,6 +2505,7 @@ export async function createServer( ws.close(1001, "Server shutting down"); } activeConnections.clear(); + openRouterVideoJobs.clear(); originalClose(callback); return this; } as typeof server.close; @@ -2469,7 +2527,7 @@ export async function createServer( } } - resolve({ server, journal, url, defaults, videoStates }); + resolve({ server, journal, url, defaults, videoStates, openRouterVideoJobs }); }); }); } From 2b18662775d5c4be1c158fb7f149cbee03952c79 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:34:11 -0700 Subject: [PATCH 03/39] feat: OpenRouter video status poll with staged progression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds handleOpenRouterVideoStatus for GET /api/v1/videos/{jobId}: advances pending → in_progress → completed|failed per the openRouterVideo poll thresholds (fal advanceJob semantics — equal thresholds still spend one poll in in_progress; default 0/0 reaches the terminal status on the first poll). Completed polls add unsigned_urls and usage.cost; failed polls add the fixture's error message (default "Video generation failed"). Chaos, 404, and journaling on every path. --- src/__tests__/openrouter-video.test.ts | 168 +++++++++++++++++++++++++ src/openrouter-video.ts | 71 +++++++++++ src/server.ts | 21 +++- 3 files changed, 259 insertions(+), 1 deletion(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index b2ffdb5b..1035592e 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -178,6 +178,174 @@ describe("POST /api/v1/videos (OpenRouter submit)", () => { 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({ diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index fccede6a..8fc9f903 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -134,6 +134,77 @@ function requestBase(req: http.IncomingMessage): string { return `http://${req.headers.host ?? "localhost"}`; } +// ─── 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)}/api/v1/videos/${job.jobId}/content?index=0`]; + body.usage = { cost: job.video.cost ?? 0 }; + } else if (job.status === "failed") { + body.error = job.video.error ?? "Video generation failed"; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + // ─── POST /api/v1/videos — submit ─────────────────────────────────────────── export async function handleOpenRouterVideoCreate( diff --git a/src/server.ts b/src/server.ts index d597e35a..25982fa2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,7 +57,11 @@ 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, OpenRouterVideoJobMap } from "./openrouter-video.js"; +import { + handleOpenRouterVideoCreate, + handleOpenRouterVideoStatus, + 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"; @@ -1311,6 +1315,21 @@ export async function createServer( // content RE → models exact → status RE → submit exact (the status RE // would otherwise swallow /models and /content). + // GET /api/v1/videos/{jobId} — poll job status + const openRouterVideoStatusMatch = pathname.match(OPENROUTER_VIDEO_STATUS_RE); + if (openRouterVideoStatusMatch && req.method === "GET") { + handleOpenRouterVideoStatus( + req, + res, + openRouterVideoStatusMatch[1], + journal, + defaults, + setCorsHeaders, + openRouterVideoJobs, + ); + return; + } + // POST /api/v1/videos — submit a video generation job if (pathname === OPENROUTER_VIDEOS_PATH && req.method === "POST") { try { From 6bc32ff1cbabf802e22000ce897062bc213adccb Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:35:48 -0700 Subject: [PATCH 04/39] feat: OpenRouter video content download endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds handleOpenRouterVideoContent for GET /api/v1/videos/{jobId}/content: 401 OpenRouter-shaped error without an Authorization header, 404 for unknown jobs, JSON 400 for non-completed jobs, and the fixture's base64 bytes (or a built-in 24-byte ftyp placeholder) served as Content-Type video/mp4 — always, including when the client sends Accept: application/octet-stream, matching the live endpoint. Chaos and journaling on every path. --- src/__tests__/openrouter-video.test.ts | 131 +++++++++++++++++++++++++ src/openrouter-video.ts | 114 +++++++++++++++++++++ src/server.ts | 16 +++ 3 files changed, 261 insertions(+) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 1035592e..41983b61 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -362,3 +362,134 @@ describe("POST /api/v1/videos (OpenRouter submit)", () => { expect(res.status).toBe(500); }); }); + +// ─── Task 4: GET /api/v1/videos/{jobId}/content — download ────────────────── + +describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + async function submitJob(prompt: string): Promise { + 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("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"); + // Reach completed via a status poll first (lifecycle order). + await fetch(`${mock.url}/api/v1/videos/${id}`); + + 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); + }); + + 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); + // 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"); + }); + +}); diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 8fc9f903..965e72d2 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -205,6 +205,120 @@ export function handleOpenRouterVideoStatus( 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, +]); + +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. + if (!req.headers.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; + } + + const bytes = job.video.b64 ? Buffer.from(job.video.b64, "base64") : 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); +} + // ─── POST /api/v1/videos — submit ─────────────────────────────────────────── export async function handleOpenRouterVideoCreate( diff --git a/src/server.ts b/src/server.ts index 25982fa2..fa267b12 100644 --- a/src/server.ts +++ b/src/server.ts @@ -60,6 +60,7 @@ import { handleVideoCreate, handleVideoStatus, VideoStateMap } from "./video.js" import { handleOpenRouterVideoCreate, handleOpenRouterVideoStatus, + handleOpenRouterVideoContent, OpenRouterVideoJobMap, } from "./openrouter-video.js"; import { handleElevenLabsAudio, handleElevenLabsTTS } from "./elevenlabs-audio.js"; @@ -1315,6 +1316,21 @@ export async function createServer( // content RE → models exact → status RE → submit exact (the status RE // would otherwise swallow /models and /content). + // GET /api/v1/videos/{jobId}/content — download the generated bytes + const openRouterVideoContentMatch = pathname.match(OPENROUTER_VIDEO_CONTENT_RE); + if (openRouterVideoContentMatch && req.method === "GET") { + handleOpenRouterVideoContent( + req, + res, + openRouterVideoContentMatch[1], + journal, + defaults, + setCorsHeaders, + openRouterVideoJobs, + ); + return; + } + // GET /api/v1/videos/{jobId} — poll job status const openRouterVideoStatusMatch = pathname.match(OPENROUTER_VIDEO_STATUS_RE); if (openRouterVideoStatusMatch && req.method === "GET") { From f01fde240e6136e758dc13e76f819dc3ac4c046e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:37:19 -0700 Subject: [PATCH 05/39] feat: OpenRouter video model listing endpoint Adds handleOpenRouterVideoModels for GET /api/v1/videos/models, synthesizing {data:[...]} entries from loaded video-endpoint fixtures with string models (mirroring the Ollama /api/tags synthesis) and falling back to a default model set when none are loaded. Dispatched before the status RE so the literal models segment is not captured as a job id, with a regression test pinning that ordering. --- src/__tests__/openrouter-video.test.ts | 71 +++++++++++++++++++++++++- src/openrouter-video.ts | 57 +++++++++++++++++++++ src/server.ts | 8 +++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 41983b61..732c192e 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -426,7 +426,7 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { headers: { Authorization: "Bearer test" }, }); expect(res.status).toBe(400); - expect((res.headers.get("content-type") ?? "")).toContain("application/json"); + expect(res.headers.get("content-type") ?? "").toContain("application/json"); const data = await res.json(); expect(data.error.message).toContain("not completed"); }); @@ -491,5 +491,74 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { 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; + + afterEach(async () => { + await mock?.stop(); + }); + 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); + expect(ids).toEqual(["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("returns sensible defaults when no video fixtures are loaded", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + + const res = await fetch(`${mock.url}/api/v1/videos/models`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.data)).toBe(true); + expect(data.data.length).toBeGreaterThan(0); + expect(typeof data.data[0].id).toBe("string"); + }); + + test("models path is not swallowed by the status handler", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + + // If the status RE matched first, this would be a 404 "Video job models + // not found" — it must instead return the model listing envelope. + 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(Array.isArray(data.data)).toBe(true); + }); }); diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 965e72d2..412e8eea 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -319,6 +319,63 @@ export function handleOpenRouterVideoContent( 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, + setCorsHeaders: (res: http.ServerResponse) => void, +): void { + setCorsHeaders(res); + const path = req.url ?? "/api/v1/videos/models"; + const method = req.method ?? "GET"; + + const modelIds = new Set(); + for (const f of fixtures) { + if (f.match.endpoint === "video" && typeof f.match.model === "string") { + modelIds.add(f.match.model); + } + } + 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( diff --git a/src/server.ts b/src/server.ts index fa267b12..292659fe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,6 +61,7 @@ import { handleOpenRouterVideoCreate, handleOpenRouterVideoStatus, handleOpenRouterVideoContent, + handleOpenRouterVideoModels, OpenRouterVideoJobMap, } from "./openrouter-video.js"; import { handleElevenLabsAudio, handleElevenLabsTTS } from "./elevenlabs-audio.js"; @@ -1331,6 +1332,13 @@ export async function createServer( 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") { + handleOpenRouterVideoModels(req, res, fixtures, journal, setCorsHeaders); + return; + } + // GET /api/v1/videos/{jobId} — poll job status const openRouterVideoStatusMatch = pathname.match(OPENROUTER_VIDEO_STATUS_RE); if (openRouterVideoStatusMatch && req.method === "GET") { From 908372682bfe9492fb427731f85e1a9a3f951e72 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:38:34 -0700 Subject: [PATCH 06/39] test: OpenRouter video cross-cutting conformance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers strict-mode 503 with the shared sequence/turn-skip matcher, journal coverage across every lifecycle path, chaos drop injection on submit/status/content, full submit→poll→download and failure/auth lifecycles, and routing collision regressions (Ollama /api/chat + /api/embeddings, OpenAI /v1/videos, and /api/v1/videos/models vs the status RE). --- src/__tests__/openrouter-video.test.ts | 312 +++++++++++++++++++++++++ 1 file changed, 312 insertions(+) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 732c192e..9cac33d4 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -562,3 +562,315 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { expect(Array.isArray(data.data)).toBe(true); }); }); + +// ─── Task 6: cross-cutting conformance ────────────────────────────────────── + +describe("OpenRouter video — strict mode diagnostics", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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; + + afterEach(async () => { + await mock?.stop(); + }); + + 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 }; + await fetch(`${mock.url}/api/v1/videos/${id}`); + await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, { + headers: { Authorization: "Bearer test" }, + }); + await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`); // 401 + await fetch(`${mock.url}/api/v1/videos/unknown-job`); // 404 + await fetch(`${mock.url}/api/v1/videos/models`); + await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "no match here" }), + }); // 404 no-match + await fetch(`${mock.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{nope", + }); // 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`); + }); +}); + +describe("OpenRouter video — chaos injection", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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 — full lifecycle integration", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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 }; + await fetch(`${mock.url}/api/v1/videos/${id}`); // completed + + 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); + }); +}); + +describe("OpenRouter video — routing collision regression", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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"); + }); +}); From 2060053520ac7f9b9dfb486d6c30db07b345473e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:38:57 -0700 Subject: [PATCH 07/39] docs: changelog entry for the OpenRouter async video lifecycle mock --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf8910b7..e8b336e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **OpenRouter async video lifecycle** — mocks OpenRouter's dedicated video-generation job API alongside the existing OpenAI-shaped `/v1/videos` handler. `POST /api/v1/videos` matches fixtures on `prompt`/`model` (endpoint `video`) and returns a `{ id, polling_url, status: "pending" }` job envelope; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`); `GET /api/v1/videos/models` synthesizes the video-model listing from loaded video fixtures. Video fixtures gain optional `error`, `b64`, and `cost` fields. Replay/strict-only for now — record-mode proxying for this surface is a follow-up. + ## [1.30.0] - 2026-06-09 ### Added From a0859f643b1ebc025ceaacd2ea1b11608a8bb67e Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:53:52 -0700 Subject: [PATCH 08/39] fix: validate OpenRouter video request bodies and Bearer auth scheme Non-object/null JSON bodies, non-string prompt, and non-string model now return 400 invalid_request_error instead of leaking a raw TypeError 500 or falling through to fixture matching. The /content endpoint now requires the Bearer scheme (not just header presence), matching the documented behavior. --- src/__tests__/openrouter-video.test.ts | 122 +++++++++++++++++++++++++ src/openrouter-video.ts | 49 +++++++++- 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 9cac33d4..0984b2e1 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -803,6 +803,128 @@ describe("OpenRouter video — full lifecycle integration", () => { }); }); +// ─── CR findings: input validation (400, not 500/mismatch) ───────────────── + +describe("OpenRouter video — request body validation", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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"); + expect(data.error.message).toContain("prompt"); + }); + + 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"); + }); +}); + +// ─── CR findings: Bearer scheme validation on /content ───────────────────── + +describe("OpenRouter video — Bearer scheme validation", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + async function completedJob(prompt: string): Promise { + 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 }; + await fetch(`${mock.url}/api/v1/videos/${id}`); // advance to completed + 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); + }); +}); + describe("OpenRouter video — routing collision regression", () => { let mock: LLMock; diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 412e8eea..5b16dc91 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -246,7 +246,7 @@ export function handleOpenRouterVideoContent( // The real endpoint requires Bearer auth even though the unsigned URL is // otherwise self-contained — the @openrouter/sdk fetches it with the key. - if (!req.headers.authorization) { + if (!req.headers.authorization?.startsWith("Bearer ")) { journal.add({ method, path, @@ -418,7 +418,31 @@ export async function handleOpenRouterVideoCreate( return; } - if (!videoReq.prompt) { + // 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; + } + + if (typeof videoReq.prompt !== "string" || !videoReq.prompt) { journal.add({ method, path, @@ -436,6 +460,27 @@ export async function handleOpenRouterVideoCreate( return; } + if (videoReq.model !== undefined && typeof videoReq.model !== "string") { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 400, fixture: null }, + }); + writeErrorResponse( + res, + 400, + JSON.stringify({ + error: { + message: "Invalid type for parameter: 'model' must be a string", + type: "invalid_request_error", + }, + }), + ); + return; + } + const syntheticReq: ChatCompletionRequest = { model: videoReq.model ?? DEFAULT_OPENROUTER_VIDEO_MODEL, messages: [{ role: "user", content: videoReq.prompt }], From 81647d421fc03af67e4a8eb4cac73474e400dc78 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:55:08 -0700 Subject: [PATCH 09/39] fix: scope OpenRouter video URLs by testId and honor x-forwarded-proto polling_url and unsigned_urls now embed a testId query param when the request runs under a non-default test scope, since the @openrouter/sdk fetches these URLs bare (no custom headers) and would otherwise resolve the wrong job map. Generated URLs also honor x-forwarded-proto so they survive TLS-terminating proxies. --- src/__tests__/openrouter-video.test.ts | 150 +++++++++++++++++++++++++ src/openrouter-video.ts | 25 ++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 0984b2e1..c5f950db 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -871,6 +871,156 @@ describe("OpenRouter video — request body validation", () => { }); }); +// ─── CR findings: testId embedded in generated URLs ──────────────────────── + +describe("OpenRouter video — testId scoping of generated URLs", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + function addFixtureFor(prompt: string): void { + 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 in generated URLs ────────────────────── + +describe("OpenRouter video — x-forwarded-proto scheme", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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); + }); +}); + // ─── CR findings: Bearer scheme validation on /content ───────────────────── describe("OpenRouter video — Bearer scheme validation", () => { diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 5b16dc91..4c36f188 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -14,6 +14,7 @@ import { 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"; @@ -131,7 +132,23 @@ function advanceJob(job: OpenRouterVideoJob): void { } function requestBase(req: http.IncomingMessage): string { - return `http://${req.headers.host ?? "localhost"}`; + // Honor x-forwarded-proto so generated URLs survive a TLS-terminating + // proxy in front of the mock. First value wins on comma-joined lists. + const fwdProto = req.headers["x-forwarded-proto"]; + const protoRaw = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; + const proto = protoRaw?.split(",")[0]?.trim() || "http"; + return `${proto}://${req.headers.host ?? "localhost"}`; +} + +/** + * Query-string suffix embedding the request's testId into generated URLs + * (polling_url, unsigned_urls). The @openrouter/sdk fetches these URLs bare — + * no custom headers — 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)}`; } // ─── GET /api/v1/videos/{jobId} — status poll ─────────────────────────────── @@ -195,7 +212,9 @@ export function handleOpenRouterVideoStatus( const body: Record = { id: job.jobId, status: job.status }; if (job.status === "completed") { - body.unsigned_urls = [`${requestBase(req)}/api/v1/videos/${job.jobId}/content?index=0`]; + body.unsigned_urls = [ + `${requestBase(req)}/api/v1/videos/${job.jobId}/content?index=0${testIdSuffix(testId, "&")}`, + ]; body.usage = { cost: job.video.cost ?? 0 }; } else if (job.status === "failed") { body.error = job.video.error ?? "Video generation failed"; @@ -633,7 +652,7 @@ export async function handleOpenRouterVideoCreate( res.end( JSON.stringify({ id: jobId, - polling_url: `${requestBase(req)}/api/v1/videos/${jobId}`, + polling_url: `${requestBase(req)}/api/v1/videos/${jobId}${testIdSuffix(testId, "?")}`, status: "pending", }), ); From d5d1172238bab4c17f395cab379d2a66aa897c9c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:56:17 -0700 Subject: [PATCH 10/39] fix: correct chaos source label and cover the video models route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-fixture submit path journaled chaos with source "proxy" but this surface never proxies — it is now "internal" per the JournalEntry contract. /api/v1/videos/models now threads HandlerDefaults and rolls chaos like the status handler, so drop/malformed/disconnect headers take effect there too. --- src/__tests__/openrouter-video.test.ts | 42 ++++++++++++++++++++++++++ src/openrouter-video.ts | 20 +++++++++++- src/server.ts | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index c5f950db..10e125fc 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -696,6 +696,48 @@ describe("OpenRouter video — chaos injection", () => { }); }); +describe("OpenRouter video — chaos source label and models route", () => { + let mock: LLMock; + + afterEach(async () => { + await mock?.stop(); + }); + + 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); + }); +}); + describe("OpenRouter video — full lifecycle integration", () => { let mock: LLMock; diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 4c36f188..9746ec06 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -369,12 +369,28 @@ export function handleOpenRouterVideoModels( 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(); for (const f of fixtures) { if (f.match.endpoint === "video" && typeof f.match.model === "string") { @@ -530,7 +546,9 @@ export async function handleOpenRouterVideoCreate( req.headers, journal, { method, path, headers: flattenHeaders(req.headers), body: syntheticReq }, - fixture ? "fixture" : "proxy", + // This surface never proxies (replay/strict-only) — the no-fixture + // chaos path is still served internally. + fixture ? "fixture" : "internal", defaults.registry, defaults.logger, ) diff --git a/src/server.ts b/src/server.ts index 292659fe..0d0efc57 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1335,7 +1335,7 @@ export async function createServer( // 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") { - handleOpenRouterVideoModels(req, res, fixtures, journal, setCorsHeaders); + handleOpenRouterVideoModels(req, res, fixtures, journal, defaults, setCorsHeaders); return; } From b13b26b5410e2b922fbd8f41f6f7434d35056b99 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:57:31 -0700 Subject: [PATCH 11/39] fix: surface silent coercions and gaps on the OpenRouter video path Warn when a "processing" fixture is coerced to completed, when a no-match occurs while record mode is configured (recording is not wired for this surface), and when a fixture's non-empty b64 decodes to zero bytes. The no-match debug line now includes the model and a prompt snippet, mirroring handleCompletions. --- src/__tests__/openrouter-video.test.ts | 116 ++++++++++++++++++++++++- src/openrouter-video.ts | 40 ++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 10e125fc..df8c3303 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, afterEach } from "vitest"; +import { describe, test, expect, afterEach, vi } from "vitest"; import { LLMock } from "../llmock.js"; import { resolveProgression } from "../fal.js"; import type { VideoResponse } from "../types.js"; @@ -738,6 +738,120 @@ describe("OpenRouter video — chaos source label and models route", () => { }); }); +// ─── CR findings: logger observability ────────────────────────────────────── + +describe("OpenRouter video — logger observability", () => { + let mock: LLMock; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + }); + + 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: "sk-test" } }, + }); + 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 }; + await fetch(`${mock.url}/api/v1/videos/${id}`); // advance to 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).toBe(0); // the decode is served as-is, just warned about + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).toBe(true); + }); +}); + describe("OpenRouter video — full lifecycle integration", () => { let mock: LLMock; diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 9746ec06..b83924cc 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -109,6 +109,13 @@ export class OpenRouterVideoJobMap { // ─── 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"; } @@ -321,7 +328,19 @@ export function handleOpenRouterVideoContent( return; } - const bytes = job.video.b64 ? Buffer.from(job.video.b64, "base64") : PLACEHOLDER_MP4; + let bytes: Buffer; + if (job.video.b64) { + bytes = Buffer.from(job.video.b64, "base64"); + if (bytes.length === 0) { + // Non-empty b64 that decodes to nothing is almost certainly a corrupt + // fixture. Serve the (empty) decode as-is, but make the cause visible. + defaults.logger.warn( + `Video fixture b64 for job ${jobId} decoded to 0 bytes — likely corrupt base64`, + ); + } + } else { + bytes = PLACEHOLDER_MP4; + } journal.add({ method, @@ -535,7 +554,10 @@ export async function handleOpenRouterVideoCreate( journal.incrementFixtureMatchCount(fixture, fixtures, testId); defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`); } else { - defaults.logger.debug(`No fixture matched for request`); + const snippet = videoReq.prompt.slice(0, 80); + defaults.logger.debug( + `No fixture matched for request (model=${syntheticReq.model}, msg="${snippet}")`, + ); } if ( @@ -556,6 +578,11 @@ export async function handleOpenRouterVideoCreate( 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); @@ -647,6 +674,15 @@ export async function handleOpenRouterVideoCreate( response: { status: 200, fixture }, }); + // A fixture authored with status "processing" has no terminal state to + // converge on — terminalStatus coerces it to completed. Keep the behavior + // (jobs always terminate) but surface the coercion. + if (response.video.status === "processing") { + defaults.logger.warn( + `Video fixture has status "processing" — treated as completed for /api/v1/videos jobs`, + ); + } + const jobId = crypto.randomUUID(); const progression = resolveProgression(defaults.openRouterVideo); const job: OpenRouterVideoJob = { From e3ed14c16a1424d1483dec78074d8d8b08dc060f Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:58:54 -0700 Subject: [PATCH 12/39] chore: clear videoStates on server close and drop dead job createdAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close override cleared openRouterVideoJobs but left the sibling per-instance videoStates map populated; both are now cleared symmetrically. OpenRouterVideoJob.createdAt was never read — TTL eviction uses the entry wrapper's own createdAt. --- src/__tests__/openrouter-video.test.ts | 39 ++++++++++++++++++++++++++ src/openrouter-video.ts | 2 -- src/server.ts | 1 + 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index df8c3303..2a801e42 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, afterEach, vi } from "vitest"; import { LLMock } from "../llmock.js"; +import { createServer } from "../server.js"; import { resolveProgression } from "../fal.js"; import type { VideoResponse } from "../types.js"; import { SKIPPED_BY_STATE_RE } from "./helpers/strict-matchers.js"; @@ -1231,6 +1232,44 @@ describe("OpenRouter video — Bearer scheme validation", () => { }); }); +// ─── 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 }); + + // Populate both per-instance maps. + await fetch(`${instance.url}/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "sora-2", prompt: "openai close" }), + }); + await fetch(`${instance.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "openrouter close" }), + }); + expect(instance.videoStates.size).toBeGreaterThan(0); + expect(instance.openRouterVideoJobs.size).toBeGreaterThan(0); + + await new Promise((resolve, reject) => { + instance.server.close((err) => (err ? reject(err) : resolve())); + }); + expect(instance.openRouterVideoJobs.size).toBe(0); + expect(instance.videoStates.size).toBe(0); + }); +}); + describe("OpenRouter video — routing collision regression", () => { let mock: LLMock; diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index b83924cc..d88e40ec 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -55,7 +55,6 @@ interface OpenRouterVideoJob { pollsBeforeCompleted: number; /** The matched fixture's video object (terminal status, bytes, cost, error). */ video: VideoResponse["video"]; - createdAt: number; } interface OpenRouterVideoEntry { @@ -692,7 +691,6 @@ export async function handleOpenRouterVideoCreate( pollsBeforeInProgress: progression.pollsBeforeInProgress, pollsBeforeCompleted: progression.pollsBeforeCompleted, video: response.video, - createdAt: Date.now(), }; // Default 0/0 progression reaches the terminal status on the first poll — // seed terminal directly (mirrors fal's COMPLETED-on-submit initial status); diff --git a/src/server.ts b/src/server.ts index 0d0efc57..035137a8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2548,6 +2548,7 @@ export async function createServer( ws.close(1001, "Server shutting down"); } activeConnections.clear(); + videoStates.clear(); openRouterVideoJobs.clear(); originalClose(callback); return this; From 303b8a55f2aa25d4ed2789706f6f9a68b038fc70 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:57:05 -0700 Subject: [PATCH 13/39] docs: tighten OpenRouter video changelog entry and routing/content comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG [Unreleased]: note the shared endpoint:"video" fixture pool with the OpenAI /v1/videos handler, the built-in default model fallback when no video fixtures are loaded, the accepted-but-ignored index query param, the re-scoped "same poll-threshold semantics as falQueue" wording, and that the content endpoint does not advance job state (URLs come from a completed poll — API fidelity, diverging from fal's advance-on-result). server.ts: the dispatch-order comment claimed the status RE would swallow both /models and /content; only /models is at risk (the content path's extra segment can never match), matching the route-constant comment above. openrouter-video.ts: document on the content handler that index is ignored and that fetching content deliberately does not advance job state. --- CHANGELOG.md | 2 +- src/openrouter-video.ts | 4 ++++ src/server.ts | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b336e4..6843315f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **OpenRouter async video lifecycle** — mocks OpenRouter's dedicated video-generation job API alongside the existing OpenAI-shaped `/v1/videos` handler. `POST /api/v1/videos` matches fixtures on `prompt`/`model` (endpoint `video`) and returns a `{ id, polling_url, status: "pending" }` job envelope; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`); `GET /api/v1/videos/models` synthesizes the video-model listing from loaded video fixtures. Video fixtures gain optional `error`, `b64`, and `cost` fields. Replay/strict-only for now — record-mode proxying for this surface is a follow-up. +- **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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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, falling back to a built-in default model set when no video fixtures are loaded. Video fixtures gain optional `error`, `b64`, and `cost` fields. Replay/strict-only for now — record-mode proxying for this surface is a follow-up. ## [1.30.0] - 2026-06-09 diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index d88e40ec..c80bbaf3 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -241,6 +241,10 @@ const PLACEHOLDER_MP4 = Buffer.from([ 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, diff --git a/src/server.ts b/src/server.ts index 035137a8..edce4010 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1314,8 +1314,9 @@ export async function createServer( // 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 - // would otherwise swallow /models and /content). + // 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); From e71d3c4a9bc9c3b0c83a1282d06aa705ff44e642 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 09:57:16 -0700 Subject: [PATCH 14/39] test: hygiene fixes and coverage adds for the OpenRouter video surface Hygiene: stop the options-acceptance LLMock in a finally block; consume the unread fetch bodies in the journal-coverage and auth-lifecycle tests; assert the error type/message on the non-video-fixture 500; merge the duplicate models-route tests and assert the status-handler 404 shape is specifically absent; make the fixture-derived models assertion order-insensitive; declare the mock?.stop()-guarded locals as LLMock | undefined; fix the stale "reach completed via a status poll" comment (default 0/0 seeds the job terminal at submit). Coverage (pins existing behavior): reset plumbing via both POST /__aimock/reset/fixtures and LLMock.reset() (old jobId polls 404); OpenRouterVideoJobMap unit tests for lazy 1h-TTL eviction on get (fake timers) and FIFO eviction past the 10k capacity; completed-only progression config ({pollsBeforeCompleted} alone) passing through in_progress; X-AIMock-Strict header override turning a no-match 404 into a 503 on a strict-off server; Content-Length asserted on both b64 and placeholder downloads. --- src/__tests__/openrouter-video.test.ts | 271 +++++++++++++++++++++---- 1 file changed, 227 insertions(+), 44 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 2a801e42..468ae6c9 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, afterEach, vi } from "vitest"; import { LLMock } from "../llmock.js"; import { createServer } from "../server.js"; import { resolveProgression } from "../fal.js"; +import { OpenRouterVideoJobMap } from "../openrouter-video.js"; import type { VideoResponse } from "../types.js"; import { SKIPPED_BY_STATE_RE } from "./helpers/strict-matchers.js"; @@ -49,17 +50,22 @@ describe("VideoResponse extended fields", () => { openRouterVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 }, }); await mock.start(); - await mock.stop(); + 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; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("fixture match returns {id, polling_url, status: pending}", async () => { @@ -361,19 +367,24 @@ describe("POST /api/v1/videos (OpenRouter submit)", () => { 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; + 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" }, @@ -443,14 +454,17 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { }); await mock.start(); const id = await submitJob("bytes"); - // Reach completed via a status poll first (lifecycle order). - await fetch(`${mock.url}/api/v1/videos/${id}`); + // 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); }); @@ -471,6 +485,7 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { 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"); }); @@ -497,10 +512,11 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { // ─── Task 5: GET /api/v1/videos/models — model listing ────────────────────── describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("synthesizes the listing from video fixtures with string models", async () => { @@ -529,7 +545,9 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { expect(res.status).toBe(200); const data = await res.json(); const ids = data.data.map((m: { id: string }) => m.id); - expect(ids).toEqual(["bytedance/seedance-2.0", "openai/sora-2"]); + // 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); @@ -538,39 +556,33 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { } }); - test("returns sensible defaults when no video fixtures are loaded", async () => { + 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("models path is not swallowed by the status handler", async () => { - mock = new LLMock({ port: 0 }); - await mock.start(); - - // If the status RE matched first, this would be a 404 "Video job models - // not found" — it must instead return the model listing envelope. - 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(Array.isArray(data.data)).toBe(true); - }); }); // ─── Task 6: cross-cutting conformance ────────────────────────────────────── describe("OpenRouter video — strict mode diagnostics", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("strict 503 reports sequence/turn skip via shared matcher", async () => { @@ -600,10 +612,11 @@ describe("OpenRouter video — strict mode diagnostics", () => { }); describe("OpenRouter video — journal coverage", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("every lifecycle path journals an entry", async () => { @@ -620,23 +633,30 @@ describe("OpenRouter video — journal coverage", () => { body: JSON.stringify({ model: "m/v", prompt: "journal me" }), }); const { id } = (await submit.json()) as { id: string }; - await fetch(`${mock.url}/api/v1/videos/${id}`); - await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, { - headers: { Authorization: "Bearer test" }, - }); - await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`); // 401 - await fetch(`${mock.url}/api/v1/videos/unknown-job`); // 404 - await fetch(`${mock.url}/api/v1/videos/models`); - await fetch(`${mock.url}/api/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "m/v", prompt: "no match here" }), - }); // 404 no-match - await fetch(`${mock.url}/api/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{nope", - }); // 400 malformed + // 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}`); @@ -652,10 +672,11 @@ describe("OpenRouter video — journal coverage", () => { }); describe("OpenRouter video — chaos injection", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("chaos drop header applies to submit, status, and content", async () => { @@ -854,10 +875,11 @@ describe("OpenRouter video — logger observability", () => { }); describe("OpenRouter video — full lifecycle integration", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("submit → pending → in_progress → completed → download", async () => { @@ -947,7 +969,7 @@ describe("OpenRouter video — full lifecycle integration", () => { body: JSON.stringify({ model: "m/v", prompt: "auth lifecycle" }), }); const { id } = (await submit.json()) as { id: string }; - await fetch(`${mock.url}/api/v1/videos/${id}`); // completed + await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed const unauthorized = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`); expect(unauthorized.status).toBe(401); @@ -1271,10 +1293,11 @@ describe("server close clears video job state", () => { }); describe("OpenRouter video — routing collision regression", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("Ollama /api/chat still routes to the Ollama handler", async () => { @@ -1341,3 +1364,163 @@ describe("OpenRouter video — routing collision regression", () => { 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 }, + createdAt: Date.now(), + }; + } + + 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 = 10_000; // OPENROUTER_VIDEO_MAX_ENTRIES (hardcoded, not exported) + 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("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"); + }); +}); From 06cc980c9c48380827bc2aa5f82820423903bf11 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:19:28 -0700 Subject: [PATCH 15/39] fix: template video job paths in Prometheus metric labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePathLabel had no rule for the new dynamic video routes, so every GET /api/v1/videos/ and //content minted a unique path label on aimock_requests_total and aimock_request_duration_seconds — unbounded label cardinality. Map them to /api/v1/videos/{jobId} and /api/v1/videos/{jobId}/content, keeping the static models listing distinct, and cover the pre-existing OpenAI sibling /v1/videos/{id} as well. --- src/__tests__/metrics.test.ts | 61 ++++++++++++++++++++++++++++++++++- src/metrics.ts | 18 +++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts index b3be3e4a..61439e17 100644 --- a/src/__tests__/metrics.test.ts +++ b/src/__tests__/metrics.test.ts @@ -49,9 +49,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 +434,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", () => { @@ -630,6 +655,40 @@ 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`); + expect(res.body).toContain('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" } }, diff --git a/src/metrics.ts b/src/metrics.ts index e6b63fd1..cd24943a 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -222,6 +222,9 @@ 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\/([^:]+):(.+)$/; +const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; +const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; +const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; /** * Normalize parametric API paths to route patterns for use as metric labels. @@ -257,6 +260,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; } From 666b06310bd15c6aedcce0a33c3f63d24428dd40 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:19:39 -0700 Subject: [PATCH 16/39] fix: harden OpenRouter video validation, auth, and observability Behavioral cluster from the round-2 CR on the OpenRouter video surface: - /content Bearer check is now RFC 7235 case-insensitive and rejects an empty credential (scheme alone no longer passes) - empty-string model is rejected with 400 invalid_request_error instead of silently matching nothing - field-validation 400s journal the parsed request body (malformed-JSON and non-object paths keep body: null) - b64 corruption warn now round-trips the lenient decode, catching payloads that silently truncate instead of only zero-byte decodes - any fixture status outside the processing/completed/failed union warns before being coerced to completed (JSON fixtures bypass the type union) - x-forwarded-proto is allowlisted to http/https; other values fall back - the model listing debug-logs when video fixtures exist but none contributes a string model and the default set is served - the four OpenRouter video route catch blocks in server.ts log handler errors via logger.error before writing the 500 --- src/__tests__/openrouter-video.test.ts | 197 +++++++++++++++++++++++++ src/openrouter-video.ts | 69 +++++++-- src/server.ts | 81 +++++++--- 3 files changed, 312 insertions(+), 35 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 468ae6c9..32d4b92f 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -515,6 +515,7 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { let mock: LLMock | undefined; afterEach(async () => { + vi.restoreAllMocks(); await mock?.stop(); mock = undefined; }); @@ -573,6 +574,29 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { expect(data.data.length).toBeGreaterThan(0); expect(typeof data.data[0].id).toBe("string"); }); + + test("debug-logs when video fixtures exist but none contribute a string model", async () => { + mock = new LLMock({ port: 0, logLevel: "debug" }); + // 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 logSpy = vi.spyOn(console, "log").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( + logSpy.mock.calls.some((c) => { + const line = c.join(" "); + return line.includes("default") && line.includes("video"); + }), + ).toBe(true); + }); }); // ─── Task 6: cross-cutting conformance ────────────────────────────────────── @@ -872,6 +896,81 @@ describe("OpenRouter video — logger observability", () => { expect(body.length).toBe(0); // the decode is served as-is, just warned about expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).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("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, + ); + }); }); describe("OpenRouter video — full lifecycle integration", () => { @@ -1048,6 +1147,54 @@ describe("OpenRouter video — request body validation", () => { 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); + expect(entries[0].body).toMatchObject({ model: 123, prompt: "a sunset" }); + expect(entries[1].body).toMatchObject({ model: "m/v" }); + expect(entries[2].body).toBeNull(); + }); }); // ─── CR findings: testId embedded in generated URLs ──────────────────────── @@ -1198,6 +1345,23 @@ describe("OpenRouter video — x-forwarded-proto scheme", () => { 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); + }); }); // ─── CR findings: Bearer scheme validation on /content ───────────────────── @@ -1252,6 +1416,39 @@ describe("OpenRouter video — Bearer scheme validation", () => { }); 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 ───────────── diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index c80bbaf3..341af53e 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -142,7 +142,9 @@ function requestBase(req: http.IncomingMessage): string { // proxy in front of the mock. First value wins on comma-joined lists. const fwdProto = req.headers["x-forwarded-proto"]; const protoRaw = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; - const proto = protoRaw?.split(",")[0]?.trim() || "http"; + const candidate = protoRaw?.split(",")[0]?.trim().toLowerCase(); + // Allowlist http/https — any other value (ws, junk header data) falls back. + const proto = candidate === "http" || candidate === "https" ? candidate : "http"; return `${proto}://${req.headers.host ?? "localhost"}`; } @@ -275,7 +277,10 @@ export function handleOpenRouterVideoContent( // The real endpoint requires Bearer auth even though the unsigned URL is // otherwise self-contained — the @openrouter/sdk fetches it with the key. - if (!req.headers.authorization?.startsWith("Bearer ")) { + // 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, @@ -334,11 +339,18 @@ export function handleOpenRouterVideoContent( let bytes: Buffer; if (job.video.b64) { bytes = Buffer.from(job.video.b64, "base64"); - if (bytes.length === 0) { - // Non-empty b64 that decodes to nothing is almost certainly a corrupt - // fixture. Serve the (empty) decode as-is, but make the cause visible. + // Node's base64 decoder is lenient — invalid characters are skipped, so a + // corrupt payload silently truncates instead of erroring. Round-trip the + // decode (normalizing whitespace, padding, and the url-safe alphabet) and + // warn on mismatch. The decode is still served as-is. + const normalized = job.video.b64 + .replace(/\s+/g, "") + .replace(/-/g, "+") + .replace(/_/g, "/") + .replace(/=+$/, ""); + if (bytes.toString("base64").replace(/=+$/, "") !== normalized) { defaults.logger.warn( - `Video fixture b64 for job ${jobId} decoded to 0 bytes — likely corrupt base64`, + `Video fixture b64 for job ${jobId} did not round-trip (decoded ${bytes.length} bytes) — likely corrupt base64`, ); } } else { @@ -414,11 +426,23 @@ export function handleOpenRouterVideoModels( return; const modelIds = new Set(); + let sawVideoFixture = false; for (const f of fixtures) { - if (f.match.endpoint === "video" && typeof f.match.model === "string") { - modelIds.add(f.match.model); + if (f.match.endpoint === "video") { + sawVideoFixture = true; + if (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.debug( + "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({ @@ -499,12 +523,16 @@ export async function handleOpenRouterVideoCreate( 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 = videoReq as ChatCompletionRequest; + if (typeof videoReq.prompt !== "string" || !videoReq.prompt) { journal.add({ method, path, headers: flattenHeaders(req.headers), - body: null, + body: parsedBody, response: { status: 400, fixture: null }, }); writeErrorResponse( @@ -517,12 +545,14 @@ export async function handleOpenRouterVideoCreate( return; } - if (videoReq.model !== undefined && typeof videoReq.model !== "string") { + // 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: null, + body: parsedBody, response: { status: 400, fixture: null }, }); writeErrorResponse( @@ -530,7 +560,7 @@ export async function handleOpenRouterVideoCreate( 400, JSON.stringify({ error: { - message: "Invalid type for parameter: 'model' must be a string", + message: "Invalid type for parameter: 'model' must be a non-empty string", type: "invalid_request_error", }, }), @@ -677,13 +707,20 @@ export async function handleOpenRouterVideoCreate( response: { status: 200, fixture }, }); - // A fixture authored with status "processing" has no terminal state to - // converge on — terminalStatus coerces it to completed. Keep the behavior - // (jobs always terminate) but surface the coercion. - if (response.video.status === "processing") { + // 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(); diff --git a/src/server.ts b/src/server.ts index edce4010..708dfc1e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1321,37 +1321,79 @@ export async function createServer( // GET /api/v1/videos/{jobId}/content — download the generated bytes const openRouterVideoContentMatch = pathname.match(OPENROUTER_VIDEO_CONTENT_RE); if (openRouterVideoContentMatch && req.method === "GET") { - handleOpenRouterVideoContent( - req, - res, - openRouterVideoContentMatch[1], - journal, - defaults, - setCorsHeaders, - openRouterVideoJobs, - ); + 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) { + 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") { - handleOpenRouterVideoModels(req, res, fixtures, journal, defaults, setCorsHeaders); + 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) { + 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") { - handleOpenRouterVideoStatus( - req, - res, - openRouterVideoStatusMatch[1], - journal, - defaults, - setCorsHeaders, - openRouterVideoJobs, - ); + 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) { + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + res.destroy(); + } + } return; } @@ -1371,6 +1413,7 @@ export async function createServer( ); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; + defaults.logger.error(`openrouter-video submit: ${msg}`); if (!res.headersSent) { writeErrorResponse( res, From 5192c0f6442aa16504cb3a55000ef559ef9235b9 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:14:44 -0700 Subject: [PATCH 17/39] docs: clarify models-listing synthesis and FalQueueConfig state mapping CHANGELOG: the /api/v1/videos/models listing is synthesized from loaded video fixtures that specify a string match.model (regex models are excluded), falling back to a built-in default set otherwise. types.ts: bridge the FalQueueConfig field docs (written in fal queue terms) to the openRouterVideo usage, where the states map to pending / in_progress / completed | failed. --- CHANGELOG.md | 2 +- src/types.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6843315f..861df80e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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, falling back to a built-in default model set when no video fixtures are loaded. Video fixtures gain optional `error`, `b64`, and `cost` fields. Replay/strict-only for now — record-mode proxying for this surface is a follow-up. +- **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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. ## [1.30.0] - 2026-06-09 diff --git a/src/types.ts b/src/types.ts index 489b07bc..09921a8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -703,6 +703,11 @@ export interface MockServerOptions { 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. From b48c310d632394dbffb33d782a11783fa1d14e99 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:14:55 -0700 Subject: [PATCH 18/39] test: pin OpenRouter video behaviors and tighten test hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pins of existing behavior: X-Test-Id header wins over the ?testId= query param; X-AIMock-Strict: false downgrades a strict server's no-match 503 to 404; multipart/form-data submit returns 400 invalid_json (watch-item should the @openrouter/sdk ever shift to multipart); a "processing"-status fixture runs the full lifecycle to completed + download; post-terminal status polls are idempotent for completed (urls + usage) and failed (error) jobs; content fetch with Bearer immediately after submit succeeds under the default 0/0 progression (pins the documented divergence from fal's advance-on-result). Hygiene: drop the stray createdAt from the unit-test job factory; export OPENROUTER_VIDEO_MAX_ENTRIES and use it in the FIFO-capacity test instead of a hardcoded 10_000; normalize the later describes to let mock: LLMock | undefined with mock = undefined after stop (guarding closed-over helpers); reword the stale "advance to completed" comments — under default 0/0 the job is seeded terminal at submit. Also note at the submit increment site that the match count increments before applyChaos by design (mirrors handleCompletions), so chaos-dropped submits consume sequence slots. --- src/__tests__/openrouter-video.test.ts | 235 +++++++++++++++++++++++-- src/openrouter-video.ts | 5 +- 2 files changed, 227 insertions(+), 13 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 32d4b92f..5af41c97 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, afterEach, vi } from "vitest"; import { LLMock } from "../llmock.js"; import { createServer } from "../server.js"; import { resolveProgression } from "../fal.js"; -import { OpenRouterVideoJobMap } from "../openrouter-video.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"; @@ -743,10 +743,11 @@ describe("OpenRouter video — chaos injection", () => { }); describe("OpenRouter video — chaos source label and models route", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("submit chaos with no fixture journals source internal (surface never proxies)", async () => { @@ -787,11 +788,12 @@ describe("OpenRouter video — chaos source label and models route", () => { // ─── CR findings: logger observability ────────────────────────────────────── describe("OpenRouter video — logger observability", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { vi.restoreAllMocks(); await mock?.stop(); + mock = undefined; }); test("warns when a processing fixture is coerced to completed", async () => { @@ -885,7 +887,9 @@ describe("OpenRouter video — logger observability", () => { body: JSON.stringify({ model: "m/v", prompt: "corrupt bytes" }), }); const { id } = (await submit.json()) as { id: string }; - await fetch(`${mock.url}/api/v1/videos/${id}`); // advance to completed + // 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 fetch(`${mock.url}/api/v1/videos/${id}`); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const res = await fetch(`${mock.url}/api/v1/videos/${id}/content?index=0`, { @@ -1068,7 +1072,9 @@ describe("OpenRouter video — full lifecycle integration", () => { body: JSON.stringify({ model: "m/v", prompt: "auth lifecycle" }), }); const { id } = (await submit.json()) as { id: string }; - await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); // completed + // 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); @@ -1084,10 +1090,11 @@ describe("OpenRouter video — full lifecycle integration", () => { // ─── CR findings: input validation (400, not 500/mismatch) ───────────────── describe("OpenRouter video — request body validation", () => { - let mock: LLMock; + 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 () => { @@ -1200,13 +1207,15 @@ describe("OpenRouter video — request body validation", () => { // ─── CR findings: testId embedded in generated URLs ──────────────────────── describe("OpenRouter video — testId scoping of generated URLs", () => { - let mock: LLMock; + 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" } }, @@ -1298,10 +1307,11 @@ describe("OpenRouter video — testId scoping of generated URLs", () => { // ─── CR findings: x-forwarded-proto in generated URLs ────────────────────── describe("OpenRouter video — x-forwarded-proto scheme", () => { - let mock: LLMock; + let mock: LLMock | undefined; afterEach(async () => { await mock?.stop(); + mock = undefined; }); test("generated URLs use https when x-forwarded-proto says so", async () => { @@ -1367,20 +1377,24 @@ describe("OpenRouter video — x-forwarded-proto scheme", () => { // ─── CR findings: Bearer scheme validation on /content ───────────────────── describe("OpenRouter video — Bearer scheme validation", () => { - let mock: LLMock; + 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 }; - await fetch(`${mock.url}/api/v1/videos/${id}`); // advance to completed + // Status poll mirrors the client flow; under default 0/0 the job is + // already seeded terminal (completed) at submit. + await fetch(`${mock.url}/api/v1/videos/${id}`); return id; } @@ -1577,7 +1591,6 @@ describe("OpenRouterVideoJobMap (unit)", () => { pollsBeforeInProgress: 0, pollsBeforeCompleted: 0, video: { id, status: "completed" as const }, - createdAt: Date.now(), }; } @@ -1601,7 +1614,7 @@ describe("OpenRouterVideoJobMap (unit)", () => { test("evicts the oldest entries FIFO beyond the 10k capacity", () => { const map = new OpenRouterVideoJobMap(); - const CAP = 10_000; // OPENROUTER_VIDEO_MAX_ENTRIES (hardcoded, not exported) + 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); @@ -1721,3 +1734,201 @@ describe("OpenRouter video — config and header overrides", () => { 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); + }); +}); diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 341af53e..820acbb4 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -39,7 +39,7 @@ const DEFAULT_OPENROUTER_VIDEO_MODEL = "bytedance/seedance-2.0"; // ─── OpenRouterVideoJobMap (TTL + bounded) ────────────────────────────────── -const OPENROUTER_VIDEO_MAX_ENTRIES = 10_000; +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"; @@ -584,6 +584,9 @@ export async function handleOpenRouterVideoCreate( ); 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 { From 265f83563718e7ddbb7aa644245d9f5dd83a8c2c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:44:50 -0700 Subject: [PATCH 19/39] =?UTF-8?q?fix:=20OpenRouter=20video=20CR=20findings?= =?UTF-8?q?=20=E2=80=94=20journal=20shape,=20b64=20warn,=20fwd=20host,=20u?= =?UTF-8?q?rl=20warn,=20prompt=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - field-validation 400s journal a structurally valid synthetic ChatCompletionRequest (messages: [], non-string model JSON-encoded) instead of casting the raw parsed body - b64 corruption warn compares decoded byte count against the sanitized input's implied length, so valid non-canonical base64 (nonzero trailing bits, e.g. "QR") no longer false-positives while skipped-character corruption still warns - requestBase honors x-forwarded-host (first value wins on comma-joined lists, array-header tolerant) alongside x-forwarded-proto in generated polling/content URLs - content endpoint warns when a url-only fixture is served the placeholder MP4 — the author's url is ignored on this surface - a present-but-invalid prompt now 400s with "'prompt' must be a non-empty string" instead of claiming the parameter is missing (mirrors the model path's message style) --- src/__tests__/openrouter-video.test.ts | 141 ++++++++++++++++++++++++- src/openrouter-video.ts | 77 +++++++++++--- 2 files changed, 199 insertions(+), 19 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 5af41c97..d7fe09b2 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -929,6 +929,71 @@ describe("OpenRouter video — logger observability", () => { 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("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 on an unknown fixture status and treats the job as completed", async () => { mock = new LLMock({ port: 0, logLevel: "warn" }); mock.addFixture({ @@ -1137,7 +1202,28 @@ describe("OpenRouter video — request body validation", () => { expect(res.status).toBe(400); const data = await res.json(); expect(data.error.type).toBe("invalid_request_error"); - expect(data.error.message).toContain("prompt"); + // 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 () => { @@ -1198,8 +1284,12 @@ describe("OpenRouter video — request body validation", () => { const entries = mock.journal.getAll().filter((e) => e.response.status === 400); expect(entries).toHaveLength(3); - expect(entries[0].body).toMatchObject({ model: 123, prompt: "a sunset" }); - expect(entries[1].body).toMatchObject({ model: "m/v" }); + // 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(); }); }); @@ -1304,9 +1394,9 @@ describe("OpenRouter video — testId scoping of generated URLs", () => { }); }); -// ─── CR findings: x-forwarded-proto in generated URLs ────────────────────── +// ─── CR findings: x-forwarded-proto/host in generated URLs ───────────────── -describe("OpenRouter video — x-forwarded-proto scheme", () => { +describe("OpenRouter video — x-forwarded-proto/host", () => { let mock: LLMock | undefined; afterEach(async () => { @@ -1372,6 +1462,47 @@ describe("OpenRouter video — x-forwarded-proto scheme", () => { 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); + }); }); // ─── CR findings: Bearer scheme validation on /content ───────────────────── diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 820acbb4..ebc27f4f 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -137,15 +137,23 @@ function advanceJob(job: OpenRouterVideoJob): void { } } +/** First value of a possibly array-typed, possibly comma-joined header. */ +function firstForwardedValue(header: string | string[] | undefined): string | undefined { + const raw = Array.isArray(header) ? header[0] : header; + return raw?.split(",")[0]?.trim(); +} + function requestBase(req: http.IncomingMessage): string { - // Honor x-forwarded-proto so generated URLs survive a TLS-terminating - // proxy in front of the mock. First value wins on comma-joined lists. - const fwdProto = req.headers["x-forwarded-proto"]; - const protoRaw = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; - const candidate = protoRaw?.split(",")[0]?.trim().toLowerCase(); + // 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 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"; - return `${proto}://${req.headers.host ?? "localhost"}`; + const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]); + const host = + fwdHost !== undefined && fwdHost !== "" ? fwdHost : (req.headers.host ?? "localhost"); + return `${proto}://${host}`; } /** @@ -159,6 +167,27 @@ 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`. Mirrors the submit success path's synthetic shape: raw + * request fields (including `prompt`) ride along via the index signature, + * `messages` is empty (there is no validated prompt to wrap), and a + * non-string `model` is JSON-encoded so the field stays a string without + * dropping what the caller sent. + */ +function validationJournalBody(videoReq: OpenRouterVideoRequest): ChatCompletionRequest { + const rawModel = videoReq.model; + const model = + typeof rawModel === "string" + ? rawModel + : rawModel === undefined + ? "" + : JSON.stringify(rawModel); + return { ...videoReq, model, messages: [] }; +} + // ─── GET /api/v1/videos/{jobId} — status poll ─────────────────────────────── export function handleOpenRouterVideoStatus( @@ -340,20 +369,33 @@ export function handleOpenRouterVideoContent( if (job.video.b64) { bytes = Buffer.from(job.video.b64, "base64"); // Node's base64 decoder is lenient — invalid characters are skipped, so a - // corrupt payload silently truncates instead of erroring. Round-trip the - // decode (normalizing whitespace, padding, and the url-safe alphabet) and - // warn on mismatch. The decode is still served as-is. - const normalized = job.video.b64 + // 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. 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(/=+$/, ""); - if (bytes.toString("base64").replace(/=+$/, "") !== normalized) { + const expectedBytes = Math.floor((sanitized.length * 3) / 4); + if (bytes.length !== expectedBytes) { defaults.logger.warn( - `Video fixture b64 for job ${jobId} did not round-trip (decoded ${bytes.length} bytes) — likely corrupt base64`, + `Video fixture b64 for job ${jobId} decoded to ${bytes.length} bytes where its length implies ${expectedBytes} — likely corrupt base64`, ); } } else { + 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; } @@ -525,7 +567,7 @@ export async function handleOpenRouterVideoCreate( // 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 = videoReq as ChatCompletionRequest; + const parsedBody = validationJournalBody(videoReq); if (typeof videoReq.prompt !== "string" || !videoReq.prompt) { journal.add({ @@ -535,11 +577,18 @@ export async function handleOpenRouterVideoCreate( 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: "Missing required parameter: 'prompt'", type: "invalid_request_error" }, + error: { message, type: "invalid_request_error" }, }), ); return; From c54df06d1789ed3a025cec6fb030a189d47a7523 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:45:08 -0700 Subject: [PATCH 20/39] test: harden faulty-registry injection assertion and models-fallback log match - the faulty metrics registry test now asserts callCount >= 2 after the second request, so the test fails instead of passing vacuously if the createMetricsRegistry spy stops intercepting - the models-fallback debug-log assertion matches the distinctive message substring instead of any line containing "default" and "video" --- src/__tests__/metrics.test.ts | 5 +++++ src/__tests__/openrouter-video.test.ts | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts index 61439e17..8dcd8327 100644 --- a/src/__tests__/metrics.test.ts +++ b/src/__tests__/metrics.test.ts @@ -761,5 +761,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 index d7fe09b2..70a1cb7a 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -591,10 +591,9 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { const data = await res.json(); expect(data.data.length).toBeGreaterThan(0); expect( - logSpy.mock.calls.some((c) => { - const line = c.join(" "); - return line.includes("default") && line.includes("video"); - }), + logSpy.mock.calls.some((c) => + c.join(" ").includes("No video fixture contributes a string model"), + ), ).toBe(true); }); }); From 7bd577be5ea03f5730d14387331ebe004f82d93c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 10:45:22 -0700 Subject: [PATCH 21/39] docs: document "internal" journal source and terminal-at-submit seeding - JournalEntry.source doc now covers the "internal" union member (aimock's own synthetic logic where no fixture matched and no proxy applies, e.g. OpenRouter video lifecycle endpoints) - the 0/0-progression seed comment and CHANGELOG entry now say the job is seeded terminal at submit (content downloadable with zero polls) rather than implying the first poll causes the transition --- CHANGELOG.md | 2 +- src/openrouter-video.ts | 7 ++++--- src/types.ts | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861df80e..4addbb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job reaches its terminal status on the first poll), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. +- **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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. ## [1.30.0] - 2026-06-09 diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index ebc27f4f..55e92516 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -785,9 +785,10 @@ export async function handleOpenRouterVideoCreate( pollsBeforeCompleted: progression.pollsBeforeCompleted, video: response.video, }; - // Default 0/0 progression reaches the terminal status on the first poll — - // seed terminal directly (mirrors fal's COMPLETED-on-submit initial status); - // the submit envelope still reports "pending" like the real API. + // 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); } diff --git a/src/types.ts b/src/types.ts index 09921a8b..bab8358e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -505,8 +505,10 @@ 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" = served by aimock's own synthetic + * logic where no fixture matched and no proxy applies (e.g. the OpenRouter + * video lifecycle endpoints). Absent when the distinction doesn't apply + * (e.g. 404/503 fallback where nothing was going to serve). */ source?: "fixture" | "proxy" | "internal"; interrupted?: boolean; From d981dfb5bdcd73d8f2f1086f6adadef73e908468 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:06:28 -0700 Subject: [PATCH 22/39] fix: harden OpenRouter video b64 heuristic, forwarded-host gate, and 500 journaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - b64 corruption heuristic now mirrors Node's decoder: the first "=" terminates the decode, so post-padding characters no longer count as data chars (concatenated payloads like "QQ==QQ==" no longer false-warn), and a sanitized length congruent to 1 mod 4 — malformed base64 the length-mismatch check can never catch — now warns explicitly. - x-forwarded-host is only honored when it matches a conservative host[:port] shape; junk values (spaces, slashes, userinfo) fall back to the Host header instead of being interpolated into generated URLs. - The four /api/v1/videos route catch blocks now journal a status-500 entry so a handler throw (which on submit may have consumed a fixture-sequence slot) is no longer invisible; journaling is wrapped so it can never mask the 500. --- src/__tests__/openrouter-video.test.ts | 108 +++++++++++++++++++++++++ src/openrouter-video.ts | 37 +++++++-- src/server.ts | 54 +++++++++++++ 3 files changed, 191 insertions(+), 8 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 70a1cb7a..6963048c 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -692,6 +692,34 @@ describe("OpenRouter video — journal coverage", () => { 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", () => { @@ -958,6 +986,65 @@ describe("OpenRouter video — logger observability", () => { 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("base64"))).toBe(true); + }); + test("warns when fixture sets video.url but no b64 (placeholder served)", async () => { mock = new LLMock({ port: 0, logLevel: "warn" }); mock.addFixture({ @@ -1502,6 +1589,27 @@ describe("OpenRouter video — x-forwarded-proto/host", () => { const envelope = await submit.json(); expect(envelope.polling_url.startsWith("https://mock.example.com/")).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); + } + }); }); // ─── CR findings: Bearer scheme validation on /content ───────────────────── diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 55e92516..310859ca 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -143,6 +143,11 @@ function firstForwardedValue(header: string | string[] | undefined): string | un return raw?.split(",")[0]?.trim(); } +// 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. +const FORWARDED_HOST_RE = /^[a-zA-Z0-9.-]+(:\d+)?$/; + function requestBase(req: http.IncomingMessage): 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 value @@ -150,9 +155,13 @@ function requestBase(req: http.IncomingMessage): string { 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] falls back to the Host header. const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]); const host = - fwdHost !== undefined && fwdHost !== "" ? fwdHost : (req.headers.host ?? "localhost"); + fwdHost !== undefined && FORWARDED_HOST_RE.test(fwdHost) + ? fwdHost + : (req.headers.host ?? "localhost"); return `${proto}://${host}`; } @@ -368,11 +377,16 @@ export function handleOpenRouterVideoContent( let bytes: Buffer; if (job.video.b64) { bytes = Buffer.from(job.video.b64, "base64"); - // Node's base64 decoder is lenient — invalid characters are skipped, 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. A length check — rather than byte-exact re-encode equality — + // 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. @@ -380,9 +394,16 @@ export function handleOpenRouterVideoContent( .replace(/\s+/g, "") .replace(/-/g, "+") .replace(/_/g, "/") - .replace(/=+$/, ""); + .replace(/=.*$/, ""); const expectedBytes = Math.floor((sanitized.length * 3) / 4); - if (bytes.length !== expectedBytes) { + if (sanitized.length % 4 === 1) { + // A length ≡ 1 (mod 4) is malformed base64 the mismatch check cannot + // catch: Node silently drops the trailing character and the floor + // formula agrees with the truncated decode. + defaults.logger.warn( + `Video fixture b64 for job ${jobId} has a sanitized length of ${sanitized.length} (≡ 1 mod 4) — malformed base64; the final character is silently dropped`, + ); + } 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`, ); diff --git a/src/server.ts b/src/server.ts index 708dfc1e..7d7ade1a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1334,6 +1334,19 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video content: ${msg}`); + // Journal the failed request so it isn't invisible to consumers. + // 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 { + /* never mask the 500 */ + } if (!res.headersSent) { writeErrorResponse( res, @@ -1355,6 +1368,19 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video models: ${msg}`); + // Journal the failed request so it isn't invisible to consumers. + // 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 { + /* never mask the 500 */ + } if (!res.headersSent) { writeErrorResponse( res, @@ -1384,6 +1410,19 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video status: ${msg}`); + // Journal the failed request so it isn't invisible to consumers. + // 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 { + /* never mask the 500 */ + } if (!res.headersSent) { writeErrorResponse( res, @@ -1414,6 +1453,21 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video submit: ${msg}`); + // 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. 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 { + /* never mask the 500 */ + } if (!res.headersSent) { writeErrorResponse( res, From 13bf83a07eb3d8651a52e300c73939380c7b7936 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:06:57 -0700 Subject: [PATCH 23/39] docs: correct OpenRouter video option/journal comments, dedupe video route REs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openRouterVideo option doc now matches the pinned default behavior: 0/0 seeds the job terminal at submit, the first poll merely reports it, and content is downloadable with zero polls. - validationJournalBody comment no longer claims to mirror the submit success path's synthetic shape — raw request fields ride along only on the validation path. - JournalEntry.source "internal" doc tightened: it appears on chaos-path entries served by aimock's own synthetic logic; normal lifecycle entries omit source. - Submit handler comments document that chaos rolls after body validation and fixture matching, unlike the GET endpoints. - OPENROUTER_VIDEO_CONTENT_RE / OPENROUTER_VIDEO_STATUS_RE are now exported from metrics.ts and imported by server.ts instead of duplicated. - FalQueueConfig.pollsBeforeCompleted doc notes the equal-thresholds case lands the terminal status one poll later. --- src/metrics.ts | 5 +++-- src/openrouter-video.ts | 15 ++++++++++----- src/server.ts | 13 +++++++++---- src/types.ts | 19 ++++++++++++------- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/metrics.ts b/src/metrics.ts index cd24943a..fe45089e 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -222,8 +222,9 @@ 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\/([^:]+):(.+)$/; -const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; -const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; +// Exported: server.ts route dispatch matches the same OpenRouter video paths. +export const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; +export const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; /** diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 310859ca..f8289737 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -180,11 +180,13 @@ function testIdSuffix(testId: string, sep: "?" | "&"): string { * 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`. Mirrors the submit success path's synthetic shape: raw - * request fields (including `prompt`) ride along via the index signature, - * `messages` is empty (there is no validated prompt to wrap), and a - * non-string `model` is JSON-encoded so the field stays a string without - * dropping what the caller sent. + * `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; @@ -666,6 +668,9 @@ export async function handleOpenRouterVideoCreate( ); } + // Chaos deliberately rolls AFTER body validation and fixture matching + // (mirrors handleCompletions) — unlike the GET endpoints above, where chaos + // rolls first. if ( applyChaos( res, diff --git a/src/server.ts b/src/server.ts index 7d7ade1a..8e98da5b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -78,7 +78,12 @@ 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, + OPENROUTER_VIDEO_CONTENT_RE, + OPENROUTER_VIDEO_STATUS_RE, +} from "./metrics.js"; import { proxyAndRecord } from "./recorder.js"; export interface ServerInstance { @@ -182,11 +187,11 @@ 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. +// `[^/]+` 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 OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; -const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; const HEALTH_PATH = "/health"; const READY_PATH = "/ready"; diff --git a/src/types.ts b/src/types.ts index bab8358e..fd3b64e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -505,10 +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. "internal" = served by aimock's own synthetic - * logic where no fixture matched and no proxy applies (e.g. the OpenRouter - * video lifecycle endpoints). 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; @@ -699,8 +701,9 @@ export interface MockServerOptions { /** * 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 a job reaches its - * terminal status on the first poll. + * 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; } @@ -722,7 +725,9 @@ 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`, the job still spends one poll in IN_PROGRESS, + * so the terminal status lands one poll later than configured. */ pollsBeforeCompleted?: number; } From f9174b6651e53c1e586e4989c4c1197034a8e8ef Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:07:07 -0700 Subject: [PATCH 24/39] test: tighten metrics path assertion, fix placeholder URL, harden cleanup - Boundary-aware match for path="/api/v1/videos" so the {jobId} line can't substring-satisfy it. - record.providers.openai placeholder is now URL-shaped instead of an API-key lookalike. - The server-close state test closes its server in a guarded try/finally so a mid-test assertion failure can't leak the instance. - The chaos proxy-source test uses a fresh mkdtemp fixture dir (cleaned up) instead of a fixed shared /tmp path. --- src/__tests__/metrics.test.ts | 11 +++++- src/__tests__/openrouter-video.test.ts | 52 ++++++++++++++++---------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts index 8dcd8327..dfa79ca2 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"; @@ -608,13 +611,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, }, }); @@ -627,6 +631,7 @@ describe("integration: /metrics endpoint", () => { ); } finally { await new Promise((resolve) => upstream.server.close(() => resolve())); + fs.rmSync(fixtureDir, { recursive: true, force: true }); } }); @@ -682,7 +687,9 @@ describe("integration: /metrics endpoint", () => { ).toBe(200); const res = await httpGet(`${instance.url}/metrics`); - expect(res.body).toContain('path="/api/v1/videos"'); + // 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. diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 6963048c..20146fa5 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -845,7 +845,7 @@ describe("OpenRouter video — logger observability", () => { mock = new LLMock({ port: 0, logLevel: "warn", - record: { providers: { openai: "sk-test" } }, + record: { providers: { openai: "http://127.0.0.1:9" } }, }); await mock.start(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -1718,26 +1718,40 @@ describe("server close clears video job state", () => { }, ]; 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())); + }); + }; - // Populate both per-instance maps. - await fetch(`${instance.url}/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "sora-2", prompt: "openai close" }), - }); - await fetch(`${instance.url}/api/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "m/v", prompt: "openrouter close" }), - }); - expect(instance.videoStates.size).toBeGreaterThan(0); - expect(instance.openRouterVideoJobs.size).toBeGreaterThan(0); + try { + // Populate both per-instance maps. + await fetch(`${instance.url}/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "sora-2", prompt: "openai close" }), + }); + await fetch(`${instance.url}/api/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "m/v", prompt: "openrouter close" }), + }); + expect(instance.videoStates.size).toBeGreaterThan(0); + expect(instance.openRouterVideoJobs.size).toBeGreaterThan(0); - await new Promise((resolve, reject) => { - instance.server.close((err) => (err ? reject(err) : resolve())); - }); - expect(instance.openRouterVideoJobs.size).toBe(0); - expect(instance.videoStates.size).toBe(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(); + } }); }); From e68b3c143c17ab36be906339ae5a3ba2dc292a12 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:28:40 -0700 Subject: [PATCH 25/39] fix: harden OpenRouter video proxy-host handling and error-path journaling - accept bracketed IPv6 literals ([::1], [::1]:8080) in x-forwarded-host and warn when a present-but-rejected value falls back to the Host header - warn when a fixture's b64 is the empty string before serving the placeholder MP4 - reword the mod-4 b64 warn: invalid characters also land in this branch, so the "final character is silently dropped" diagnosis was wrong for inputs like "AAAA!" - guard the /api/v1/videos* catch-block journaling on res.headersSent so a throw after a successful journal+response cannot double-journal, and log a debug line when the journal write itself fails instead of swallowing it silently --- src/__tests__/openrouter-video.test.ts | 75 ++++++++++++++- src/openrouter-video.ts | 42 ++++++--- src/server.ts | 124 ++++++++++++++----------- 3 files changed, 174 insertions(+), 67 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 20146fa5..a5ac6d23 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1042,7 +1042,11 @@ describe("OpenRouter video — logger observability", () => { 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("base64"))).toBe(true); + 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 () => { @@ -1080,6 +1084,56 @@ describe("OpenRouter video — logger observability", () => { ).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 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(); + // Still falls back to the Host header (pre-existing junk-host behavior). + 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({ @@ -1590,6 +1644,25 @@ describe("OpenRouter video — x-forwarded-proto/host", () => { 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({ diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index f8289737..e2047b85 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -1,6 +1,7 @@ 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, @@ -147,8 +148,11 @@ function firstForwardedValue(header: string | string[] | undefined): string | un // userinfo, or any other URL-structure character would corrupt (or smuggle // paths into) the generated URLs the value is interpolated into. 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): string { +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 value // wins on comma-joined lists. @@ -156,12 +160,17 @@ function requestBase(req: http.IncomingMessage): string { // 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] falls back to the Host header. + // 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"]); - const host = - fwdHost !== undefined && FORWARDED_HOST_RE.test(fwdHost) - ? fwdHost - : (req.headers.host ?? "localhost"); + let host = req.headers.host ?? "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"); + } + } return `${proto}://${host}`; } @@ -261,7 +270,7 @@ export function handleOpenRouterVideoStatus( const body: Record = { id: job.jobId, status: job.status }; if (job.status === "completed") { body.unsigned_urls = [ - `${requestBase(req)}/api/v1/videos/${job.jobId}/content?index=0${testIdSuffix(testId, "&")}`, + `${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") { @@ -399,11 +408,12 @@ export function handleOpenRouterVideoContent( .replace(/=.*$/, ""); const expectedBytes = Math.floor((sanitized.length * 3) / 4); if (sanitized.length % 4 === 1) { - // A length ≡ 1 (mod 4) is malformed base64 the mismatch check cannot - // catch: Node silently drops the trailing character and the floor - // formula agrees with the truncated decode. + // 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 a sanitized length of ${sanitized.length} (≡ 1 mod 4) — malformed base64; the final character is silently dropped`, + `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( @@ -411,6 +421,14 @@ export function handleOpenRouterVideoContent( ); } } 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 @@ -824,7 +842,7 @@ export async function handleOpenRouterVideoCreate( res.end( JSON.stringify({ id: jobId, - polling_url: `${requestBase(req)}/api/v1/videos/${jobId}${testIdSuffix(testId, "?")}`, + 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 8e98da5b..500569ae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1339,20 +1339,24 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video content: ${msg}`); - // Journal the failed request so it isn't invisible to consumers. - // 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 { - /* never mask the 500 */ - } 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 { + defaults.logger.debug( + "openrouter-video content: journal write failed after handler error", + ); + } writeErrorResponse( res, 500, @@ -1373,20 +1377,24 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video models: ${msg}`); - // Journal the failed request so it isn't invisible to consumers. - // 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 { - /* never mask the 500 */ - } 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 { + defaults.logger.debug( + "openrouter-video models: journal write failed after handler error", + ); + } writeErrorResponse( res, 500, @@ -1415,20 +1423,24 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video status: ${msg}`); - // Journal the failed request so it isn't invisible to consumers. - // 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 { - /* never mask the 500 */ - } 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 { + defaults.logger.debug( + "openrouter-video status: journal write failed after handler error", + ); + } writeErrorResponse( res, 500, @@ -1458,22 +1470,26 @@ export async function createServer( } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Internal error"; defaults.logger.error(`openrouter-video submit: ${msg}`); - // 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. 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 { - /* never mask the 500 */ - } 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. 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 { + defaults.logger.debug( + "openrouter-video submit: journal write failed after handler error", + ); + } writeErrorResponse( res, 500, From ac88859b14836c515d8a1282b8928037f3910b68 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:29:05 -0700 Subject: [PATCH 26/39] test: pin OpenRouter video poll-budget and threshold semantics, consume bodies - pin that /content fetches never advance job state (consume no poll budget), shown red via a temporary advanceJob mutation in the content handler - pin {pollsBeforeCompleted: 1} landing terminal on poll 2 (in_progress consumes poll 1) - consume response bodies at the remaining three spots that left connections dangling (zero-bytes status poll, completedJob helper, server-close test's two submits) --- src/__tests__/openrouter-video.test.ts | 81 +++++++++++++++++++++----- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index a5ac6d23..a29eec6b 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -443,6 +443,32 @@ describe("GET /api/v1/videos/{jobId}/content (OpenRouter download)", () => { 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 }); @@ -916,7 +942,7 @@ describe("OpenRouter video — logger observability", () => { 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 fetch(`${mock.url}/api/v1/videos/${id}`); + 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`, { @@ -1705,7 +1731,7 @@ describe("OpenRouter video — Bearer scheme validation", () => { 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 fetch(`${mock.url}/api/v1/videos/${id}`); + await (await fetch(`${mock.url}/api/v1/videos/${id}`)).arrayBuffer(); return id; } @@ -1803,17 +1829,22 @@ describe("server close clears video job state", () => { }; try { - // Populate both per-instance maps. - await fetch(`${instance.url}/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "sora-2", prompt: "openai close" }), - }); - await fetch(`${instance.url}/api/v1/videos`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "m/v", prompt: "openrouter close" }), - }); + // 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); @@ -2045,6 +2076,30 @@ describe("OpenRouter video — config and header overrides", () => { 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(); From 0ecf2b53301cb030415f920ec4c2b5dcc9fc49ed Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:29:05 -0700 Subject: [PATCH 27/39] docs: clarify FalQueueConfig threshold defaults and video failure message - document the unset-vs-explicit-0 distinction on pollsBeforeInProgress (explicitly setting it, even to 0, enables progression) and extend the late-by-one note on pollsBeforeCompleted to the {pollsBeforeInProgress unset/0, pollsBeforeCompleted: 1} case - note in the CHANGELOG that a default "Video generation failed" message is used when a failed fixture has no error field --- CHANGELOG.md | 2 +- src/types.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4addbb24..7b91d48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), adding `unsigned_urls` + `usage.cost` on completion and the fixture's `error` message on failure; `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. +- **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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), 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); `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. ## [1.30.0] - 2026-06-09 diff --git a/src/types.ts b/src/types.ts index fd3b64e5..55ab3684 100644 --- a/src/types.ts +++ b/src/types.ts @@ -715,9 +715,12 @@ export interface MockServerOptions { */ 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, with + * `pollsBeforeCompleted` defaulting to `pollsBeforeInProgress + 1` so the + * job passes through IN_PROGRESS. */ pollsBeforeInProgress?: number; /** @@ -726,8 +729,10 @@ export interface FalQueueConfig { * `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. When equal to a nonzero - * `pollsBeforeInProgress`, the job still spends one poll in IN_PROGRESS, - * so the terminal status lands one poll later than configured. + * `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; } From 3fbcde74a5f50a98162214675cee846cbbed5e51 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:50:32 -0700 Subject: [PATCH 28/39] fix: harden OpenRouter video journal, thresholds, and header/content edge cases - strip underscore-prefixed client keys (_endpointType, _context) from the validation-400 journal body so requests cannot spoof handler-set discriminators - sanitize resolveProgression thresholds to non-negative integers: non-finite values (NaN/Infinity) are treated as unset and negatives/fractions clamp, so a NaN threshold can no longer strand polling clients short of terminal and -1 honors the explicit-0 progression contract (also covers the falQueue surface); createServer warns on invalid thresholds next to the chaos-rate validation - warn when a non-empty b64 (e.g. padding-only "=") decodes to zero bytes - firstForwardedValue skips empty segments: an empty x-forwarded-* header or a leading-comma list no longer triggers a spurious rejection warn or discards valid later entries - include the offending (truncated, JSON-quoted) value in the x-forwarded-host rejection warn - include the caught error message in the four openrouter-video journal-failure debug logs instead of discarding it - warn when a present ?index= parses to a non-zero value (still serves index 0) - shallow-copy the fixture's video object at job creation so later fixture or factory mutation cannot retroactively change an in-flight job - exclude an empty-string match.model from the video models listing --- src/__tests__/openrouter-video.test.ts | 362 +++++++++++++++++++++++++ src/fal.ts | 18 +- src/openrouter-video.ts | 57 +++- src/server.ts | 31 ++- 4 files changed, 448 insertions(+), 20 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index a29eec6b..959e37d8 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -29,6 +29,43 @@ describe("resolveProgression (shared with fal queue)", () => { 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", () => { @@ -2312,3 +2349,328 @@ describe("OpenRouter video — pinned existing behavior", () => { 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("not a non-negative integer") + ); + }), + ).toBe(true); + expect( + warnSpy.mock.calls.some((c) => { + const line = c.join(" "); + return ( + line.includes("falQueue.pollsBeforeInProgress") && + line.includes("not 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); + }); +}); diff --git a/src/fal.ts b/src/fal.ts index 4f248ecd..07273cf6 100644 --- a/src/fal.ts +++ b/src/fal.ts @@ -169,17 +169,27 @@ export function resolveProgression(config: FalQueueConfig | undefined): { pollsBeforeInProgress: number; pollsBeforeCompleted: number; } { - const pollsBeforeInProgress = config?.pollsBeforeInProgress ?? 0; - const explicitCompleted = config?.pollsBeforeCompleted; + // Thresholds must resolve to non-negative integers: a NaN would make + // advanceJob's `pollCount >= 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/openrouter-video.ts b/src/openrouter-video.ts index e2047b85..d60868c2 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -138,10 +138,20 @@ function advanceJob(job: OpenRouterVideoJob): void { } } -/** First value of a possibly array-typed, possibly comma-joined header. */ +/** + * 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[0] : header; - return raw?.split(",")[0]?.trim(); + 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, @@ -154,8 +164,8 @@ 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 value - // wins on comma-joined lists. + // 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"; @@ -168,7 +178,9 @@ function requestBase(req: http.IncomingMessage, logger: Logger): string { 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"); + logger.warn( + `x-forwarded-host value rejected, falling back to Host header: ${JSON.stringify(fwdHost.slice(0, 100))}`, + ); } } return `${proto}://${host}`; @@ -205,7 +217,13 @@ function validationJournalBody(videoReq: OpenRouterVideoRequest): ChatCompletion : rawModel === undefined ? "" : JSON.stringify(rawModel); - return { ...videoReq, model, messages: [] }; + // 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 ─────────────────────────────── @@ -385,6 +403,18 @@ export function handleOpenRouterVideoContent( 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"); @@ -407,6 +437,15 @@ export function handleOpenRouterVideoContent( .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 @@ -513,7 +552,7 @@ export function handleOpenRouterVideoModels( for (const f of fixtures) { if (f.match.endpoint === "video") { sawVideoFixture = true; - if (typeof f.match.model === "string") { + if (f.match.model && typeof f.match.model === "string") { modelIds.add(f.match.model); } } @@ -827,7 +866,9 @@ export async function handleOpenRouterVideoCreate( pollCount: 0, pollsBeforeInProgress: progression.pollsBeforeInProgress, pollsBeforeCompleted: progression.pollsBeforeCompleted, - video: response.video, + // 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 diff --git a/src/server.ts b/src/server.ts index 500569ae..8c02348f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1121,6 +1121,21 @@ 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 && (!Number.isInteger(value) || value < 0)) { + logger.warn(`${name}.${field} (${value}) is not a non-negative integer — clamping`); + } + } + } + // 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. @@ -1352,9 +1367,9 @@ export async function createServer( body: null, response: { status: 500, fixture: null }, }); - } catch { + } catch (jErr) { defaults.logger.debug( - "openrouter-video content: journal write failed after handler error", + `openrouter-video content: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } writeErrorResponse( @@ -1390,9 +1405,9 @@ export async function createServer( body: null, response: { status: 500, fixture: null }, }); - } catch { + } catch (jErr) { defaults.logger.debug( - "openrouter-video models: journal write failed after handler error", + `openrouter-video models: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } writeErrorResponse( @@ -1436,9 +1451,9 @@ export async function createServer( body: null, response: { status: 500, fixture: null }, }); - } catch { + } catch (jErr) { defaults.logger.debug( - "openrouter-video status: journal write failed after handler error", + `openrouter-video status: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } writeErrorResponse( @@ -1485,9 +1500,9 @@ export async function createServer( body: null, response: { status: 500, fixture: null }, }); - } catch { + } catch (jErr) { defaults.logger.debug( - "openrouter-video submit: journal write failed after handler error", + `openrouter-video submit: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } writeErrorResponse( From 45416951cdaca7f52fd9788aa202f278a7887437 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:50:53 -0700 Subject: [PATCH 29/39] docs: clarify video threshold and default-model contracts - pollsBeforeInProgress doc: explicit 0 enables progression only when pollsBeforeCompleted is unset ({0,0} still completes on submit) - note in the CHANGELOG and at the fallback site that model-less submits assume the default video model, so model-restricted fixtures can match them - note the deliberate lifetime divergence between OpenRouterVideoJobMap (per-server-instance) and FalQueueStateMap (module-global) --- CHANGELOG.md | 2 +- src/openrouter-video.ts | 4 ++++ src/types.ts | 7 ++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b91d48c..a0367991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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; `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), 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); `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. +- **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); `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), 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); `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`). 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/strict-only for now — record-mode proxying for this surface is a follow-up. ## [1.30.0] - 2026-06-09 diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index d60868c2..5b472780 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -67,6 +67,8 @@ interface OpenRouterVideoEntry { * 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 { @@ -698,6 +700,8 @@ export async function handleOpenRouterVideoCreate( } 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", diff --git a/src/types.ts b/src/types.ts index 55ab3684..487b351c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -718,9 +718,10 @@ export interface FalQueueConfig { * 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, with - * `pollsBeforeCompleted` defaulting to `pollsBeforeInProgress + 1` so the - * job passes through IN_PROGRESS. + * 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` still completes on submit). */ pollsBeforeInProgress?: number; /** From a6181e687b95bb144e64d58bb51470d4a51ac5f4 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 11:51:10 -0700 Subject: [PATCH 30/39] test: pin OpenRouter video chaos-slot, proto-list, and chaos-source contracts - a chaos-dropped submit consumes the matched fixture's sequence slot (a clean identical resubmit gets the strict skip-by-state 503); verified red-by-mutation against moving the increment after applyChaos - a comma-joined x-forwarded-proto list honors the first value in generated URLs - a chaos-dropped status poll (no fixture in scope) journals source "internal" --- src/__tests__/openrouter-video.test.ts | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 959e37d8..a6d62216 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -2674,3 +2674,74 @@ describe("OpenRouter video — CR round 8 content/header polish", () => { 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"); + }); +}); From 301d4215eb35515081bf738089cfad2b1d5344a0 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:09:15 -0700 Subject: [PATCH 31/39] fix: harden OpenRouter video host handling and journal-failure logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate the Host-header fallback in requestBase with the same host[:port] REs as x-forwarded-host, falling back to localhost — a junk Host (evil.com/x) can no longer smuggle a path into polling_url/unsigned_urls - scan all elements of an array-typed x-forwarded-* header in firstForwardedValue (join before split), matching its docstring - warn and serve the default message when a failed fixture carries an explicit empty error instead of returning "" to polling clients - split the createServer threshold warn: non-finite values are treated as unset (resolveProgression semantics); negative/fractional values clamp - raise the four journal-write-failure logs from debug to warn — a journal failure during error handling is a double-fault worth surfacing --- src/__tests__/openrouter-video.test.ts | 73 ++++++++++++++++++++++++-- src/openrouter-video.ts | 22 ++++++-- src/server.ts | 17 +++--- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index a6d62216..844329a2 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1,4 +1,5 @@ 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"; @@ -1175,6 +1176,30 @@ describe("OpenRouter video — logger observability", () => { 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({ @@ -1190,7 +1215,8 @@ describe("OpenRouter video — logger observability", () => { body: JSON.stringify({ model: "m/v", prompt: "rejected host" }), }); const envelope = await submit.json(); - // Still falls back to the Host header (pre-existing junk-host behavior). + // 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")), @@ -1746,6 +1772,47 @@ describe("OpenRouter video — x-forwarded-proto/host", () => { 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); + }); }); // ─── CR findings: Bearer scheme validation on /content ───────────────────── @@ -2464,7 +2531,7 @@ describe("OpenRouter video — progression threshold sanitization (e2e)", () => const line = c.join(" "); return ( line.includes("openRouterVideo.pollsBeforeCompleted") && - line.includes("not a non-negative integer") + line.includes("treating as unset") ); }), ).toBe(true); @@ -2473,7 +2540,7 @@ describe("OpenRouter video — progression threshold sanitization (e2e)", () => const line = c.join(" "); return ( line.includes("falQueue.pollsBeforeInProgress") && - line.includes("not a non-negative integer") + line.includes("clamping to a non-negative integer") ); }), ).toBe(true); diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 5b472780..6298cd7b 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -147,7 +147,7 @@ function advanceJob(job: OpenRouterVideoJob): void { * valid later entries — so empty segments are skipped. */ function firstForwardedValue(header: string | string[] | undefined): string | undefined { - const raw = Array.isArray(header) ? header[0] : header; + const raw = Array.isArray(header) ? header.join(",") : header; if (raw === undefined) return undefined; for (const segment of raw.split(",")) { const trimmed = segment.trim(); @@ -175,7 +175,16 @@ function requestBase(req: http.IncomingMessage, logger: Logger): string { // 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"]); - let host = req.headers.host ?? "localhost"; + // 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; @@ -294,7 +303,14 @@ export function handleOpenRouterVideoStatus( ]; body.usage = { cost: job.video.cost ?? 0 }; } else if (job.status === "failed") { - body.error = job.video.error ?? "Video generation 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" }); diff --git a/src/server.ts b/src/server.ts index 8c02348f..47f456ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1130,8 +1130,13 @@ export async function createServer( if (!config) continue; for (const field of ["pollsBeforeInProgress", "pollsBeforeCompleted"] as const) { const value = config[field]; - if (value !== undefined && (!Number.isInteger(value) || value < 0)) { - logger.warn(`${name}.${field} (${value}) is not a non-negative integer — clamping`); + 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 — clamping to a non-negative integer`, + ); } } } @@ -1368,7 +1373,7 @@ export async function createServer( response: { status: 500, fixture: null }, }); } catch (jErr) { - defaults.logger.debug( + defaults.logger.warn( `openrouter-video content: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } @@ -1406,7 +1411,7 @@ export async function createServer( response: { status: 500, fixture: null }, }); } catch (jErr) { - defaults.logger.debug( + defaults.logger.warn( `openrouter-video models: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } @@ -1452,7 +1457,7 @@ export async function createServer( response: { status: 500, fixture: null }, }); } catch (jErr) { - defaults.logger.debug( + defaults.logger.warn( `openrouter-video status: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } @@ -1501,7 +1506,7 @@ export async function createServer( response: { status: 500, fixture: null }, }); } catch (jErr) { - defaults.logger.debug( + defaults.logger.warn( `openrouter-video submit: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, ); } From af3b07fd8437511f75fbfd3436cef70f50c34ffc Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:09:42 -0700 Subject: [PATCH 32/39] docs: qualify threshold and double-journal notes; changelog falQueue change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: document the falQueue behavior change from the shared resolveProgression sanitization (non-finite thresholds treated as unset, negative/fractional values clamped, createServer warns on invalid values) - types.ts: an explicit pollsBeforeCompleted: 0 completes on submit only when pollsBeforeInProgress is 0 or unset (the clamp otherwise lifts it) - server.ts: note the submit catch's headersSent guard only covers post-write throws, not the journal.add → writeHead window --- CHANGELOG.md | 9 +++++++++ src/server.ts | 7 +++++-- src/types.ts | 4 +++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0367991..f2b34b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ - **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); `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), 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); `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`). 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/strict-only for now — 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/src/server.ts b/src/server.ts index 47f456ca..867da5ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1495,8 +1495,11 @@ export async function createServer( // 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. Wrapped so journaling can never mask - // the 500 write below. + // 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", diff --git a/src/types.ts b/src/types.ts index 487b351c..bb6335b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -721,7 +721,9 @@ export interface FalQueueConfig { * 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` still completes on submit). + * (an explicit `pollsBeforeCompleted: 0` completes on submit only when + * `pollsBeforeInProgress` is `0` or unset — otherwise the clamp below + * lifts it to `pollsBeforeInProgress`). */ pollsBeforeInProgress?: number; /** From 24fdd6a86cd7de4f2f813fbaa8a0010abfb1b5a7 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:27:31 -0700 Subject: [PATCH 33/39] fix: accept underscore hosts, CORS on submit errors, dedupe status RE, warn wording - FORWARDED_HOST_RE admits underscores: hostnames like my_project_aimock:4010 are routine in docker-compose/k8s networks, and a rejected Host silently fell back to a dead "localhost" polling_url/unsigned_urls. The value feeds URL-string interpolation, not DNS validation. Covered by a red-green raw-Host test. - The /api/v1/videos submit route sets CORS headers before readBody, so a body-read failure that lands in the catch writes a 500 a browser client can inspect instead of an opaque CORS-blocked failure. The readBody rejection paths destroy the socket (no observable response), so the regression test asserts the handler-throw 500 carries access-control-allow-origin. - server.ts VIDEOS_STATUS_RE was byte-identical to metrics.ts OPENAI_VIDEO_STATUS_RE; export it from metrics.ts (like the OpenRouter REs) so the two cannot drift. - The poll-threshold warn said "clamping" but resolveProgression floors fractional values (1.9 -> 1); say "flooring/clamping" so the message matches the behavior. --- src/__tests__/openrouter-video.test.ts | 65 ++++++++++++++++++++++++++ src/metrics.ts | 5 +- src/openrouter-video.ts | 7 ++- src/server.ts | 11 +++-- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 844329a2..81e559ef 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -1269,6 +1269,31 @@ describe("OpenRouter video — logger observability", () => { true, ); }); + + test("submit error 500s carry CORS headers", async () => { + mock = new LLMock({ port: 0, logLevel: "error" }); + 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", () => { @@ -1813,6 +1838,46 @@ describe("OpenRouter video — x-forwarded-proto/host", () => { 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 ───────────────────── diff --git a/src/metrics.ts b/src/metrics.ts index fe45089e..48cb6e09 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -222,10 +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 video paths. +// 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\/([^/]+)$/; -const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; +export const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; /** * Normalize parametric API paths to route patterns for use as metric labels. diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 6298cd7b..5af46c52 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -158,8 +158,11 @@ function firstForwardedValue(header: string | string[] | undefined): string | un // 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. -const FORWARDED_HOST_RE = /^[a-zA-Z0-9.-]+(:\d+)?$/; +// 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+)?$/; diff --git a/src/server.ts b/src/server.ts index 867da5ca..7c13c3df 100644 --- a/src/server.ts +++ b/src/server.ts @@ -81,6 +81,7 @@ import { applyChaosAction, evaluateChaos } from "./chaos.js"; import { createMetricsRegistry, normalizePathLabel, + OPENAI_VIDEO_STATUS_RE, OPENROUTER_VIDEO_CONTENT_RE, OPENROUTER_VIDEO_STATUS_RE, } from "./metrics.js"; @@ -114,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\/([^/]+)$/; @@ -1135,7 +1135,7 @@ export async function createServer( 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 — clamping to a non-negative integer`, + `${name}.${field} (${value}) is not a non-negative integer — flooring/clamping to a non-negative integer`, ); } } @@ -1475,6 +1475,11 @@ export async function createServer( // 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( @@ -1946,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); From d00546884546f59187c93a6720d7fe7ce1592a81 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:27:41 -0700 Subject: [PATCH 34/39] docs: clarify testIdSuffix header comment and chaos source-label test comment - testIdSuffix said the @openrouter/sdk fetches generated URLs "bare -- no custom headers", which read as contradicting the content endpoint's Bearer requirement. Both are true: the SDK sends standard Authorization but no aimock-specific headers (no x-test-id). Reword so the two comments cannot be read as conflicting. - The metrics test comment claimed an omitted source label "would serialize source=\"\"" -- wrong; an omitted label is simply absent from the series. Reword to say the source-less series would pass a bare action match but fails this regex. --- src/__tests__/metrics.test.ts | 3 ++- src/openrouter-video.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts index dfa79ca2..1688988b 100644 --- a/src/__tests__/metrics.test.ts +++ b/src/__tests__/metrics.test.ts @@ -597,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/, ); diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 5af46c52..ff204d0d 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -202,10 +202,11 @@ function requestBase(req: http.IncomingMessage, logger: Logger): string { /** * Query-string suffix embedding the request's testId into generated URLs - * (polling_url, unsigned_urls). The @openrouter/sdk fetches these URLs bare — - * no custom headers — 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. + * (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)}`; From de92c0d07862e3f3440bc159fffef779619f353c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:41:53 -0700 Subject: [PATCH 35/39] fix: warn (not debug) when video fixtures contribute no string model The models-listing fallback to the default video model set is a fixture-authoring surprise; this surface's convention is warn for those, and debug is invisible at the CLI default info level. Pin test updated to spy console.warn at logLevel "warn". --- src/__tests__/openrouter-video.test.ts | 4 ++-- src/openrouter-video.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 81e559ef..142768ea 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -639,8 +639,8 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { expect(typeof data.data[0].id).toBe("string"); }); - test("debug-logs when video fixtures exist but none contribute a string model", async () => { - mock = new LLMock({ port: 0, logLevel: "debug" }); + 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({ diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index ff204d0d..41019dd3 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -583,7 +583,7 @@ export function handleOpenRouterVideoModels( // 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.debug( + defaults.logger.warn( "No video fixture contributes a string model — serving the default video model set", ); } From cd886e7308da80ece312f91fcb67f66fb0f1c7a9 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:42:03 -0700 Subject: [PATCH 36/39] test: fix invalid logLevel and loosen-prone warn assertion in video tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "error" is not a valid LogLevel ("silent" | "warn" | "info" | "debug"); the Logger constructor silently degraded it to fully-silent — use "silent" to match the intent. The zero-byte decode test matched the substring "base64", which the zero-byte warn does not contain — it was satisfied by the separate length-mismatch warn that also fires for "!!!!"; assert "zero bytes" specifically. --- src/__tests__/openrouter-video.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts index 142768ea..b73d91d1 100644 --- a/src/__tests__/openrouter-video.test.ts +++ b/src/__tests__/openrouter-video.test.ts @@ -648,14 +648,14 @@ describe("GET /api/v1/videos/models (OpenRouter video model listing)", () => { response: { video: { id: "vid_rx", status: "completed" } }, }); await mock.start(); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + 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( - logSpy.mock.calls.some((c) => + warnSpy.mock.calls.some((c) => c.join(" ").includes("No video fixture contributes a string model"), ), ).toBe(true); @@ -989,7 +989,9 @@ describe("OpenRouter video — logger observability", () => { 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 - expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("base64"))).toBe(true); + // "!!!!" 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 () => { @@ -1271,7 +1273,7 @@ describe("OpenRouter video — logger observability", () => { }); test("submit error 500s carry CORS headers", async () => { - mock = new LLMock({ port: 0, logLevel: "error" }); + mock = new LLMock({ port: 0, logLevel: "silent" }); mock.addFixture({ match: { userMessage: "cors boom", endpoint: "video" }, response: () => { From 24231e6b10ebfd16b73c102fac8b1f395d350754 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 12:42:12 -0700 Subject: [PATCH 37/39] docs: wording pass on OpenRouter video changelog entries Reconcile the "pending" submit envelope with the internally-terminal default seeding; clarify that zero-poll content needs a client-built URL while the documented flow polls once to learn unsigned_urls; replace "replay/strict-only" with "replay-only (no record/proxy mode yet)" since the surface works in lenient mode too; note status polls and the models listing are served without auth (only /content enforces Bearer); fix the shared-progression-resolver sentence grammar in the Fixed entry. --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b34b63..3e2ecc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,36 @@ ### 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); `GET /api/v1/videos/{jobId}` advances `pending → in_progress → completed | failed` per the new `openRouterVideo` poll-threshold config (same poll-threshold semantics as `falQueue`; by default the job is seeded terminal at submit — content is downloadable with zero polls, and the first poll merely reports the terminal status), 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); `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`). 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/strict-only for now — 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 +- **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` From bee2d1312ec60782bf7911183e9c8582202cc435 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 13:02:23 -0700 Subject: [PATCH 38/39] docs: document OpenRouter video surface across docs site and README Adds a dedicated docs page for the OpenRouter async video lifecycle (/api/v1/videos submit, status poll, content download, models listing), wires it into the sidebar, extends the fixtures response-fields table with the new video error/b64/cost fields, notes poll-threshold sanitization on the fal.ai page, cross-links the OpenRouter surface from the video page, and adds it to the README multimedia bullet. --- README.md | 2 +- docs/fal-ai/index.html | 4 +- docs/fixtures/index.html | 9 +- docs/openrouter-video/index.html | 261 +++++++++++++++++++++++++++++++ docs/sidebar.js | 1 + docs/video/index.html | 5 + 6 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 docs/openrouter-video/index.html 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..17e15c7e --- /dev/null +++ b/docs/openrouter-video/index.html @@ -0,0 +1,261 @@ + + + + + + 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: { id: "v1", status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
+});
+
+// A failed job:
+mock.onVideo("impossible prompt", {
+  video: { id: "v2", status: "failed", error: "content policy violation" },
+});
+
+ +

The fixture's video object supports:

+
    +
  • + id, status"completed" or + "failed" set the job's terminal state (any other status is coerced to + completed, with a warning) +
  • +
  • error? — failure message surfaced on a failed status poll
  • +
  • + b64? — base64-encoded video bytes served by the content endpoint +
  • +
  • + cost? — generation cost surfaced as usage.cost on + completion +
  • +
  • + url? — ignored on this surface (the content endpoint serves bytes, + not a redirect); use b64 +
  • +
+ +

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 From 0f74b4fbc9145a05e96dd1ac93e10fe91c52fdc5 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 10 Jun 2026 13:11:07 -0700 Subject: [PATCH 39/39] docs: note video.id is ignored on the OpenRouter surface The Fixture Authoring bullet lumped id with status, implying the fixture's video.id is surfaced as the job id. On this surface the job id is always a server-minted UUID, so split the bullet: status alone drives terminal state, and id is annotated as ignored (the /v1/videos surface does use it). Drop the inert id fields from the page's code examples. --- docs/openrouter-video/index.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/openrouter-video/index.html b/docs/openrouter-video/index.html index 17e15c7e..1f510b38 100644 --- a/docs/openrouter-video/index.html +++ b/docs/openrouter-video/index.html @@ -114,21 +114,24 @@

Fixture Authoring

openrouter-video.test.ts ts
mock.onVideo("a cat playing piano", {
-  video: { id: "v1", status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
+  video: { status: "completed", b64: "AAAAGGZ0eXBpc29t...", cost: 0.12 },
 });
 
 // A failed job:
 mock.onVideo("impossible prompt", {
-  video: { id: "v2", status: "failed", error: "content policy violation" },
+  video: { status: "failed", error: "content policy violation" },
 });

The fixture's video object supports:

  • - id, status"completed" or - "failed" set the job's terminal state (any other status is coerced to - completed, with a warning) + status"completed" or "failed" sets the + job's terminal state (any other status is coerced to completed, with a warning) +
  • +
  • + id — ignored on this surface (the job id is always a server-minted + UUID); the /v1/videos surface does use it
  • error? — failure message surfaced on a failed status poll