From 3f240a38ce838eb64ff5c293d0819fc864856355 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 26 Jun 2026 15:55:14 -0700 Subject: [PATCH 1/4] feat(video): shared proxy module + server dispatch wiring for async video providers (#278) Introduce the shared async-video proxy module and wire server dispatch, types, and metrics for Veo/Grok providers. --- src/index.ts | 12 ++ src/metrics.ts | 29 ++++- src/openrouter-video.ts | 184 ++------------------------- src/server.ts | 253 ++++++++++++++++++++++++++++++++++++-- src/types.ts | 31 ++++- src/video-proxy-shared.ts | 197 +++++++++++++++++++++++++++++ 6 files changed, 518 insertions(+), 188 deletions(-) create mode 100644 src/video-proxy-shared.ts diff --git a/src/index.ts b/src/index.ts index f2264f7..838f3cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,18 @@ export { OPENROUTER_VIDEO_MAX_ENTRIES, OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES, } from "./openrouter-video.js"; +export { + handleVeoVideoCreate, + handleVeoVideoStatus, + VeoVideoJobMap, + VEO_VIDEO_MAX_ENTRIES, +} from "./veo-video.js"; +export { + handleGrokVideoCreate, + handleGrokVideoStatus, + GrokVideoJobMap, + GROK_VIDEO_MAX_ENTRIES, +} from "./grok-video.js"; export { handleElevenLabsAudio } from "./elevenlabs-audio.js"; export { handleFalQueue } from "./fal-audio.js"; export { handleFal, FalQueueStateMap } from "./fal.js"; diff --git a/src/metrics.ts b/src/metrics.ts index 48cb6e0..2c2b478 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -227,6 +227,16 @@ const VERTEX_RE = export const OPENROUTER_VIDEO_CONTENT_RE = /^\/api\/v1\/videos\/([^/]+)\/content$/; export const OPENROUTER_VIDEO_STATUS_RE = /^\/api\/v1\/videos\/([^/]+)$/; export const OPENAI_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; +// Exported: server.ts route dispatch matches the same Google Veo and xAI Grok +// video paths. Veo submit (`:predictLongRunning`) is anchored so it never +// collides with the bare Gemini `:predict` route; Veo operations live in a +// fresh `/v1beta/operations/...` namespace. Grok submit is an exact literal +// path; Grok status reuses the OpenAI `/v1/videos/{id}` shape (the dispatch +// guards `id !== "generations"` and the job-map lookup disambiguates Sora). +export const VEO_PREDICT_LRO_RE = /^\/v1beta\/models\/([^:]+):predictLongRunning$/; +export const VEO_OPERATION_RE = /^\/v1beta\/(operations\/.+)$/; +export const GROK_VIDEO_SUBMIT_PATH = "/v1/videos/generations"; +export const GROK_VIDEO_STATUS_RE = /^\/v1\/videos\/([^/]+)$/; /** * Normalize parametric API paths to route patterns for use as metric labels. @@ -272,7 +282,24 @@ export function normalizePathLabel(pathname: string): string { return "/api/v1/videos/{jobId}"; } - // OpenAI video status: /v1/videos/{id} + // Google Veo video: submit `:predictLongRunning` + poll `/v1beta/operations/{name}`. + // Operation names are random UUIDs, so the raw path would mint unbounded + // label cardinality. + if (VEO_PREDICT_LRO_RE.test(pathname)) { + return "/v1beta/models/{model}:predictLongRunning"; + } + if (VEO_OPERATION_RE.test(pathname)) { + return "/v1beta/operations/{name}"; + } + + // xAI Grok Imagine submit is a static literal path — keep it distinct from + // the `/v1/videos/{id}` status label below (which it would otherwise collapse + // into), exactly as `/api/v1/videos/models` is kept out of the jobId bucket. + if (pathname === GROK_VIDEO_SUBMIT_PATH) { + return GROK_VIDEO_SUBMIT_PATH; + } + + // OpenAI/Grok video status: /v1/videos/{id} if (OPENAI_VIDEO_STATUS_RE.test(pathname)) { return "/v1/videos/{id}"; } diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts index 4f4d53d..1f98452 100644 --- a/src/openrouter-video.ts +++ b/src/openrouter-video.ts @@ -21,7 +21,6 @@ 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"; @@ -35,6 +34,14 @@ import { sanitizeHeaderValue, } from "./recorder.js"; import { resolveUpstreamUrl } from "./url.js"; +import { + DEFAULT_UPSTREAM_TIMEOUT_MS, + readBodyIdle, + readEnvelopeText, + requestBase, + testIdSuffix, + upstreamTimeoutSignal, +} from "./video-proxy-shared.js"; /** * OpenRouter async video lifecycle mock (`/api/v1/videos`). Mirrors the @@ -255,98 +262,6 @@ function advanceJob(job: OpenRouterVideoReplayJob): void { } } -/** - * First non-empty value of a possibly array-typed, possibly comma-joined - * header. An empty header value or a leading-comma list (", host") would - * otherwise yield "" — triggering a spurious rejection warn and discarding - * valid later entries — so empty segments are skipped. - */ -function firstForwardedValue(header: string | string[] | undefined): string | undefined { - const raw = Array.isArray(header) ? header.join(",") : header; - if (raw === undefined) return undefined; - for (const segment of raw.split(",")) { - const trimmed = segment.trim(); - if (trimmed) return trimmed; - } - return undefined; -} - -// Conservative host[:port] shape for x-forwarded-host. Spaces, slashes, -// userinfo, or any other URL-structure character would corrupt (or smuggle -// paths into) the generated URLs the value is interpolated into. Underscores -// are admitted: the value feeds URL-string interpolation, not DNS validation, -// and underscore hostnames are routine in docker-compose/k8s networks -// (e.g. my_project_aimock:4010). -const FORWARDED_HOST_RE = /^[a-zA-Z0-9._-]+(:\d+)?$/; -// Bracketed IPv6 literal host[:port], e.g. [::1] or [::1]:8080 — the bare -// RE above cannot admit ":" inside the host without also admitting junk. -const FORWARDED_HOST_IPV6_RE = /^\[[0-9a-fA-F:.]+\](:\d+)?$/; - -function requestBase(req: http.IncomingMessage, logger: Logger): string { - // Honor x-forwarded-proto and x-forwarded-host so generated URLs survive a - // TLS-terminating or host-rewriting proxy in front of the mock. First - // non-empty value wins on comma-joined lists. - const candidate = firstForwardedValue(req.headers["x-forwarded-proto"])?.toLowerCase(); - // Allowlist http/https — any other value (ws, junk header data) falls back. - const proto = candidate === "http" || candidate === "https" ? candidate : "http"; - // Like the proto allowlist, a forwarded host that doesn't look like a bare - // host[:port] (or a bracketed IPv6 literal) falls back to the Host header — - // with a warn, so a misconfigured proxy isn't silently ignored. - const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]); - // The Host fallback gets the same host[:port] validation — a junk Host - // (e.g. "evil.com/path") could otherwise smuggle URL structure into the - // generated URLs. No warn: a missing/odd Host is transport-level noise, - // unlike a misconfigured proxy's x-forwarded-host. - const rawHost = req.headers.host; - let host = - rawHost !== undefined && - (FORWARDED_HOST_RE.test(rawHost) || FORWARDED_HOST_IPV6_RE.test(rawHost)) - ? rawHost - : "localhost"; - if (fwdHost !== undefined) { - if (FORWARDED_HOST_RE.test(fwdHost) || FORWARDED_HOST_IPV6_RE.test(fwdHost)) { - host = fwdHost; - } else { - logger.warn( - `x-forwarded-host value rejected, falling back to Host header: ${JSON.stringify(fwdHost.slice(0, 100))}`, - ); - } - } - return `${proto}://${host}`; -} - -/** - * Query-string suffix embedding the request's testId into generated URLs - * (polling_url, unsigned_urls). The @openrouter/sdk fetches these URLs with - * standard Authorization but no aimock-specific headers (no x-test-id) — so - * the testId must travel in the URL for getTestId's `?testId=` fallback to - * resolve the right job scope. The default testId is omitted to keep - * single-tenant URLs clean. - */ -function testIdSuffix(testId: string, sep: "?" | "&"): string { - return testId === DEFAULT_TEST_ID ? "" : `${sep}testId=${encodeURIComponent(testId)}`; -} - -/** Default upstream timeout for the live lifecycle proxy (matches recorder.ts). */ -const DEFAULT_UPSTREAM_TIMEOUT_MS = 30_000; - -/** - * Abort signal for the SMALL-JSON upstream fetches on this surface (submit, - * status poll, models listing), honoring `record.upstreamTimeoutMs` with the - * same clamp conventions as recorder.ts (non-finite / non-positive values - * fall back to the 30s default). Note the nuance (documented on - * RecordConfig.upstreamTimeoutMs): these envelope-sized fetches use the value - * as a TOTAL deadline via AbortSignal.timeout — indistinguishable from a - * socket-idle timeout for small bodies. The byte-bearing content fetches use - * `fetchHeadersWithTimeout` + `readBodyIdle` instead, which implement true - * idle semantics. An abort rejects the fetch and surfaces through the - * caller's existing failure path (502 proxy_error on submit/poll, models - * synthesis fallback). - */ -function upstreamTimeoutSignal(record: RecordConfig | undefined): AbortSignal { - return AbortSignal.timeout(clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS)); -} - /** * Fetch a byte-bearing upstream URL with `record.upstreamTimeoutMs` gating * only the HEADERS: the abort timer is cleared the moment the response head @@ -372,63 +287,6 @@ async function fetchHeadersWithTimeout( } } -/** - * Buffer a fetch Response body with IDLE-based timeout semantics: a timer - * clamped from `record.bodyTimeoutMs` (30s default — same clamp as - * recorder.ts) is re-armed for every chunk, so a steadily-dripping body of - * any total duration completes and only a silent mid-body stall rejects. - * When `cap` > 0 the byte count is enforced DURING the read: on exceed the - * stream is cancelled and `{ overCap: true }` is returned with NOTHING - * oversized retained in memory — the cap is a memory guard as much as a - * disk guard. - */ -async function readBodyIdle( - res: Response, - record: RecordConfig | undefined, - cap = 0, -): Promise<{ overCap: false; buf: Buffer } | { overCap: true; bytesRead: number }> { - const body = res.body; - if (!body) return { overCap: false, buf: Buffer.alloc(0) }; - const idleMs = clampTimeout(record?.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS); - const reader = body.getReader(); - const chunks: Buffer[] = []; - let total = 0; - try { - for (;;) { - let idleTimer: NodeJS.Timeout | undefined; - // A stream error landing AFTER the idle timeout has already won the - // race must never become an unhandledRejection (process crash) — attach - // a no-op rejection handler to the read promise BEFORE racing. - // Promise.race subscribes to its inputs too, so this is deliberate - // defense-in-depth pinning the invariant against a refactor that races - // the read differently; the race below still observes the rejection - // normally when the read loses first. - const readPromise = reader.read(); - readPromise.catch(() => {}); - const result = await Promise.race([ - readPromise, - new Promise((_, reject) => { - idleTimer = setTimeout( - () => reject(new Error(`Upstream response body idle for ${idleMs}ms`)), - idleMs, - ); - }), - ]).finally(() => clearTimeout(idleTimer)); - if (result.done) break; - total += result.value.byteLength; - if (cap > 0 && total > cap) { - return { overCap: true, bytesRead: total }; - } - chunks.push(Buffer.from(result.value)); - } - } finally { - // Idle expiry and the over-cap early return leave the stream open — - // release it. After a normal completion this is a no-op. - void reader.cancel().catch(() => {}); - } - return { overCap: false, buf: Buffer.concat(chunks) }; -} - /** * Cap on an upstream ERROR body (401/403 relays) buffered by the content * proxy. Error bodies are envelope-sized JSON in practice — 64 KB is generous @@ -437,32 +295,6 @@ async function readBodyIdle( */ const OPENROUTER_VIDEO_ERROR_BODY_CAP = 64 * 1024; -/** - * Cap on the small-JSON upstream envelope bodies (submit, status poll, models - * listing) buffered before parse/relay. Envelopes are KB-scale in practice — - * 1 MB is generous headroom even for a large models listing — and, like the - * error-body cap above, they are buffered in full, so an unbounded `text()` - * would be a memory hole on a hostile upstream. - */ -const OPENROUTER_VIDEO_ENVELOPE_BODY_CAP = 1024 * 1024; - -/** - * Bounded read of a small-JSON upstream envelope body (submit, status poll, - * models listing): readBodyIdle's idle semantics plus the envelope cap. An - * over-cap body is a hard failure — the throw surfaces through each caller's - * existing failure path (502 proxy_error on submit/poll, models synthesis - * fallback) without retaining anything oversized in memory. - */ -async function readEnvelopeText(res: Response, record: RecordConfig | undefined): Promise { - const read = await readBodyIdle(res, record, OPENROUTER_VIDEO_ENVELOPE_BODY_CAP); - if (read.overCap) { - throw new Error( - `Upstream envelope body exceeded ${OPENROUTER_VIDEO_ENVELOPE_BODY_CAP} bytes (read aborted at ${read.bytesRead})`, - ); - } - return read.buf.toString("utf8"); -} - /** * Rewrite an upstream poll body for relay to the client. Every field is * relayed verbatim EXCEPT the ones that would let the client escape the mock diff --git a/src/server.ts b/src/server.ts index b254e5f..31f57e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -56,7 +56,7 @@ import { handleEmbeddings } from "./embeddings.js"; import { handleImages, handleImageEdit, handleImageVariations } from "./images.js"; import { handleSpeech } from "./speech.js"; import { handleTranscription } from "./transcription.js"; -import { handleVideoCreate, handleVideoStatus, VideoStateMap } from "./video.js"; +import { handleVideoCreate, VideoStateMap } from "./video.js"; import { handleOpenRouterVideoCreate, handleOpenRouterVideoStatus, @@ -65,6 +65,8 @@ import { OpenRouterVideoJobMap, OPENROUTER_VIDEO_DEFAULT_MAX_CONTENT_BYTES, } from "./openrouter-video.js"; +import { handleVeoVideoCreate, handleVeoVideoStatus, VeoVideoJobMap } from "./veo-video.js"; +import { handleGrokVideoCreate, handleGrokVideoStatus, GrokVideoJobMap } from "./grok-video.js"; import { handleElevenLabsAudio, handleElevenLabsTTS } from "./elevenlabs-audio.js"; import { handleFalQueue, falJobs } from "./fal-audio.js"; import { handleFal, falQueueStates } from "./fal.js"; @@ -82,9 +84,12 @@ import { applyChaosAction, evaluateChaos } from "./chaos.js"; import { createMetricsRegistry, normalizePathLabel, - OPENAI_VIDEO_STATUS_RE, OPENROUTER_VIDEO_CONTENT_RE, OPENROUTER_VIDEO_STATUS_RE, + VEO_PREDICT_LRO_RE, + VEO_OPERATION_RE, + GROK_VIDEO_SUBMIT_PATH, + GROK_VIDEO_STATUS_RE, } from "./metrics.js"; import { proxyAndRecord } from "./recorder.js"; @@ -95,6 +100,8 @@ export interface ServerInstance { defaults: HandlerDefaults; videoStates: VideoStateMap; openRouterVideoJobs: OpenRouterVideoJobMap; + veoVideoJobs: VeoVideoJobMap; + grokVideoJobs: GrokVideoJobMap; } const COMPLETIONS_PATH = "/v1/chat/completions"; @@ -249,12 +256,16 @@ function performFixturesReset( journal: Journal, videoStates: VideoStateMap, openRouterVideoJobs: OpenRouterVideoJobMap, + veoVideoJobs: VeoVideoJobMap, + grokVideoJobs: GrokVideoJobMap, defaults: HandlerDefaults, ): void { fixtures.length = 0; journal.clear(); videoStates.clear(); openRouterVideoJobs.clear(); + veoVideoJobs.clear(); + grokVideoJobs.clear(); falJobs.clear(); falQueueStates.clear(); resetInteractionCounter(); @@ -276,6 +287,8 @@ async function handleControlAPI( journal: Journal, videoStates: VideoStateMap, openRouterVideoJobs: OpenRouterVideoJobMap, + veoVideoJobs: VeoVideoJobMap, + grokVideoJobs: GrokVideoJobMap, defaults: HandlerDefaults, ): Promise { if (!pathname.startsWith(CONTROL_PREFIX)) return false; @@ -358,7 +371,15 @@ async function handleControlAPI( // POST /__aimock/reset/fixtures — full reset (fixtures + journal + match counts) if (subPath === "/reset/fixtures" && req.method === "POST") { - performFixturesReset(fixtures, journal, videoStates, openRouterVideoJobs, defaults); + performFixturesReset( + fixtures, + journal, + videoStates, + openRouterVideoJobs, + veoVideoJobs, + grokVideoJobs, + defaults, + ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ reset: true })); return true; @@ -375,7 +396,15 @@ async function handleControlAPI( // POST /__aimock/reset — DEPRECATED alias for /reset/fixtures (full reset) if (subPath === "/reset" && req.method === "POST") { - performFixturesReset(fixtures, journal, videoStates, openRouterVideoJobs, defaults); + performFixturesReset( + fixtures, + journal, + videoStates, + openRouterVideoJobs, + veoVideoJobs, + grokVideoJobs, + 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( @@ -1111,6 +1140,12 @@ export async function createServer( get openRouterVideo() { return serverOptions.openRouterVideo; }, + get veoVideo() { + return serverOptions.veoVideo; + }, + get grokVideo() { + return serverOptions.grokVideo; + }, }; // Validate chaos config rates @@ -1132,6 +1167,8 @@ export async function createServer( for (const { name, config } of [ { name: "falQueue", config: options?.falQueue }, { name: "openRouterVideo", config: options?.openRouterVideo }, + { name: "veoVideo", config: options?.veoVideo }, + { name: "grokVideo", config: options?.grokVideo }, ]) { if (!config) continue; for (const field of ["pollsBeforeInProgress", "pollsBeforeCompleted"] as const) { @@ -1168,6 +1205,8 @@ export async function createServer( }); const videoStates = new VideoStateMap(); const openRouterVideoJobs = new OpenRouterVideoJobMap(); + const veoVideoJobs = new VeoVideoJobMap(); + const grokVideoJobs = new GrokVideoJobMap(); // Share journal and metrics registry with mounted services if (mounts) { @@ -1249,6 +1288,8 @@ export async function createServer( journal, videoStates, openRouterVideoJobs, + veoVideoJobs, + grokVideoJobs, defaults, ); return; @@ -1940,6 +1981,54 @@ export async function createServer( return; } + // POST /v1/videos/generations — xAI Grok Imagine video submit. A distinct + // exact path from Sora's POST /v1/videos (no collision). Must precede the + // Sora GET status RE which would otherwise parse `generations` as an id on + // a GET — but this is a POST, so the guard is method-level here and + // `id !== "generations"` in the GET block below. (T0: stub filled in T2.) + if (pathname === GROK_VIDEO_SUBMIT_PATH && req.method === "POST") { + setCorsHeaders(res); + try { + const raw = await readBody(req); + await handleGrokVideoCreate( + req, + res, + raw, + fixtures, + journal, + defaults, + setCorsHeaders, + grokVideoJobs, + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + defaults.logger.error(`grok-video submit: ${msg}`); + if (!res.headersSent) { + try { + journal.add({ + method: req.method ?? "POST", + path: req.url ?? pathname, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 500, fixture: null }, + }); + } catch (jErr) { + defaults.logger.warn( + `grok-video submit: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, + ); + } + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + res.destroy(); + } + } + return; + } + // POST /v1/videos — Video Generation API if (pathname === VIDEOS_PATH && req.method === "POST") { try { @@ -1969,11 +2058,144 @@ export async function createServer( return; } - // GET /v1/videos/{id} — Video Status Check - 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); + // GET /v1/videos/{id} — Grok-first video status check. Grok Imagine and + // Sora share this path: handleGrokVideoStatus does a job-map-first lookup + // and falls through to the UNCHANGED Sora handleVideoStatus on a Grok miss + // (disjoint id namespaces → unambiguous). The `id !== "generations"` guard + // keeps the Grok submit literal out of the status RE. (T0: the Grok job map + // is always empty, so every GET delegates to Sora — behavior-preserving.) + const grokVideoStatusMatch = pathname.match(GROK_VIDEO_STATUS_RE); + if (grokVideoStatusMatch && grokVideoStatusMatch[1] !== "generations" && req.method === "GET") { + try { + await handleGrokVideoStatus( + req, + res, + grokVideoStatusMatch[1], + fixtures, + journal, + defaults, + setCorsHeaders, + grokVideoJobs, + videoStates, + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + defaults.logger.error(`grok-video status: ${msg}`); + if (!res.headersSent) { + try { + journal.add({ + method: req.method ?? "GET", + path: req.url ?? pathname, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 500, fixture: null }, + }); + } catch (jErr) { + defaults.logger.warn( + `grok-video status: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, + ); + } + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + res.destroy(); + } + } + return; + } + + // POST /v1beta/models/{model}:predictLongRunning — Google Veo video submit. + // Anchored on `:predictLongRunning`, so it never collides with the Gemini + // `:predict` Imagen route below. (T0: handler is a stub filled in T1.) + const veoPredictMatch = pathname.match(VEO_PREDICT_LRO_RE); + if (veoPredictMatch && req.method === "POST") { + setCorsHeaders(res); + try { + const raw = await readBody(req); + await handleVeoVideoCreate( + req, + res, + raw, + veoPredictMatch[1], + fixtures, + journal, + defaults, + setCorsHeaders, + veoVideoJobs, + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + defaults.logger.error(`veo-video submit: ${msg}`); + if (!res.headersSent) { + try { + journal.add({ + method: req.method ?? "POST", + path: req.url ?? pathname, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 500, fixture: null }, + }); + } catch (jErr) { + defaults.logger.warn( + `veo-video submit: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, + ); + } + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + res.destroy(); + } + } + return; + } + + // GET /v1beta/operations/{name} — Google Veo video status poll. + // (T0: handler is a stub filled in T1.) + const veoOperationMatch = pathname.match(VEO_OPERATION_RE); + if (veoOperationMatch && req.method === "GET") { + try { + await handleVeoVideoStatus( + req, + res, + veoOperationMatch[1], + fixtures, + journal, + defaults, + setCorsHeaders, + veoVideoJobs, + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + defaults.logger.error(`veo-video status: ${msg}`); + if (!res.headersSent) { + try { + journal.add({ + method: req.method ?? "GET", + path: req.url ?? pathname, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 500, fixture: null }, + }); + } catch (jErr) { + defaults.logger.warn( + `veo-video status: journal write failed after handler error: ${jErr instanceof Error ? jErr.message : String(jErr)}`, + ); + } + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + res.destroy(); + } + } return; } @@ -2716,6 +2938,8 @@ export async function createServer( activeConnections.clear(); videoStates.clear(); openRouterVideoJobs.clear(); + veoVideoJobs.clear(); + grokVideoJobs.clear(); originalClose(callback); return this; } as typeof server.close; @@ -2737,7 +2961,16 @@ export async function createServer( } } - resolve({ server, journal, url, defaults, videoStates, openRouterVideoJobs }); + resolve({ + server, + journal, + url, + defaults, + videoStates, + openRouterVideoJobs, + veoVideoJobs, + grokVideoJobs, + }); }); }); } diff --git a/src/types.ts b/src/types.ts index bd4e13f..dceaca5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,14 @@ export interface ChatCompletionRequest { _endpointType?: string; /** Context identifier, set by handlers for fixture context routing. */ _context?: string; + /** + * Video-provider discriminator, set by the async video handlers for journal + * and dispatch clarity only. NOT a fixture match key — `buildFixtureMatch` + * derives the endpoint from `_endpointType` and never reads this field; the + * model string is the provider disambiguator (veo-* / grok-imagine-* / + * openrouter ids do not overlap). + */ + _videoProvider?: "openrouter" | "veo" | "grok"; [key: string]: unknown; } @@ -261,6 +269,8 @@ export interface VideoResponse { b64?: string; /** Generation cost surfaced in usage envelopes (e.g. OpenRouter `usage.cost`). */ cost?: number; + /** Clip duration in seconds surfaced by some providers (e.g. Grok `video.duration`). */ + duration?: number; }; } @@ -613,7 +623,9 @@ export type RecordProviderKey = | "cohere" | "elevenlabs" | "fal" - | "openrouter"; + | "openrouter" + | "veo" + | "grok"; export interface RecordConfig { providers: Partial>; @@ -785,6 +797,21 @@ export interface MockServerOptions { * the first status poll merely reports the already-terminal status. */ openRouterVideo?: FalQueueConfig; + /** + * Configure Google Veo async video job polling progression + * (`done:false → done:true` on `GET /v1beta/operations/{name}`). Same + * threshold semantics as `openRouterVideo`; the internal + * `pending → in_progress → completed | failed` model is serialized to the + * two-state Veo wire. + */ + veoVideo?: FalQueueConfig; + /** + * Configure xAI Grok Imagine async video job polling progression + * (`pending → done | failed` on `GET /v1/videos/{request_id}`). Same + * threshold semantics as `openRouterVideo`; progress is synthesized from the + * poll count. + */ + grokVideo?: FalQueueConfig; } /** @@ -833,4 +860,6 @@ export interface HandlerDefaults { requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest; falQueue?: FalQueueConfig; openRouterVideo?: FalQueueConfig; + veoVideo?: FalQueueConfig; + grokVideo?: FalQueueConfig; } diff --git a/src/video-proxy-shared.ts b/src/video-proxy-shared.ts new file mode 100644 index 0000000..30fdf2a --- /dev/null +++ b/src/video-proxy-shared.ts @@ -0,0 +1,197 @@ +import type * as http from "node:http"; +import type { RecordConfig } from "./types.js"; +import type { Logger } from "./logger.js"; +import { DEFAULT_TEST_ID } from "./constants.js"; +import { clampTimeout } from "./recorder.js"; + +/** + * Shared upstream-proxy helpers for the async video-generation handlers + * (OpenRouter / Veo / Grok). These are the provider-agnostic primitives every + * live record-mode proxy needs: request-base derivation behind a forwarding + * proxy, testId URL-suffix embedding, upstream timeout signals, and bounded + * idle-based body reads. They were extracted verbatim from openrouter-video.ts + * so all three handlers share one implementation rather than triplicating it. + * + * NOT included here (byte-download only, OpenRouter-specific): + * `fetchHeadersWithTimeout` and the streaming content relay. Veo serves the + * Files-API uri as-is and Grok serves `video.url` as-is — neither downloads + * video bytes — so those stay private to openrouter-video.ts. + */ + +/** + * 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. + */ +export function firstForwardedValue(header: string | string[] | undefined): string | undefined { + const raw = Array.isArray(header) ? header.join(",") : header; + if (raw === undefined) return undefined; + for (const segment of raw.split(",")) { + const trimmed = segment.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +// Conservative host[:port] shape for x-forwarded-host. Spaces, slashes, +// userinfo, or any other URL-structure character would corrupt (or smuggle +// paths into) the generated URLs the value is interpolated into. Underscores +// are admitted: the value feeds URL-string interpolation, not DNS validation, +// and underscore hostnames are routine in docker-compose/k8s networks +// (e.g. my_project_aimock:4010). +const FORWARDED_HOST_RE = /^[a-zA-Z0-9._-]+(:\d+)?$/; +// Bracketed IPv6 literal host[:port], e.g. [::1] or [::1]:8080 — the bare +// RE above cannot admit ":" inside the host without also admitting junk. +const FORWARDED_HOST_IPV6_RE = /^\[[0-9a-fA-F:.]+\](:\d+)?$/; + +export function requestBase(req: http.IncomingMessage, logger: Logger): string { + // Honor x-forwarded-proto and x-forwarded-host so generated URLs survive a + // TLS-terminating or host-rewriting proxy in front of the mock. First + // non-empty value wins on comma-joined lists. + const candidate = firstForwardedValue(req.headers["x-forwarded-proto"])?.toLowerCase(); + // Allowlist http/https — any other value (ws, junk header data) falls back. + const proto = candidate === "http" || candidate === "https" ? candidate : "http"; + // Like the proto allowlist, a forwarded host that doesn't look like a bare + // host[:port] (or a bracketed IPv6 literal) falls back to the Host header — + // with a warn, so a misconfigured proxy isn't silently ignored. + const fwdHost = firstForwardedValue(req.headers["x-forwarded-host"]); + // The Host fallback gets the same host[:port] validation — a junk Host + // (e.g. "evil.com/path") could otherwise smuggle URL structure into the + // generated URLs. No warn: a missing/odd Host is transport-level noise, + // unlike a misconfigured proxy's x-forwarded-host. + const rawHost = req.headers.host; + let host = + rawHost !== undefined && + (FORWARDED_HOST_RE.test(rawHost) || FORWARDED_HOST_IPV6_RE.test(rawHost)) + ? rawHost + : "localhost"; + if (fwdHost !== undefined) { + if (FORWARDED_HOST_RE.test(fwdHost) || FORWARDED_HOST_IPV6_RE.test(fwdHost)) { + host = fwdHost; + } else { + logger.warn( + `x-forwarded-host value rejected, falling back to Host header: ${JSON.stringify(fwdHost.slice(0, 100))}`, + ); + } + } + return `${proto}://${host}`; +} + +/** + * Query-string suffix embedding the request's testId into generated URLs + * (polling_url, unsigned_urls). The provider 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. + */ +export function testIdSuffix(testId: string, sep: "?" | "&"): string { + return testId === DEFAULT_TEST_ID ? "" : `${sep}testId=${encodeURIComponent(testId)}`; +} + +/** Default upstream timeout for the live lifecycle proxy (matches recorder.ts). */ +export const DEFAULT_UPSTREAM_TIMEOUT_MS = 30_000; + +/** + * Abort signal for the SMALL-JSON upstream fetches on this surface (submit, + * status poll, models listing), honoring `record.upstreamTimeoutMs` with the + * same clamp conventions as recorder.ts (non-finite / non-positive values + * fall back to the 30s default). Note the nuance (documented on + * RecordConfig.upstreamTimeoutMs): these envelope-sized fetches use the value + * as a TOTAL deadline via AbortSignal.timeout — indistinguishable from a + * socket-idle timeout for small bodies. The byte-bearing content fetches use + * `fetchHeadersWithTimeout` + `readBodyIdle` instead, which implement true + * idle semantics. An abort rejects the fetch and surfaces through the + * caller's existing failure path (502 proxy_error on submit/poll, models + * synthesis fallback). + */ +export function upstreamTimeoutSignal(record: RecordConfig | undefined): AbortSignal { + return AbortSignal.timeout(clampTimeout(record?.upstreamTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS)); +} + +/** + * Buffer a fetch Response body with IDLE-based timeout semantics: a timer + * clamped from `record.bodyTimeoutMs` (30s default — same clamp as + * recorder.ts) is re-armed for every chunk, so a steadily-dripping body of + * any total duration completes and only a silent mid-body stall rejects. + * When `cap` > 0 the byte count is enforced DURING the read: on exceed the + * stream is cancelled and `{ overCap: true }` is returned with NOTHING + * oversized retained in memory — the cap is a memory guard as much as a + * disk guard. + */ +export async function readBodyIdle( + res: Response, + record: RecordConfig | undefined, + cap = 0, +): Promise<{ overCap: false; buf: Buffer } | { overCap: true; bytesRead: number }> { + const body = res.body; + if (!body) return { overCap: false, buf: Buffer.alloc(0) }; + const idleMs = clampTimeout(record?.bodyTimeoutMs, DEFAULT_UPSTREAM_TIMEOUT_MS); + const reader = body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + try { + for (;;) { + let idleTimer: NodeJS.Timeout | undefined; + // A stream error landing AFTER the idle timeout has already won the + // race must never become an unhandledRejection (process crash) — attach + // a no-op rejection handler to the read promise BEFORE racing. + // Promise.race subscribes to its inputs too, so this is deliberate + // defense-in-depth pinning the invariant against a refactor that races + // the read differently; the race below still observes the rejection + // normally when the read loses first. + const readPromise = reader.read(); + readPromise.catch(() => {}); + const result = await Promise.race([ + readPromise, + new Promise((_, reject) => { + idleTimer = setTimeout( + () => reject(new Error(`Upstream response body idle for ${idleMs}ms`)), + idleMs, + ); + }), + ]).finally(() => clearTimeout(idleTimer)); + if (result.done) break; + total += result.value.byteLength; + if (cap > 0 && total > cap) { + return { overCap: true, bytesRead: total }; + } + chunks.push(Buffer.from(result.value)); + } + } finally { + // Idle expiry and the over-cap early return leave the stream open — + // release it. After a normal completion this is a no-op. + void reader.cancel().catch(() => {}); + } + return { overCap: false, buf: Buffer.concat(chunks) }; +} + +/** + * Cap on the small-JSON upstream envelope bodies (submit, status poll, models + * listing) buffered before parse/relay. Envelopes are KB-scale in practice — + * 1 MB is generous headroom even for a large models listing — and they are + * buffered in full, so an unbounded `text()` would be a memory hole on a + * hostile upstream. + */ +export const VIDEO_PROXY_ENVELOPE_BODY_CAP = 1024 * 1024; + +/** + * Bounded read of a small-JSON upstream envelope body (submit, status poll, + * models listing): readBodyIdle's idle semantics plus the envelope cap. An + * over-cap body is a hard failure — the throw surfaces through each caller's + * existing failure path (502 proxy_error on submit/poll, models synthesis + * fallback) without retaining anything oversized in memory. + */ +export async function readEnvelopeText( + res: Response, + record: RecordConfig | undefined, +): Promise { + const read = await readBodyIdle(res, record, VIDEO_PROXY_ENVELOPE_BODY_CAP); + if (read.overCap) { + throw new Error( + `Upstream envelope body exceeded ${VIDEO_PROXY_ENVELOPE_BODY_CAP} bytes (read aborted at ${read.bytesRead})`, + ); + } + return read.buf.toString("utf8"); +} From 399862da12160d7bdd76cc9db6946acca3add0ed Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 26 Jun 2026 15:55:18 -0700 Subject: [PATCH 2/4] feat(video): native Google Veo predictLongRunning lifecycle with record-mode (#278) Implement the native Veo predictLongRunning poll lifecycle with record-mode fixtures, tests, and provider docs. --- docs/veo-video/index.html | 302 +++++++ src/__tests__/veo-video-record.test.ts | 455 ++++++++++ src/__tests__/veo-video.test.ts | 280 ++++++ src/veo-video.ts | 1133 ++++++++++++++++++++++++ 4 files changed, 2170 insertions(+) create mode 100644 docs/veo-video/index.html create mode 100644 src/__tests__/veo-video-record.test.ts create mode 100644 src/__tests__/veo-video.test.ts create mode 100644 src/veo-video.ts diff --git a/docs/veo-video/index.html b/docs/veo-video/index.html new file mode 100644 index 0000000..7de9b6e --- /dev/null +++ b/docs/veo-video/index.html @@ -0,0 +1,302 @@ + + + + + + Google Veo Video — aimock + + + + + + + + + + +
+ + +
+

Google Veo Video

+

+ aimock mocks Google's native Veo async video-generation API — submit a long-running + operation under POST /v1beta/models/{model}:predictLongRunning, then poll it + through done: false → done: true under + GET /v1beta/operations/{name}. It draws from the same + endpoint: "video" fixture pool as the other video surfaces, disambiguated by + the veo-* model string. +

+ +

Endpoints

+ + + + + + + + + + + + + + + + + + + + +
MethodPathResponse
POST/v1beta/models/{model}:predictLongRunning + { name: "operations/…" } — the long-running operation + handle; the matched fixture's video drives the operation's terminal + state +
GET/v1beta/operations/{name} + { name, done: false } while running, then + { name, done: true, response.generateVideoResponse.generatedSamples[0].video.uri + } + once complete — or { name, done: true, error } on failure +
+ +

+ Veo has no collision with the Gemini :predict route (anchored on + :predict$, so :predictLongRunning never matches it) and + /v1beta/operations/… is a fresh namespace. There is no model-listing + endpoint, and — deliberately — no content/download endpoint (see + Files-API uri). +

+ +

Fixture Authoring

+

+ Submits are matched against endpoint: "video" fixtures on the request's + prompt (via match.userMessage) and model (via + match.model). The Veo create body's prompt is read from + instances[0].prompt. The veo-* model string is the provider + disambiguator — Veo, Grok, and OpenRouter video fixtures share one match namespace, + and their model tokens never overlap. +

+ +
+
veo-video.test.ts ts
+
mock.onVideo("a cat playing piano", {
+  // `id` is required by the type but ignored on this surface (the operation
+  // name is always server-minted); `url` is the Files-API uri served as-is.
+  video: { id: "vid_1", status: "completed", url: "https://generativelanguage.googleapis.com/v1beta/files/abc" },
+}, { model: "veo-3.1-generate-preview" });
+
+// A failed operation:
+mock.onVideo("impossible prompt", {
+  video: { id: "vid_2", status: "failed", error: "content policy violation" },
+}, { model: "veo-3.1-generate-preview" });
+
+ +

The fixture's video object supports:

+
    +
  • + status"completed" or "failed" sets the + operation's terminal state. The wire terminal signal is the boolean + done field: a stored "completed" or + "failed" status serializes to done: true (and a non-terminal + poll to done: false). There is no "done" status string on this + surface — only the boolean. +
  • +
  • + id — ignored on this surface (the operation name is always a + server-minted operations/<uuid>) +
  • +
  • + url? — the Files-API uri returned in + generatedSamples[0].video.uri on a completed poll; defaults to a + Files-API-shaped placeholder when omitted +
  • +
  • error? — failure message surfaced on a failed-operation poll
  • +
  • + cost? — not applicable to Veo. The Veo operations envelope carries no + cost field, so cost is neither captured in record mode nor round-tripped on this + surface. +
  • +
+ +

Polling Realism

+

+ By default a submitted operation is seeded terminal internally — the first status + poll already reports done: true. To exercise client code that polls through + intermediate states, pass veoVideo with poll thresholds. The semantics are + identical to falQueue and the + OpenRouter video surface: aimock keeps a + pending → in_progress → completed model internally and serializes + both non-terminal states to the Veo wire's done: false. +

+ +
+
polling.test.ts ts
+
const mock = new LLMock({
+  port: 0,
+  veoVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
+});
+
+// Submit  → { name: "operations/…" }
+// poll 1  → { name, done: false }
+// poll 2  → { name, done: true, response.generateVideoResponse.generatedSamples[0].video.uri }
+
+ +

+ Thresholds are sanitized exactly as on the other video surfaces: non-finite values + (NaN, Infinity) are treated as unset, negative or fractional + values are floored and clamped to non-negative integers, and an explicit + { pollsBeforeInProgress: 0, pollsBeforeCompleted: 0 } still seeds the + operation terminal at submit. createServer warns at startup on invalid + values. +

+ +

Files-API uri — served as-is, no byte proxying

+
+

+ aimock does not proxy or capture Veo video bytes. The Veo API returns + the rendered clip as a Files-API uri inside + generatedSamples[0].video.uri, and aimock serves that uri + verbatim — there is no content/download endpoint, no base64, and no byte + relay. A fixture stores the uri in video.url (a Files-API-shaped + https://generativelanguage.googleapis.com/v1beta/files/… placeholder + is served when omitted), and record mode persists the upstream uri as-is. Clients fetch + the actual bytes from the Files API themselves, exactly as against the real provider. +

+
+ +

Errors

+

+ Malformed or non-object JSON, an empty prompt, and an unknown operation all return the + Gemini error envelope { error: { code, message, status } } — 400 for a + bad submit body, 404 for an unknown operation. A matched failed fixture polls + to { name, done: true, error: { code, message } }. +

+ +

Chaos & Metrics

+

+ Chaos injection applies to both routes. In + Prometheus metrics the per-operation path is templated as + /v1beta/models/{model}:predictLongRunning and + /v1beta/operations/{name} to keep label cardinality bounded. +

+ +

Record Mode

+

+ With record mode and the veo provider + configured, an unmatched submit becomes a live interactive proxy: the + :predictLongRunning POST is forwarded to the real API and answered with a + mock-rewritten { name } envelope (a fresh aimock operation name), and each + client poll is proxied upstream 1:1 with the mock operation name substituted. The client's + own polling drives the upstream lifecycle — there is no server-side queue walk. +

+ +
+
aimock.config.json json
+
{
+  "llm": {
+    "fixtures": "./fixtures",
+    "record": {
+      "providers": { "veo": "https://generativelanguage.googleapis.com" }
+    }
+  }
+}
+
+ +
+
Programmatic config ts
+
const mock = new LLMock({
+  record: {
+    providers: { veo: "https://generativelanguage.googleapis.com" },
+  },
+});
+
+ +

+ When the upstream poll reports done: true, the body is relayed immediately + and an eager capture runs in the background: aimock reads the Files-API uri + straight out of the terminal poll body (no download — there are no bytes to fetch) + and persists a normal video fixture (match.userMessage = the prompt, + match.model = the submitted model under the standard model-normalization + rules, video.id = the upstream operation name, video.status = + "completed", video.url = the upstream uri). The Veo operations + envelope carries no cost field, so no cost is captured. The same submit then replays + in-session and across sessions. Relayed poll bodies are otherwise faithful: every upstream + field passes through verbatim — including the Files-API uri inside + response — with only the operation name rewritten to the mock id. A + failed operation persists { status: "failed", error }; any + terminal status that is not representable in video.status passes through with + a warning and is never recorded. +

+ +

+ The polling client's Bearer credential is forwarded only to the configured provider + origin: the upstream poll URL is adopted only when same-origin (an off-origin or + unparseable URL falls back to the constructed path on the provider origin, with a + warning). Strict mode wins over record: a strict no-match returns 503 and nothing is + proxied. Without a configured veo provider URL, --record warns + and serves the normal no-match 404. Under proxy-only mode (record.proxyOnly / + --proxy-only) nothing is persisted and a completed operation is never + converted to a local replay job — every poll keeps proxying upstream. +

+ +
+

+ TTL caveat: record-mode operations live in the same bounded job map as + replay operations (1-hour TTL, 10,000 entries). Each successful proxied poll refreshes a + record operation's TTL, so an actively-polled long render is never evicted mid-recording + — but a poll arriving more than an hour after the last successful poll + finds the operation evicted and returns 404. +

+
+
+ +
+ +
+ +
+ + + + diff --git a/src/__tests__/veo-video-record.test.ts b/src/__tests__/veo-video-record.test.ts new file mode 100644 index 0000000..e0eb2c8 --- /dev/null +++ b/src/__tests__/veo-video-record.test.ts @@ -0,0 +1,455 @@ +import { describe, test, expect, afterAll, afterEach, vi } from "vitest"; +import * as http from "node:http"; +import * as net from "node:net"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { LLMock } from "../llmock.js"; +import { startRefusingUpstream } from "./helpers/refusing-upstream.js"; + +// ─── Protocol-aware stub Veo upstream ──────────────────────────────────────── +// A real http.createServer on 127.0.0.1:0 implementing the two Veo endpoints — +// submit (POST :predictLongRunning → { name }) and operation poll (GET +// /v1beta/operations/{name} → done:false ... done:true with the Files-API uri). +// Tracks per-endpoint call counts and the last-received headers. + +interface VeoUpstreamOptions { + /** Non-terminal polls served before done:true. Default: 0. */ + pollsBeforeDone?: number; + /** Files-API uri served on done:true. Default: a stub files url. */ + uri?: string; + /** Terminal failure instead of success: { code, message }. */ + failError?: { code: number; message: string }; + /** Force the SUBMIT endpoint to reply with this HTTP status (JSON error). */ + submitHttpStatus?: number; + /** Accept the submit and never respond (socket hang). */ + hangOnSubmit?: boolean; +} + +interface VeoUpstream { + url: string; + close: () => Promise; + counts: { submit: number; status: number }; + lastHeaders: { submit?: http.IncomingHttpHeaders; status?: http.IncomingHttpHeaders }; +} + +const UPSTREAM_OP_NAME = "operations/up-veo-1"; + +function startVeoVideoUpstream(opts: VeoUpstreamOptions): Promise { + const pollsBeforeDone = opts.pollsBeforeDone ?? 0; + const uri = opts.uri ?? "https://files.example/upstream.mp4"; + const counts = { submit: 0, status: 0 }; + const lastHeaders: VeoUpstream["lastHeaders"] = {}; + const opPolls = new Map(); + const submitRe = /^\/v1beta\/models\/([^:]+):predictLongRunning$/; + const operationRe = /^\/v1beta\/(operations\/.+)$/; + + return new Promise((resolve, reject) => { + let selfUrl = "http://stub"; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const url = new URL(req.url ?? "/", selfUrl); + const sendJson = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + if (req.method === "POST" && submitRe.test(url.pathname)) { + counts.submit++; + lastHeaders.submit = req.headers; + if (opts.hangOnSubmit) return; // accept, never respond + if (opts.submitHttpStatus !== undefined && opts.submitHttpStatus !== 200) { + sendJson(opts.submitHttpStatus, { + error: { code: opts.submitHttpStatus, message: "stub submit rejected" }, + }); + return; + } + sendJson(200, { name: UPSTREAM_OP_NAME }); + return; + } + + const opMatch = url.pathname.match(operationRe); + if (req.method === "GET" && opMatch) { + counts.status++; + lastHeaders.status = req.headers; + const opName = opMatch[1]; + const n = (opPolls.get(opName) ?? 0) + 1; + opPolls.set(opName, n); + if (n <= pollsBeforeDone) { + sendJson(200, { name: opName, done: false }); + return; + } + if (opts.failError) { + sendJson(200, { name: opName, done: true, error: opts.failError }); + return; + } + sendJson(200, { + name: opName, + done: true, + response: { generateVideoResponse: { generatedSamples: [{ video: { uri } }] } }, + }); + return; + } + + sendJson(404, { error: { code: 404, message: "stub: unhandled", path: url.pathname } }); + }); + }); + server.once("error", reject); + const sockets = new Set(); + server.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + counts, + lastHeaders, + close: () => + new Promise((r) => { + for (const s of sockets) s.destroy(); + server.close(() => r()); + }), + }); + }); + }); +} + +function readRecordedFixtureFiles(dir: string): { file: string; content: unknown }[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ file: f, content: JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")) })); +} + +async function waitUntil(cond: () => boolean, timeoutMs = 5000, intervalMs = 20): Promise { + const deadline = performance.now() + timeoutMs; + for (;;) { + if (cond()) return; + if (performance.now() > deadline) { + throw new Error(`waitUntil: condition not met within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +function veoSubmitUrl(base: string, model = "veo-3.1-generate-preview"): string { + return `${base}/v1beta/models/${model}:predictLongRunning`; +} + +// ─── Stub self-test ─────────────────────────────────────────────────────────── + +describe("startVeoVideoUpstream (stub self-test)", () => { + let upstream: Awaited> | undefined; + + afterEach(async () => { + await upstream?.close(); + upstream = undefined; + }); + + test("submit returns a name, polls advance done:false → done:true with the uri", async () => { + upstream = await startVeoVideoUpstream({ + pollsBeforeDone: 1, + uri: "https://files.example/x.mp4", + }); + const submit = await fetch(veoSubmitUrl(upstream.url), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-up" }, + body: JSON.stringify({ instances: [{ prompt: "hi" }] }), + }); + expect((await submit.json()).name).toBe(UPSTREAM_OP_NAME); + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-up"); + + const opUrl = `${upstream.url}/v1beta/${UPSTREAM_OP_NAME}`; + expect((await (await fetch(opUrl)).json()).done).toBe(false); + const done = await (await fetch(opUrl)).json(); + expect(done.done).toBe(true); + expect(done.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/x.mp4", + ); + }); +}); + +// ─── Config acceptance ───────────────────────────────────────────────────────── + +describe("Veo video record — config acceptance", () => { + let mock: LLMock | undefined; + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("LLMock accepts record.providers.veo", async () => { + mock = new LLMock({ + port: 0, + record: { providers: { veo: "https://generativelanguage.googleapis.com" } }, + }); + await mock.start(); + expect(mock.url).toMatch(/^http:/); + }); +}); + +// ─── Record lifecycle ────────────────────────────────────────────────────────── + +const refusingUpstream = await startRefusingUpstream(); +const UPSTREAM_DOWN_URL = refusingUpstream.url; +afterAll(() => refusingUpstream.close()); + +describe("Veo video record — submit + poll proxy and eager capture", () => { + let mock: LLMock | undefined; + let upstream: Awaited> | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-veo-record-")); + } + + async function startRecordingMock(upstreamUrl: string): Promise { + tmpDir = makeTmpDir(); + const m = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { veo: upstreamUrl }, fixturePath: tmpDir }, + }); + await m.start(); + return m; + } + + async function submitRecord(m: LLMock, prompt: string): Promise { + const res = await fetch(veoSubmitUrl(m.url), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test" }, + body: JSON.stringify({ instances: [{ prompt }] }), + }); + expect(res.status).toBe(200); + return (await res.json()).name as string; + } + + test("proxies an unmatched submit and returns a mock-rewritten {name}", async () => { + upstream = await startVeoVideoUpstream({ pollsBeforeDone: 1 }); + mock = await startRecordingMock(upstream.url); + + const res = await fetch(veoSubmitUrl(mock.url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-test", + "X-Test-Id": "rec-a", + }, + body: JSON.stringify({ instances: [{ prompt: "record me" }] }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.name.startsWith("operations/")).toBe(true); + // The mock operation name must NOT be the upstream's. + expect(data.name).not.toBe(UPSTREAM_OP_NAME); + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-test"); + + const submitEntry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path.includes(":predictLongRunning")); + expect(submitEntry?.response.source).toBe("proxy"); + }); + + test("strict mode wins over record: 503, nothing proxied", async () => { + upstream = await startVeoVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + strict: true, + logLevel: "silent", + record: { providers: { veo: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const res = await fetch(veoSubmitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "no fixture" }] }), + }); + expect(res.status).toBe(503); + expect(upstream.counts.submit).toBe(0); + }); + + test("record without a veo provider warns and falls through to 404", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { openrouter: "https://openrouter.ai" }, fixturePath: tmpDir }, + }); + await mock.start(); + const res = await fetch(veoSubmitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "no veo provider" }] }), + }); + expect(res.status).toBe(404); + }); + + test("upstream connection failure returns 502 proxy_error journaled as proxy", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { veo: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + await mock.start(); + const res = await fetch(veoSubmitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "down upstream" }] }), + }); + expect(res.status).toBe(502); + expect((await res.json()).error.type).toBe("proxy_error"); + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path.includes(":predictLongRunning")); + expect(entry?.response.source).toBe("proxy"); + }); + + test("non-terminal polls proxied 1:1 and relayed with the mock operation name", async () => { + upstream = await startVeoVideoUpstream({ pollsBeforeDone: 2 }); + mock = await startRecordingMock(upstream.url); + const name = await submitRecord(mock, "slow record"); + + const poll1 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(poll1.name).toBe(name); + expect(poll1.done).toBe(false); + expect(upstream.counts.status).toBe(1); + + const poll2 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(poll2.done).toBe(false); + expect(upstream.counts.status).toBe(2); + + const pollEntries = mock.journal + .getAll() + .filter((e) => e.method === "GET" && e.path.includes(name)); + expect(pollEntries.length).toBe(2); + for (const e of pollEntries) expect(e.response.source).toBe("proxy"); + }); + + test("completed poll captures the fixture eagerly (uri) — NO byte download", async () => { + upstream = await startVeoVideoUpstream({ uri: "https://files.example/captured.mp4" }); + mock = await startRecordingMock(upstream.url); + const name = await submitRecord(mock, "capture me"); + + const done = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(done.done).toBe(true); + expect(done.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/captured.mp4", + ); + + const dir = tmpDir as string; + await waitUntil(() => readRecordedFixtureFiles(dir).length > 0); + const fixtures = readRecordedFixtureFiles(dir); + expect(fixtures.length).toBe(1); + const content = fixtures[0].content as { + fixtures: { response: { video: { url: string; status: string } } }[]; + }; + expect(content.fixtures[0].response.video.url).toBe("https://files.example/captured.mp4"); + expect(content.fixtures[0].response.video.status).toBe("completed"); + // Veo's operations envelope carries no cost field — capture must not invent one. + expect(content.fixtures[0].response.video).not.toHaveProperty("cost"); + }); + + test("post-capture polls served from the mutated replay job (upstream not hit again)", async () => { + upstream = await startVeoVideoUpstream({ uri: "https://files.example/post.mp4" }); + mock = await startRecordingMock(upstream.url); + const name = await submitRecord(mock, "post capture"); + + await (await fetch(`${mock.url}/v1beta/${name}`)).json(); // triggers capture + const dir = tmpDir as string; + await waitUntil(() => readRecordedFixtureFiles(dir).length > 0); + const statusAfterCapture = upstream.counts.status; + + const replayed = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(replayed.done).toBe(true); + expect(replayed.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/post.mp4", + ); + // The mutated replay job serves locally — no new upstream poll. + expect(upstream.counts.status).toBe(statusAfterCapture); + }); + + test("proxyOnly: terminal job keeps proxying live, nothing persisted", async () => { + upstream = await startVeoVideoUpstream({ uri: "https://files.example/proxyonly.mp4" }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { veo: upstream.url }, fixturePath: tmpDir, proxyOnly: true }, + }); + await mock.start(); + const name = await submitRecord(mock, "proxy only"); + + const done1 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(done1.done).toBe(true); + const statusAfter = upstream.counts.status; + // A second poll still hits the upstream (no replay mutation under proxyOnly). + const done2 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(done2.done).toBe(true); + expect(upstream.counts.status).toBe(statusAfter + 1); + expect(readRecordedFixtureFiles(tmpDir).length).toBe(0); + }); + + test("round trip: record session then replay session matches", async () => { + upstream = await startVeoVideoUpstream({ + pollsBeforeDone: 1, + uri: "https://files.example/roundtrip.mp4", + }); + mock = await startRecordingMock(upstream.url); + const name = await submitRecord(mock, "round trip render"); + + // Drive to terminal. + await (await fetch(`${mock.url}/v1beta/${name}`)).json(); // done:false + const done = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); // done:true + expect(done.done).toBe(true); + const dir = tmpDir as string; + await waitUntil(() => readRecordedFixtureFiles(dir).length > 0); + await mock.stop(); + mock = undefined; + + // Fresh replay session loads the recorded fixture. + const replayMock = new LLMock({ port: 0, logLevel: "silent" }); + replayMock.loadFixtureDir(dir); + await replayMock.start(); + try { + const submit = await fetch(veoSubmitUrl(replayMock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "round trip render" }] }), + }); + expect(submit.status).toBe(200); + const replayName = (await submit.json()).name as string; + const replayDone = await (await fetch(`${replayMock.url}/v1beta/${replayName}`)).json(); + expect(replayDone.done).toBe(true); + expect(replayDone.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/roundtrip.mp4", + ); + // No upstream contact during replay (upstream poll count unchanged from + // the record session's two polls). + expect(upstream.counts.status).toBe(2); + } finally { + await replayMock.stop(); + } + }); +}); diff --git a/src/__tests__/veo-video.test.ts b/src/__tests__/veo-video.test.ts new file mode 100644 index 0000000..dde561b --- /dev/null +++ b/src/__tests__/veo-video.test.ts @@ -0,0 +1,280 @@ +import { describe, test, expect, afterEach, vi } from "vitest"; +import { LLMock } from "../llmock.js"; + +// ─── Google Veo replay lifecycle ───────────────────────────────────────────── +// POST /v1beta/models/{model}:predictLongRunning → { name: "operations/..." } +// GET /v1beta/operations/{name} → { name, done:false } ... { name, done:true, +// response: { generateVideoResponse: { generatedSamples: [{ video: { uri }}]}}} +// The Files-API uri is served AS-IS — aimock never downloads bytes. + +const VEO_MODEL = "veo-3.1-generate-preview"; + +function submitUrl(base: string, model = VEO_MODEL): string { + return `${base}/v1beta/models/${model}:predictLongRunning`; +} + +describe("POST /v1beta/models/{model}:predictLongRunning (Veo submit)", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("fixture match returns an operations envelope", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "a sunset over the ocean", endpoint: "video" }, + response: { + video: { id: "veo_1", status: "completed", url: "https://files.example/v.mp4" }, + }, + }); + await mock.start(); + + const res = await fetch(submitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ instances: [{ prompt: "a sunset over the ocean" }] }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(typeof data.name).toBe("string"); + expect(data.name.startsWith("operations/")).toBe(true); + }); + + test("malformed JSON submit 400s with a Gemini error envelope", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + + const res = await fetch(submitUrl(mock.url), { + 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(400); + expect(typeof data.error.message).toBe("string"); + expect(data.error.status).toBe("INVALID_ARGUMENT"); + }); + + test("missing prompt 400s", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + const res = await fetch(submitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{}] }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error.message).toMatch(/prompt/); + }); + + test("strict no-match submit 503s; non-strict no-match 404s", async () => { + mock = new LLMock({ port: 0, strict: true }); + await mock.start(); + const strictRes = await fetch(submitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "no fixture here" }] }), + }); + expect(strictRes.status).toBe(503); + expect((await strictRes.json()).error.status).toBe("UNAVAILABLE"); + await mock.stop(); + + mock = new LLMock({ port: 0 }); + await mock.start(); + const looseRes = await fetch(submitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "no fixture here" }] }), + }); + expect(looseRes.status).toBe(404); + expect((await looseRes.json()).error.status).toBe("NOT_FOUND"); + }); +}); + +describe("GET /v1beta/operations/{name} (Veo status)", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + async function submit(m: LLMock, prompt: string): Promise { + const res = await fetch(submitUrl(m.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt }] }), + }); + expect(res.status).toBe(200); + return (await res.json()).name as string; + } + + test("poll with 0/0 progression returns done:true with the Files-API uri served as-is", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "served as-is", endpoint: "video" }, + response: { + video: { id: "veo_uri", status: "completed", url: "https://files.example/asis.mp4" }, + }, + }); + await mock.start(); + const name = await submit(mock, "served as-is"); + + const res = await fetch(`${mock.url}/v1beta/${name}`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.name).toBe(name); + expect(data.done).toBe(true); + expect(data.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/asis.mp4", + ); + }); + + test("progression: done:false then done:true", async () => { + mock = new LLMock({ port: 0, veoVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 } }); + mock.addFixture({ + match: { userMessage: "slow render", endpoint: "video" }, + response: { + video: { id: "veo_slow", status: "completed", url: "https://files.example/slow.mp4" }, + }, + }); + await mock.start(); + const name = await submit(mock, "slow render"); + + const poll1 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(poll1.done).toBe(false); + const poll2 = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(poll2.done).toBe(true); + expect(poll2.response.generateVideoResponse.generatedSamples[0].video.uri).toBe( + "https://files.example/slow.mp4", + ); + }); + + test("unknown operation 404s with a Gemini error envelope", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + const res = await fetch(`${mock.url}/v1beta/operations/does-not-exist`); + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error.code).toBe(404); + expect(data.error.status).toBe("NOT_FOUND"); + }); + + test("failed fixture polls to done:true with a Gemini error body", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "policy violation", endpoint: "video" }, + response: { video: { id: "veo_fail", status: "failed", error: "blocked by policy" } }, + }); + await mock.start(); + const name = await submit(mock, "policy violation"); + + const data = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(data.done).toBe(true); + expect(data.error.message).toBe("blocked by policy"); + }); + + test("completed fixture without a url serves the documented placeholder uri", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "no url", endpoint: "video" }, + response: { video: { id: "veo_nourl", status: "completed" } }, + }); + await mock.start(); + const name = await submit(mock, "no url"); + const data = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(data.done).toBe(true); + expect(data.response.generateVideoResponse.generatedSamples[0].video.uri).toMatch( + /generativelanguage\.googleapis\.com\/v1beta\/files\//, + ); + }); + + test("completed fixture with an empty url serves the documented placeholder uri", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "empty url", endpoint: "video" }, + response: { video: { id: "veo_emptyurl", status: "completed", url: "" } }, + }); + await mock.start(); + const name = await submit(mock, "empty url"); + const data = await (await fetch(`${mock.url}/v1beta/${name}`)).json(); + expect(data.done).toBe(true); + const uri = data.response.generateVideoResponse.generatedSamples[0].video.uri; + expect(uri).not.toBe(""); + expect(uri).toMatch(/generativelanguage\.googleapis\.com\/v1beta\/files\//); + }); +}); + +// ─── Dispatch error journaling (GET /v1beta/operations/{name} Veo status) ───── +// +// A throw from handleVeoVideoStatus must be journaled as a 500 and answered with +// the server_error envelope, mirroring the sibling video dispatches (Grok status, +// Veo create). We force the handler to throw mid-poll by making its replay +// journal.add (status:200) throw AFTER a real Veo job exists — the narrowest seam +// at the real failure surface (a poll that blows up before it can respond). The +// dispatch wrapper's own status:500 journal write must still land so the failed +// poll is visible in /__aimock/journal. +describe("GET /v1beta/operations/{name} dispatch journals handler errors as 500 (Veo)", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + }); + + test("a throw from handleVeoVideoStatus returns a journaled 500", async () => { + mock = new LLMock({ port: 0 }); + // Seed a completed Veo fixture and submit so a real replay job exists; the + // subsequent poll is a genuine Veo hit (not an unknown-operation 404). + mock.addFixture({ + match: { userMessage: "boom render", endpoint: "video" }, + response: { + video: { id: "veo_boom", status: "completed", url: "https://files.example/boom.mp4" }, + }, + }); + await mock.start(); + + const submit = await fetch(submitUrl(mock.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [{ prompt: "boom render" }] }), + }); + expect(submit.status).toBe(200); + const { name } = await submit.json(); + expect(typeof name).toBe("string"); + + // Seam: the handler's replay path journals { status: 200 } BEFORE it writes + // the response; force THAT add to throw so the throw escapes the handler with + // headers unsent. The dispatch wrapper's own { status: 500 } add (and every + // prior/other add) passes through untouched. + const journal = mock.journal; + const realAdd = journal.add.bind(journal); + vi.spyOn(journal, "add").mockImplementation((entry) => { + if (entry.response.status === 200) { + throw new Error("forced handler explosion"); + } + return realAdd(entry); + }); + + const poll = await fetch(`${mock.url}/v1beta/${name}`); + + // GREEN expectation: dispatch caught the throw, returned a 500 server_error + // envelope, and journaled the failed poll. (RED on un-wrapped code: the throw + // is caught but NO status:500 journal entry is written → count 0.) + expect(poll.status).toBe(500); + const body = await poll.json(); + expect(body.error.type).toBe("server_error"); + expect(body.error.message).toBe("forced handler explosion"); + + const failed = mock + .getRequests() + .filter((e) => e.response.status === 500 && e.path === `/v1beta/${name}`); + expect(failed.length).toBe(1); + expect(failed[0].method).toBe("GET"); + }); +}); diff --git a/src/veo-video.ts b/src/veo-video.ts new file mode 100644 index 0000000..ce42533 --- /dev/null +++ b/src/veo-video.ts @@ -0,0 +1,1133 @@ +import type * as http from "node:http"; +import crypto from "node:crypto"; +import type { + ChatCompletionRequest, + Fixture, + HandlerDefaults, + RecordConfig, + 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"; +import { + buildFixtureMatch, + buildForwardHeaders, + persistFixture, + sanitizeHeaderValue, +} from "./recorder.js"; +import { resolveUpstreamUrl } from "./url.js"; +import { readEnvelopeText, upstreamTimeoutSignal } from "./video-proxy-shared.js"; + +/** + * Google Veo async video lifecycle mock. Submit + * `POST /v1beta/models/{model}:predictLongRunning` returns + * `{ name: "operations/..." }`; status `GET /v1beta/operations/{name}` polls + * `done:false → done:true`. The Files-API `uri` is served AS-IS — aimock does + * NOT proxy or capture video bytes. With `record.providers.veo` configured an + * unmatched submit becomes a live interactive proxy (submit + poll forwarded + * 1:1, eager fixture capture on terminal status). Strict mode still wins. + * + * ASSUMED Veo create body shape (the `@google/genai` :predictLongRunning + * envelope from issue #278): `{ instances: [{ prompt, image? }], parameters }`. + * Match surface is `instances[0].prompt` only. + */ + +/** Files-API-shaped placeholder served when a completed fixture omits a uri. */ +const DEFAULT_VEO_FILES_URI = "https://generativelanguage.googleapis.com/v1beta/files/placeholder"; + +// The `:predictLongRunning` envelope nests the prompt under instances[]. +interface VeoVideoRequest { + instances?: Array<{ prompt?: unknown; [key: string]: unknown }>; + parameters?: Record; + [key: string]: unknown; +} + +// ─── VeoVideoJobMap (TTL + bounded) ───────────────────────────────────────── + +export const VEO_VIDEO_MAX_ENTRIES = 10_000; +const VEO_VIDEO_TTL_MS = 3_600_000; // 1 hour + +type VeoVideoStatus = "pending" | "in_progress" | "completed" | "failed"; + +interface VeoVideoReplayJob { + kind: "replay"; + operationName: string; + status: VeoVideoStatus; + pollCount: number; + pollsBeforeInProgress: number; + pollsBeforeCompleted: number; + video: VideoResponse["video"]; + /** Latch for the empty-`error` authoring warn on failed polls (once/job). */ + emptyErrorWarned?: boolean; +} + +interface VeoVideoRecordJob { + kind: "record"; + operationName: string; + status: VeoVideoStatus; + upstreamOperationName: string; + upstreamPollingUrl: string; + match: Fixture["match"]; + capturing?: boolean; +} + +export type VeoVideoJob = VeoVideoReplayJob | VeoVideoRecordJob; + +interface VeoVideoEntry { + job: VeoVideoJob; + createdAt: number; +} + +/** + * Per-testId job state for the Veo video handler. Mirrors OpenRouterVideoJobMap + * (openrouter-video.ts): lazy TTL eviction on `get`, FIFO eviction of the + * oldest entries on `set` when over capacity, delete-before-set TTL refresh, + * monotonic world-generation counter for reset-mid-flight detection, no + * background sweep timer. Keys are `${testId}:${operationName}`. + */ +export class VeoVideoJobMap { + private readonly entries = new Map(); + private worldGeneration = 0; + + get generation(): number { + return this.worldGeneration; + } + + get(key: string): VeoVideoJob | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (Date.now() - entry.createdAt > VEO_VIDEO_TTL_MS) { + this.entries.delete(key); + return undefined; + } + return entry.job; + } + + set(key: string, job: VeoVideoJob): void { + this.entries.delete(key); + this.entries.set(key, { job, createdAt: Date.now() }); + if (this.entries.size > VEO_VIDEO_MAX_ENTRIES) { + const excess = this.entries.size - VEO_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(); + this.worldGeneration++; + } + + get size(): number { + return this.entries.size; + } +} + +// ─── Job progression ──────────────────────────────────────────────────────── + +/** + * Maps the fixture's terminal video status onto the job lifecycle. Anything + * that is not "failed" — including a fixture authored as "processing" — is + * treated as completed, since this surface always drives jobs to a terminal + * state. + */ +function terminalStatus(job: VeoVideoReplayJob): VeoVideoStatus { + 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). The wire + * value is derived from this internal status (pending/in_progress → done:false, + * completed/failed → done:true). + */ +function advanceVeoJob(job: VeoVideoReplayJob): 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); + } +} + +/** Gemini-style error envelope used by every Veo error response. */ +function geminiError(code: number, message: string, status: string): string { + return JSON.stringify({ error: { code, message, status } }); +} + +/** + * The done:true operation body for a completed replay job — the Files-API uri + * is served as-is (the fixture's stored `video.url`, or the documented + * placeholder when omitted). + */ +function completedOperationBody(operationName: string, uri: string): Record { + return { + name: operationName, + done: true, + response: { + generateVideoResponse: { + generatedSamples: [{ video: { uri } }], + }, + }, + }; +} + +// ─── POST /v1beta/models/{model}:predictLongRunning — submit ───────────────── + +export async function handleVeoVideoCreate( + req: http.IncomingMessage, + res: http.ServerResponse, + raw: string, + model: string, + fixtures: Fixture[], + journal: Journal, + defaults: HandlerDefaults, + setCorsHeaders: (res: http.ServerResponse) => void, + jobs: VeoVideoJobMap, +): Promise { + setCorsHeaders(res); + const path = req.url ?? `/v1beta/models/${model}:predictLongRunning`; + const method = req.method ?? "POST"; + + let videoReq: VeoVideoRequest; + try { + videoReq = JSON.parse(raw) as VeoVideoRequest; + } 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, geminiError(400, `Malformed JSON: ${detail}`, "INVALID_ARGUMENT")); + return; + } + + // Reject bodies that parsed but are not a JSON object before touching fields. + 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, + geminiError(400, "Request body must be a JSON object", "INVALID_ARGUMENT"), + ); + return; + } + + // Prompt lives at instances[0].prompt (the @google/genai envelope). + const firstInstance = Array.isArray(videoReq.instances) ? videoReq.instances[0] : undefined; + const prompt = firstInstance?.prompt; + // Synthesize a structurally valid journal body for field-validation 400s + // (JournalEntry.body is ChatCompletionRequest | null). Strip reserved + // underscore-prefixed keys so a request cannot spoof handler discriminators. + const sanitized = Object.fromEntries( + Object.entries(videoReq).filter(([key]) => !key.startsWith("_")), + ); + const parsedBody: ChatCompletionRequest = { ...sanitized, model, messages: [] }; + + if (typeof prompt !== "string" || !prompt) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: parsedBody, + response: { status: 400, fixture: null }, + }); + const message = + prompt === undefined + ? "Missing required parameter: 'instances[0].prompt'" + : "Invalid type for parameter: 'instances[0].prompt' must be a non-empty string"; + writeErrorResponse(res, 400, geminiError(400, message, "INVALID_ARGUMENT")); + return; + } + + const syntheticReq: ChatCompletionRequest = { + model, + messages: [{ role: "user", content: prompt }], + _endpointType: "video", + _videoProvider: "veo", + _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 Veo request (model=${model}, msg="${prompt.slice(0, 80)}")`, + ); + } + + // Chaos rolls AFTER body validation and fixture matching (mirrors the + // OpenRouter submit). An unmatched submit is proxied upstream when record + // mode has a veo provider AND strict would not win — label that roll "proxy". + if ( + applyChaos( + res, + fixture, + defaults.chaos, + req.headers, + journal, + { method, path, headers: flattenHeaders(req.headers), body: syntheticReq }, + fixture + ? "fixture" + : resolveStrictMode(defaults.strict, req.headers) + ? "internal" + : defaults.record?.providers.veo + ? "proxy" + : "internal", + defaults.registry, + defaults.logger, + ) + ) + return; + + if (!fixture) { + // Strict mode wins over record: a strict no-match fails loudly with 503. + if (resolveStrictMode(defaults.strict, req.headers)) { + 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, geminiError(503, strictMessage, "UNAVAILABLE")); + return; + } + + if (defaults.record) { + const outcome = await proxyVeoVideoSubmit({ + req, + res, + raw, + syntheticReq, + record: defaults.record, + journal, + defaults, + jobs, + method, + path, + }); + if (outcome === "handled") return; + // outcome === "no_upstream" — fall through to 404 (fal convention). + } + + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 404, + fixture: null, + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse(res, 404, geminiError(404, "No fixture matched", "NOT_FOUND")); + return; + } + + // World-generation snapshot (mirrors the record-submit guard): a fixtures + // reset can land while the fixture's ResponseFactory below is awaited, and + // the job insertion at the bottom must not seed the NEW world. + const worldGeneration = jobs.generation; + const response = await resolveResponse(fixture, syntheticReq); + + if (isErrorResponse(response)) { + if (res.destroyed || res.writableEnded) return; + 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)) { + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { status: 500, fixture }, + }); + writeErrorResponse( + res, + 500, + geminiError(500, "Fixture response is not a video type", "INTERNAL"), + ); + return; + } + + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { status: 200, fixture }, + }); + + const fixtureStatus: string = response.video.status; + if (fixtureStatus === "processing") { + defaults.logger.warn( + `Veo video fixture has status "processing" — treated as completed for the operations lifecycle`, + ); + } else if (fixtureStatus !== "completed" && fixtureStatus !== "failed") { + defaults.logger.warn( + `Veo video fixture has unknown status "${fixtureStatus}" — treating as completed`, + ); + } + + const operationName = `operations/${crypto.randomUUID()}`; + const progression = resolveProgression(defaults.veoVideo); + const job: VeoVideoReplayJob = { + kind: "replay", + operationName, + status: "pending", + pollCount: 0, + pollsBeforeInProgress: progression.pollsBeforeInProgress, + pollsBeforeCompleted: progression.pollsBeforeCompleted, + video: { ...response.video }, + }; + // Default 0/0 progression seeds the job terminal at submit; the submit + // envelope still reports a not-done operation. + if (progression.pollsBeforeCompleted === 0) { + job.status = terminalStatus(job); + } + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${operationName}`, job); + } else { + defaults.logger.warn( + `Veo video submit resolved after a fixtures reset — not inserting operation ${operationName} into the new world (its polls will 404)`, + ); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ name: operationName })); +} + +// ─── GET /v1beta/operations/{name} — status poll ───────────────────────────── + +export async function handleVeoVideoStatus( + req: http.IncomingMessage, + res: http.ServerResponse, + operationName: string, + fixtures: Fixture[], + journal: Journal, + defaults: HandlerDefaults, + setCorsHeaders: (res: http.ServerResponse) => void, + jobs: VeoVideoJobMap, +): Promise { + setCorsHeaders(res); + const path = req.url ?? `/v1beta/${operationName}`; + const method = req.method ?? "GET"; + + // Chaos rolls BEFORE the job lookup — the label stays "internal" even in + // record mode, and a chaos-dropped poll never reaches the upstream. + 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 key = `${testId}:${operationName}`; + const job = jobs.get(key); + + if (!job) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 404, fixture: null }, + }); + writeErrorResponse( + res, + 404, + geminiError(404, `Operation ${operationName} not found`, "NOT_FOUND"), + ); + return; + } + + if (job.kind === "record") { + // Strict means nothing reaches an upstream — a record job's polls are pure + // upstream proxies, so an effective-strict request is refused with 503. + if (resolveStrictMode(defaults.strict, req.headers)) { + defaults.logger.error( + `STRICT: Veo operation ${operationName} is proxied live upstream (record mode) — refusing the upstream poll`, + ); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 503, + fixture: null, + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 503, + geminiError( + 503, + `Strict mode: Veo operation ${operationName} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`, + "UNAVAILABLE", + ), + ); + return; + } + await proxyVeoVideoRecordPoll({ + req, + res, + job, + key, + testId, + fixtures, + journal, + defaults, + jobs, + method, + path, + }); + return; + } + + // Guard BEFORE advancing or journaling (file convention): a disconnected + // client consumes no progression step or TTL refresh. + if (res.destroyed || res.writableEnded) return; + advanceVeoJob(job); + // Refresh the TTL on every replay poll (delete-before-set also moves the + // entry to the back of the FIFO eviction order). + jobs.set(key, job); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 200, fixture: null }, + }); + + let body: Record; + if (job.status === "completed") { + // Truthy guard (not `??`): an empty-string fixture url must still fall back + // to the placeholder Files-API uri rather than being served as an empty uri. + const rawUrl = job.video.url; + body = completedOperationBody( + operationName, + typeof rawUrl === "string" && rawUrl ? rawUrl : DEFAULT_VEO_FILES_URI, + ); + } else if (job.status === "failed") { + if (job.video.error === "" && !job.emptyErrorWarned) { + job.emptyErrorWarned = true; + defaults.logger.warn( + `Veo video fixture for operation ${operationName} has an empty error message — using the default`, + ); + } + body = { + name: operationName, + done: true, + error: { code: 3, message: job.video.error || "Video generation failed" }, + }; + } else { + body = { name: operationName, done: false }; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +// ─── Record mode: live interactive proxy (submit) ─────────────────────────── + +/** + * Proxy an unmatched Veo submit to the configured upstream and answer the + * client with a mock-rewritten envelope: a fresh aimock operation name. The + * upstream lifecycle is driven interactively by the client's own polls. + * + * Returns "no_upstream" when record mode has no veo provider URL — the caller + * falls through to its 404 branch (fal convention). + */ +async function proxyVeoVideoSubmit(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + raw: string; + syntheticReq: ChatCompletionRequest; + record: RecordConfig; + journal: Journal; + defaults: HandlerDefaults; + jobs: VeoVideoJobMap; + method: string; + path: string; +}): Promise<"handled" | "no_upstream"> { + const { req, res, raw, syntheticReq, record, journal, defaults, jobs, method, path } = args; + + const upstreamBase = record.providers.veo; + if (!upstreamBase) { + defaults.logger.warn(`No upstream URL configured for provider "veo" — cannot proxy`); + return "no_upstream"; + } + + const proxyError = (msg: string): "handled" => { + defaults.logger.error(`Veo video submit proxy failed: ${msg}`); + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 502, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { message: `Proxy to upstream failed: ${msg}`, type: "proxy_error" }, + }), + ); + return "handled"; + }; + + const submitPath = `/v1beta/models/${syntheticReq.model}:predictLongRunning`; + let submitUrl: URL; + let upstreamOrigin: string; + try { + submitUrl = resolveUpstreamUrl(upstreamBase, submitPath); + upstreamOrigin = new URL(upstreamBase).origin; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return proxyError(`Invalid upstream URL: ${upstreamBase} (${msg})`); + } + + defaults.logger.warn( + `NO FIXTURE MATCH — proxying Veo video submit to ${upstreamBase}${submitPath}`, + ); + + // World-generation snapshot: a fixtures reset landing during the upstream + // fetch clears the job map — the insertion guard compares against this. + const worldGeneration = jobs.generation; + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(submitUrl, { + method: "POST", + headers: buildForwardHeaders(req), + body: raw, + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + return proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + } + + if (fetched.status === 401 || fetched.status === 403) { + // Real-API fidelity: relay an upstream auth rejection verbatim. + defaults.logger.warn( + `Upstream rejected the Veo video submit (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: fetched.status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" }); + res.end(fetched.text); + return "handled"; + } + + let upstreamOperationName: string; + { + if (fetched.status < 200 || fetched.status >= 300) { + return proxyError(`Submit ${fetched.status}: ${fetched.text.slice(0, 200)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + return proxyError(`Submit returned non-JSON: ${fetched.text.slice(0, 200)}`); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return proxyError("Submit response is not a JSON object"); + } + upstreamOperationName = String((parsed as Record).name ?? "").trim(); + if (!upstreamOperationName) { + return proxyError("Submit response missing name"); + } + } + + // Veo has no polling_url field — the poll URL is constructed from the + // provider base + `/v1beta/{operationName}`. Origin-validate the constructed + // URL (the client's Authorization travels to it on every poll); fall back to + // a string on the provider base on any parse failure. + let upstreamPollingUrl: string; + try { + const constructed = resolveUpstreamUrl( + upstreamBase, + `/v1beta/${upstreamOperationName.replace(/^\/+/, "")}`, + ); + if (constructed.origin === upstreamOrigin) { + upstreamPollingUrl = constructed.toString(); + } else { + defaults.logger.warn( + `Constructed Veo poll URL origin ${constructed.origin} differs from the configured provider origin ${upstreamOrigin} — using the provider-origin URL`, + ); + upstreamPollingUrl = `${upstreamOrigin}/v1beta/${upstreamOperationName.replace(/^\/+/, "")}`; + } + } catch { + defaults.logger.warn( + `Could not construct the Veo poll URL for ${upstreamOperationName} — using the provider-origin URL`, + ); + upstreamPollingUrl = `${upstreamOrigin}/v1beta/${upstreamOperationName.replace(/^\/+/, "")}`; + } + + const testId = getTestId(req); + const matchRequest = defaults.requestTransform + ? defaults.requestTransform(syntheticReq) + : syntheticReq; + const operationName = `operations/${crypto.randomUUID()}`; + const job: VeoVideoRecordJob = { + kind: "record", + operationName, + status: "pending", + upstreamOperationName, + upstreamPollingUrl, + match: buildFixtureMatch(matchRequest, record), + }; + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${operationName}`, job); + } else { + defaults.logger.warn( + `Veo video submit for upstream operation ${upstreamOperationName} completed after a fixtures reset — not inserting the job into the new world (its polls will 404)`, + ); + } + + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 200, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ name: operationName })); + return "handled"; +} + +// ─── Record mode: live interactive proxy (poll + eager capture) ───────────── + +/** + * Proxy a status poll for a record-mode Veo job 1:1 to the upstream and relay + * the result with the operation name rewritten to the mock operation (the + * Files-API uri inside `response` passes through verbatim — aimock never + * downloads bytes). When the upstream reports `done:true` with a `response`, + * the rewritten body is relayed IMMEDIATELY and the eager capture (persist the + * uri as a fixture, mutate to a terminal replay job) runs DETACHED. A + * `done:true` with an `error` persists a failed fixture synchronously. Under + * `record.proxyOnly` nothing is captured, persisted, or mutated. Every + * post-await map mutation is identity-guarded. + */ +async function proxyVeoVideoRecordPoll(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + job: VeoVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + journal: Journal; + defaults: HandlerDefaults; + jobs: VeoVideoJobMap; + method: string; + path: string; +}): Promise { + const { req, res, job, key, testId, fixtures, journal, defaults, jobs, method, path } = args; + const logger = defaults.logger; + + const journalProxy = (status: number): void => { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + }; + + const proxyError = (msg: string): void => { + logger.error(`Veo video poll proxy failed: ${msg}`); + if (res.destroyed || res.writableEnded) return; + journalProxy(502); + writeErrorResponse( + res, + 502, + JSON.stringify({ + error: { message: `Proxy to upstream failed: ${msg}`, type: "proxy_error" }, + }), + ); + }; + + // Recording can be disabled mid-flight — an orphaned record job must fail + // loudly before contacting the upstream. + const record = defaults.record; + if (!record) { + proxyError("record mode is no longer configured for an in-flight record job"); + return; + } + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(job.upstreamPollingUrl, { + headers: buildForwardHeaders(req), + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + return; + } + + if (fetched.status === 401 || fetched.status === 403) { + logger.warn( + `Upstream rejected the Veo status poll for operation ${job.upstreamOperationName} (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return; + journalProxy(fetched.status); + res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" }); + res.end(fetched.text); + return; + } + + let upstreamBody: Record; + { + if (fetched.status < 200 || fetched.status >= 300) { + proxyError(`Status ${fetched.status}: ${fetched.text.slice(0, 200)}`); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + proxyError(`Status returned non-JSON: ${fetched.text.slice(0, 200)}`); + return; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + proxyError("Status response is not a JSON object"); + return; + } + upstreamBody = parsed as Record; + } + + // Rewrite ONLY the top-level operation name so no upstream identifier + // escapes — the `response` (carrying the Files-API uri the client needs) and + // every other field pass through verbatim. + const relayBody: Record = { ...upstreamBody, name: job.operationName }; + + const relayJson = (): void => { + if (res.destroyed || res.writableEnded) return; + journalProxy(200); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(relayBody)); + }; + + const done = upstreamBody.done === true; + const upstreamError = upstreamBody.error; + const hasError = + upstreamError !== undefined && upstreamError !== null && typeof upstreamError === "object"; + + if (!done) { + // Non-terminal: identity-guarded status write + TTL refresh, relay. + if (jobs.get(key) === job) { + if (!job.capturing && job.status !== "completed" && job.status !== "failed") { + job.status = "in_progress"; + } + jobs.set(key, job); + } + relayJson(); + return; + } + + if (hasError) { + // Terminal failure: persist a failed fixture synchronously (mirrors the + // OpenRouter failed branch), then relay. + if (record.proxyOnly) { + if (jobs.get(key) === job) { + job.status = "failed"; + jobs.set(key, job); + } + relayJson(); + return; + } + if (jobs.get(key) !== job) { + // A concurrent terminal poll already persisted/replaced the entry, or a + // fixtures reset cleared the world — relay without a duplicate fixture. + relayJson(); + return; + } + const errObj = upstreamError as Record; + const rawMessage = errObj.message; + const error = typeof rawMessage === "string" && rawMessage ? rawMessage : undefined; + const video: VideoResponse["video"] = { + id: job.upstreamOperationName, + status: "failed", + ...(error !== undefined ? { error } : {}), + }; + const persistResult = persistFixture({ + record, + providerKey: "veo", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + logger, + }); + if (persistResult.kind === "failed" && !res.headersSent) { + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); + } + jobs.set(key, { + kind: "replay", + operationName: job.operationName, + status: "failed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + relayJson(); + return; + } + + // Terminal success (done:true with a response). + if (record.proxyOnly) { + if (jobs.get(key) === job) { + job.status = "completed"; + jobs.set(key, job); + } + relayJson(); + return; + } + + if (job.capturing || jobs.get(key) !== job) { + // A concurrent poll already entered the capture sequence, or this + // reference is detached — relay without starting a second capture and + // never re-insert the stale object. + if (jobs.get(key) === job) { + jobs.set(key, job); // TTL refresh + } + relayJson(); + return; + } + + // Open the capturing window SYNCHRONOUSLY before the first await. + job.capturing = true; + job.status = "completed"; + jobs.set(key, job); // TTL refresh — identity-checked above, no await since + + // Relay IMMEDIATELY; the persist + replay mutation runs DETACHED. + relayJson(); + + void captureVeoVideoRecordFixture({ + job, + key, + testId, + fixtures, + defaults, + jobs, + record, + upstreamBody, + }); +} + +/** + * Detached eager-capture for a completed Veo record job. Extracts the + * Files-API uri from the terminal poll body (NO download), persists a + * fixture, and mutates the map entry into a terminal replay job. Every failure + * is handled internally; the returned promise NEVER rejects. World-generation + * guarded immediately before persist. + */ +async function captureVeoVideoRecordFixture(args: { + job: VeoVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + defaults: HandlerDefaults; + jobs: VeoVideoJobMap; + record: RecordConfig; + upstreamBody: Record; +}): Promise { + const { job, key, testId, fixtures, defaults, jobs, record, upstreamBody } = args; + const logger = defaults.logger; + + try { + // Drill into response.generateVideoResponse.generatedSamples[0].video.uri. + let uri: string | undefined; + const response = upstreamBody.response; + if (response !== null && typeof response === "object" && !Array.isArray(response)) { + const gvr = (response as Record).generateVideoResponse; + if (gvr !== null && typeof gvr === "object" && !Array.isArray(gvr)) { + const samples = (gvr as Record).generatedSamples; + const first = Array.isArray(samples) ? samples[0] : undefined; + if (first !== null && typeof first === "object" && !Array.isArray(first)) { + const video = (first as Record).video; + if (video !== null && typeof video === "object" && !Array.isArray(video)) { + const rawUri = (video as Record).uri; + if (typeof rawUri === "string" && rawUri) uri = rawUri; + } + } + } + } + if (!uri) { + logger.warn( + `Upstream Veo operation ${job.upstreamOperationName} completed without a usable Files-API uri — capture skipped, nothing persisted; the next completed poll retries`, + ); + return; + } + + const video: VideoResponse["video"] = { + id: job.upstreamOperationName, + status: "completed", + url: uri, + }; + + // World-generation guard: a fixtures reset landing during the await above + // clears the job map — map identity is a valid proxy for "same world". + if (jobs.get(key) !== job) { + logger.warn( + `Veo video capture for operation ${job.upstreamOperationName} discarded: the job map no longer holds this job (fixtures reset or TTL eviction) — nothing persisted`, + ); + return; + } + persistFixture({ + record, + providerKey: "veo", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + logger, + }); + + // Mutate the entry into a terminal replay job: later polls serve locally. + if (jobs.get(key) === job) { + jobs.set(key, { + kind: "replay", + operationName: job.operationName, + status: "completed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error( + `Veo video capture for operation ${job.upstreamOperationName} failed unexpectedly (${msg}) — fixture not persisted; the job keeps proxying live`, + ); + } finally { + // Close the capturing window when the map still holds this record job (a + // step threw before the replay mutation). On the success path the entry + // was just replaced; the detached object deliberately keeps capturing=true. + if (jobs.get(key) === job) { + job.capturing = false; + } + } +} From 5039e06e5ae144c09a062a73de298603171873ed Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 26 Jun 2026 15:55:22 -0700 Subject: [PATCH 3/4] feat(video): native xAI Grok Imagine video with record-mode + Sora-safe dispatch (#278) Add the native xAI Grok Imagine video provider with record-mode fixtures, Sora-safe dispatch, tests, and provider docs. --- docs/grok-video/index.html | 308 ++++++ src/__tests__/grok-video-record.test.ts | 747 ++++++++++++++ src/__tests__/grok-video.test.ts | 373 +++++++ src/grok-video.ts | 1210 +++++++++++++++++++++++ 4 files changed, 2638 insertions(+) create mode 100644 docs/grok-video/index.html create mode 100644 src/__tests__/grok-video-record.test.ts create mode 100644 src/__tests__/grok-video.test.ts create mode 100644 src/grok-video.ts diff --git a/docs/grok-video/index.html b/docs/grok-video/index.html new file mode 100644 index 0000000..fca3977 --- /dev/null +++ b/docs/grok-video/index.html @@ -0,0 +1,308 @@ + + + + + + Grok Imagine Video — aimock + + + + + + + + + + +
+ + +
+

Grok Imagine Video

+

+ aimock mocks xAI's native Grok Imagine async video-generation API — submit a job + under POST /v1/videos/generations, then poll it through + pending → done | failed | expired under + GET /v1/videos/{request_id}. It draws from the same + endpoint: "video" fixture pool as the other video surfaces, disambiguated by + the grok-imagine-* model string. +

+ +

Endpoints

+ + + + + + + + + + + + + + + + + + + + +
MethodPathResponse
POST/v1/videos/generations + { request_id } job envelope (JSON only — a + multipart/form-data body is rejected with 400 before the body is + parsed); the matched fixture's video drives the job's terminal state +
GET/v1/videos/{request_id} + { request_id, status, progress } — plus video.url / + video.duration + usage.cost_in_usd_ticks once + done, or those same + { request_id, status, progress } fields plus code / + error once failed +
+ +

Sora-safe dispatch

+

+ Grok's status route, GET /v1/videos/{request_id}, shares its path shape with + the OpenAI-shaped Sora /v1/videos/{id} surface. aimock + resolves this with a job-map-first lookup: a GET that matches a live Grok job is served by + the Grok handler, and any miss falls through to the unchanged Sora + handleVideoStatus byte-for-byte (the two id namespaces are disjoint — + Sora ids come from its own state map, Grok request ids are minted UUIDs). The literal + /v1/videos/generations submit path is guarded so it is never parsed as a + status id of "generations". Existing Sora behavior is unaffected. +

+ +

Fixture Authoring

+

+ Submits are matched against endpoint: "video" fixtures on the request's + prompt (via match.userMessage, read from + body.prompt) and model (via match.model, default + grok-imagine-video). The grok-imagine-* model string is the + provider disambiguator — Grok, Veo, and OpenRouter video fixtures share one match + namespace, and their model tokens never overlap. +

+ +
+
grok-video.test.ts ts
+
mock.onVideo("a cat playing piano", {
+  // `id` is required by the type but ignored on this surface (the request_id
+  // is always server-minted); `url` is served as-is, no byte proxying.
+  video: { id: "vid_1", status: "completed", url: "https://videos.x.ai/abc.mp4", duration: 6, cost: 0.12 },
+}, { model: "grok-imagine-video" });
+
+// A failed job:
+mock.onVideo("impossible prompt", {
+  video: { id: "vid_2", status: "failed", error: "content policy violation" },
+}, { model: "grok-imagine-video" });
+
+ +

The fixture's video object supports:

+
    +
  • + status"completed" or "failed" sets the + job's terminal state. The wire status is derived from the stored value: a + stored "completed" serializes to the wire "done"; the stored + status is always "completed", never the wire "done". +
  • +
  • + id — ignored on this surface (the request_id is always a + server-minted UUID) +
  • +
  • + url? — the video URL surfaced as video.url on a done + poll, served as-is (no byte proxying) +
  • +
  • + duration? — clip duration surfaced as + video.duration (defaults to 0) +
  • +
  • + cost? — generation cost in USD, surfaced as + usage.cost_in_usd_ticks on completion (see units below) +
  • +
  • + error? — failure message surfaced as { code, error } on + a failed poll +
  • +
+ +

Progress & Polling Realism

+

+ Grok reports a progress percentage on every poll; aimock + synthesizes it from the poll count (climbing toward 100, reaching + 100 when the job is done). By default a submitted job is seeded + terminal internally. To exercise client code that polls through intermediate states, pass + grokVideo with poll thresholds. The semantics are identical to + falQueue and the + OpenRouter video surface. +

+ +
+
polling.test.ts ts
+
const mock = new LLMock({
+  port: 0,
+  grokVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
+});
+
+// Submit  → { request_id }
+// poll 1  → { request_id, status: "pending", progress: … }
+// poll 2  → { request_id, status: "done", progress: 100, video: { url, duration }, usage: { cost_in_usd_ticks } }
+
+ +

+ Thresholds are sanitized exactly as on the other video surfaces (non-finite treated as + unset; negatives/fractions floored and clamped). createServer warns at + startup on invalid values. +

+ +

JSON-only — multipart rejected

+
+

+ The Grok video API is JSON-only. A submit carrying a + Content-Type: multipart/form-data body is rejected with HTTP 400 and a + { code, error } envelope before the body is parsed — aimock + does not reuse Sora's accept-multipart create branch. A non-object or malformed JSON + body and an empty prompt likewise return + { code: "invalid_request", error }. +

+
+ +

Cost units

+

+ Grok bills in cost_in_usd_ticks, an integer count of USD ticks where + 1 USD = 1e10 ticks. A fixture's video.cost is the cost in USD; + on a done poll aimock surfaces + usage.cost_in_usd_ticks = round(cost × 1e10). Record mode persists the + upstream cost back to USD (cost = cost_in_usd_ticks / 1e10) so it + round-trips. +

+ +

Chaos & Metrics

+

+ Chaos injection applies to both routes. In + Prometheus metrics the per-job status path is templated as + /v1/videos/{request_id} to keep label cardinality bounded. +

+ +

Record Mode

+

+ With record mode and the grok provider + configured, an unmatched submit becomes a live interactive proxy: the + /v1/videos/generations POST is forwarded to the real API and answered with a + mock-rewritten { request_id } envelope (a fresh aimock request id), and each + client poll is proxied upstream 1:1 with the mock request id substituted. The client's own + polling drives the upstream lifecycle. +

+ +
+
aimock.config.json json
+
{
+  "llm": {
+    "fixtures": "./fixtures",
+    "record": {
+      "providers": { "grok": "https://api.x.ai" }
+    }
+  }
+}
+
+ +
+
Programmatic config ts
+
const mock = new LLMock({
+  record: {
+    providers: { grok: "https://api.x.ai" },
+  },
+});
+
+ +

+ When the upstream poll reports done, the completed poll body is relayed first + and the eager capture then runs synchronously on the request stack (there is no + byte download to detach): aimock reads the video URL, duration, and cost straight out of + the terminal poll body (no download — the URL is served as-is) and persists a normal + video fixture (match.userMessage = the prompt, match.model = the + submitted model under the standard model-normalization rules, video.id = the + upstream request id, video.status = "completed", + video.url / video.duration, plus cost converted + from cost_in_usd_ticks). The same submit then replays in-session and across + sessions. Relayed poll bodies are otherwise faithful: status, progress, + video.url, and usage pass through verbatim with only the request + id rewritten. A failed upstream persists a failed fixture ({ status: "failed", error }) and relays { request_id, status, progress, code, error }; + expired (and any terminal status not representable in + video.status) passes through with a warning, persists nothing, and keeps + proxying. +

+ +

+ The polling client's Bearer credential is forwarded only to the configured provider + origin: the submit response carries just a request_id (never an upstream poll + URL), so there is nothing off-origin to adopt or validate — the poll URL is always + constructed on the configured provider origin from that request id. Strict mode wins over + record: a strict no-match returns 503 and nothing is proxied. Without a configured + grok provider URL, --record warns and serves the normal no-match + 404. Under proxy-only mode (record.proxyOnly / --proxy-only) + nothing is persisted and a done job is never converted to a local replay job — every + poll keeps proxying upstream. +

+ +
+

+ TTL caveat: record-mode jobs live in the same bounded job map as replay + jobs (1-hour TTL, 10,000 entries). Each successful proxied poll refreshes a record job's + TTL, so an actively-polled long render is never evicted mid-recording — but a poll + arriving more than an hour after the last successful poll finds the job evicted + and returns 404. +

+
+
+ +
+ +
+ +
+ + + + diff --git a/src/__tests__/grok-video-record.test.ts b/src/__tests__/grok-video-record.test.ts new file mode 100644 index 0000000..1be5424 --- /dev/null +++ b/src/__tests__/grok-video-record.test.ts @@ -0,0 +1,747 @@ +import { describe, test, expect, afterAll, afterEach, vi } from "vitest"; +import * as http from "node:http"; +import * as net from "node:net"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { LLMock } from "../llmock.js"; +import { startRefusingUpstream } from "./helpers/refusing-upstream.js"; + +// ─── Grok video fake upstream ──────────────────────────────────────────────── +// A real http.createServer on 127.0.0.1:0 implementing the two xAI Grok Imagine +// video endpoints: submit (POST /v1/videos/generations → {request_id}) and +// status poll (GET /v1/videos/{id} → pending/done/failed/expired). Tracks +// per-endpoint counts and the last-received headers so tests can assert what +// hit the wire (and with which auth) vs. what was served from aimock's state. + +interface GrokVideoUpstreamOptions { + /** Terminal status the poll endpoint converges on. Default: "done". */ + finalStatus?: "done" | "failed" | "expired"; + /** Non-terminal polls served before the terminal status. Default: 0. */ + pollsBeforeDone?: number; + /** video.url reported on done. Default: a stub https url. */ + url?: string; + /** video.duration reported on done. Default: 6. */ + duration?: number; + /** usage.cost_in_usd_ticks reported on done. Default: 5_000_000_000. */ + costInUsdTicks?: number; + /** error reported on failure. Default: "upstream grok failure". */ + error?: string; + /** Force the SUBMIT endpoint to reply with this HTTP status (JSON error). */ + submitHttpStatus?: number; + /** Force the STATUS endpoint to reply with this HTTP status (JSON error). */ + statusHttpStatus?: number; +} + +interface GrokVideoUpstream { + url: string; + close: () => Promise; + counts: { submit: number; status: number }; + lastHeaders: { submit?: http.IncomingHttpHeaders; status?: http.IncomingHttpHeaders }; +} + +const UPSTREAM_REQUEST_ID = "grok-up-1"; + +function startGrokVideoUpstream(opts: GrokVideoUpstreamOptions): Promise { + const finalStatus = opts.finalStatus ?? "done"; + const pollsBeforeDone = opts.pollsBeforeDone ?? 0; + const url = opts.url ?? "https://cdn.x.ai/stub-grok-video.mp4"; + const duration = opts.duration ?? 6; + const costInUsdTicks = opts.costInUsdTicks ?? 5_000_000_000; + const error = opts.error ?? "upstream grok failure"; + const counts = { submit: 0, status: 0 }; + const lastHeaders: GrokVideoUpstream["lastHeaders"] = {}; + const statusPolls = new Map(); + const statusRe = /^\/v1\/videos\/([^/]+)$/; + + return new Promise((resolve, reject) => { + let selfUrl = "http://stub"; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const reqUrl = new URL(req.url ?? "/", selfUrl); + const sendJson = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + if (req.method === "POST" && reqUrl.pathname === "/v1/videos/generations") { + counts.submit++; + lastHeaders.submit = req.headers; + if (opts.submitHttpStatus !== undefined && opts.submitHttpStatus !== 200) { + sendJson(opts.submitHttpStatus, { + code: "submit_rejected", + error: "stub submit rejected", + }); + return; + } + sendJson(200, { request_id: UPSTREAM_REQUEST_ID }); + return; + } + + const statusMatch = reqUrl.pathname.match(statusRe); + if (req.method === "GET" && statusMatch && statusMatch[1] !== "generations") { + counts.status++; + lastHeaders.status = req.headers; + if (opts.statusHttpStatus !== undefined && opts.statusHttpStatus !== 200) { + sendJson(opts.statusHttpStatus, { code: "poll_rejected", error: "stub poll rejected" }); + return; + } + const id = statusMatch[1]; + const n = (statusPolls.get(id) ?? 0) + 1; + statusPolls.set(id, n); + if (n <= pollsBeforeDone) { + sendJson(200, { status: "pending", progress: Math.min(90, n * 30) }); + return; + } + if (finalStatus === "done") { + sendJson(200, { + status: "done", + progress: 100, + video: { url, duration }, + usage: { cost_in_usd_ticks: costInUsdTicks }, + }); + return; + } + if (finalStatus === "failed") { + sendJson(200, { status: "failed", code: "generation_failed", error }); + return; + } + // expired (terminal, not representable in VideoResponse.status) + sendJson(200, { status: "expired", progress: 100 }); + return; + } + + sendJson(404, { code: "not_found", error: `stub: unhandled ${reqUrl.pathname}` }); + }); + }); + server.once("error", reject); + const sockets = new Set(); + server.on("connection", (s) => { + sockets.add(s); + s.on("close", () => sockets.delete(s)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + selfUrl = `http://127.0.0.1:${port}`; + resolve({ + url: selfUrl, + counts, + lastHeaders, + close: () => + new Promise((r) => { + for (const s of sockets) s.destroy(); + server.close(() => r()); + }), + }); + }); + }); +} + +function readRecordedFixtureFiles(dir: string): { file: string; content: unknown }[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + file: f, + content: JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")), + })); +} + +async function waitUntil(cond: () => boolean, timeoutMs = 5000, intervalMs = 20): Promise { + const deadline = performance.now() + timeoutMs; + for (;;) { + if (cond()) return; + if (performance.now() > deadline) { + throw new Error(`waitUntil: condition not met within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +// ─── Stub self-test ────────────────────────────────────────────────────────── + +describe("startGrokVideoUpstream (stub self-test)", () => { + let upstream: GrokVideoUpstream | undefined; + + afterEach(async () => { + await upstream?.close(); + upstream = undefined; + }); + + test("submit returns {request_id}, counts the call, captures headers", async () => { + upstream = await startGrokVideoUpstream({}); + const res = await fetch(`${upstream.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-upstream" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "a sunset" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.request_id).toBe("grok-up-1"); + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-upstream"); + }); + + test("status polls advance pending then done with video and usage", async () => { + upstream = await startGrokVideoUpstream({ pollsBeforeDone: 1, costInUsdTicks: 4_200_000_000 }); + const url = `${upstream.url}/v1/videos/grok-up-1`; + expect((await (await fetch(url)).json()).status).toBe("pending"); + const done = await (await fetch(url)).json(); + expect(done.status).toBe("done"); + expect(done.progress).toBe(100); + expect(done.video.url).toBe("https://cdn.x.ai/stub-grok-video.mp4"); + expect(done.usage).toEqual({ cost_in_usd_ticks: 4_200_000_000 }); + expect(upstream.counts.status).toBe(2); + }); + + test("failed final status carries code+error", async () => { + upstream = await startGrokVideoUpstream({ finalStatus: "failed", error: "nsfw" }); + const data = await (await fetch(`${upstream.url}/v1/videos/j1`)).json(); + expect(data.status).toBe("failed"); + expect(data.error).toBe("nsfw"); + }); +}); + +// ─── Config acceptance ─────────────────────────────────────────────────────── + +const refusingUpstream = await startRefusingUpstream(); +const UPSTREAM_DOWN_URL = refusingUpstream.url; +afterAll(() => refusingUpstream.close()); + +describe("Grok video record — config acceptance", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("LLMock accepts record.providers.grok", async () => { + mock = new LLMock({ + port: 0, + record: { providers: { grok: "https://api.x.ai" } }, + }); + await mock.start(); + expect(mock.url).toMatch(/^http:/); + }); +}); + +// ─── Submit proxy ──────────────────────────────────────────────────────────── + +describe("Grok video record — submit proxy", () => { + let mock: LLMock | undefined; + let upstream: GrokVideoUpstream | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-grok-video-record-")); + } + + test("proxies an unmatched submit and returns a mock-rewritten {request_id}", async () => { + upstream = await startGrokVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer sk-test", + "X-Test-Id": "rec-grok", + }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "record me" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(typeof data.request_id).toBe("string"); + expect(data.request_id.length).toBeGreaterThan(0); + expect(data.request_id).not.toBe("grok-up-1"); + expect(upstream.counts.submit).toBe(1); + expect(upstream.lastHeaders.submit?.authorization).toBe("Bearer sk-test"); + + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/v1/videos/generations"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(200); + expect(entry!.response.source).toBe("proxy"); + }); + + test("strict mode wins over record: 503, nothing proxied", async () => { + upstream = await startGrokVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + strict: true, + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "no fixture" }), + }); + expect(res.status).toBe(503); + expect((await res.json()).code).toBe("no_fixture_match"); + expect(upstream.counts.submit).toBe(0); + }); + + test("record without a grok provider warns and falls through to 404", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { openai: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + await mock.start(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "unrecorded" }), + }); + expect(res.status).toBe(404); + expect( + warnSpy.mock.calls.some((c) => + c.join(" ").includes('No upstream URL configured for provider "grok"'), + ), + ).toBe(true); + }); + + test("upstream connection failure returns 502 proxy_error journaled as proxy", async () => { + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { grok: UPSTREAM_DOWN_URL }, fixturePath: tmpDir }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "dead upstream" }), + }); + expect(res.status).toBe(502); + expect((await res.json()).error).toContain("Proxy to upstream failed"); + + const entry = mock.journal + .getAll() + .find((e) => e.method === "POST" && e.path === "/v1/videos/generations"); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(502); + expect(entry!.response.source).toBe("proxy"); + }); + + test("a matching fixture still replays without touching the upstream", async () => { + upstream = await startGrokVideoUpstream({}); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + mock.addFixture({ + match: { userMessage: "already recorded", endpoint: "video" }, + response: { video: { id: "vid_r", status: "completed", url: "https://x/v.mp4" } }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "already recorded" }), + }); + expect(res.status).toBe(200); + expect(upstream.counts.submit).toBe(0); + }); +}); + +// ─── Poll proxy + eager capture ────────────────────────────────────────────── + +describe("Grok video record — poll proxy and eager capture", () => { + let mock: LLMock | undefined; + let upstream: GrokVideoUpstream | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "aimock-grok-video-poll-")); + } + + async function startRecordingMock(upstreamUrl: string): Promise { + tmpDir = makeTmpDir(); + const m = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { grok: upstreamUrl }, fixturePath: tmpDir }, + }); + await m.start(); + return m; + } + + async function submitRecordJob(m: LLMock, prompt: string): Promise<{ request_id: string }> { + const res = await fetch(`${m.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { request_id: string }; + } + + test("pending polls are proxied 1:1 with synthesized/relayed progress", async () => { + upstream = await startGrokVideoUpstream({ pollsBeforeDone: 2 }); + mock = await startRecordingMock(upstream.url); + const { request_id } = await submitRecordJob(mock, "slow record"); + + const poll1 = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll1.status).toBe("pending"); + expect(typeof poll1.progress).toBe("number"); + expect(upstream.counts.status).toBe(1); + + const poll2 = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll2.status).toBe("pending"); + expect(upstream.counts.status).toBe(2); + + const pollEntries = mock.journal + .getAll() + .filter((e) => e.method === "GET" && e.path.startsWith(`/v1/videos/${request_id}`)); + expect(pollEntries).toHaveLength(2); + for (const e of pollEntries) { + expect(e.response.status).toBe(200); + expect(e.response.source).toBe("proxy"); + } + }); + + test("done poll captures the fixture eagerly (url, duration, cost) — NO byte download", async () => { + upstream = await startGrokVideoUpstream({ + url: "https://cdn.x.ai/captured.mp4", + duration: 8, + costInUsdTicks: 4_200_000_000, + }); + mock = await startRecordingMock(upstream.url); + const { request_id } = await submitRecordJob(mock, "capture me"); + + const poll = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll.request_id).toBe(request_id); + expect(poll.status).toBe("done"); + expect(poll.video.url).toBe("https://cdn.x.ai/captured.mp4"); + expect(poll.video.duration).toBe(8); + expect(poll.usage).toEqual({ cost_in_usd_ticks: 4_200_000_000 }); + + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + + const files = readRecordedFixtureFiles(tmpDir!); + expect(files).toHaveLength(1); + const saved = files[0].content as { fixtures: unknown[] }; + expect(saved.fixtures).toHaveLength(1); + // STORED video.status is "completed" (NOT the wire "done"); cost persisted + // as USD (ticks/1e10 = 4_200_000_000 / 1e10 = 0.42). + expect(saved.fixtures[0]).toEqual({ + match: { endpoint: "video", userMessage: "capture me", model: "grok-imagine-video" }, + response: { + video: { + id: "grok-up-1", + status: "completed", + url: "https://cdn.x.ai/captured.mp4", + duration: 8, + cost: 0.42, + }, + }, + }); + + // A second identical submit replays without touching the upstream again. + const second = await submitRecordJob(mock!, "capture me"); + expect(upstream.counts.submit).toBe(1); + const secondPoll = await (await fetch(`${mock.url}/v1/videos/${second.request_id}`)).json(); + expect(secondPoll.status).toBe("done"); + expect(secondPoll.video.url).toBe("https://cdn.x.ai/captured.mp4"); + expect(secondPoll.usage).toEqual({ cost_in_usd_ticks: 4_200_000_000 }); + expect(upstream.counts.status).toBe(1); // served from the mutated replay job + }); + + test("post-capture polls served from the mutated replay job (upstream not hit again)", async () => { + upstream = await startGrokVideoUpstream({}); + mock = await startRecordingMock(upstream.url); + const { request_id } = await submitRecordJob(mock, "poll after done"); + + await (await fetch(`${mock.url}/v1/videos/${request_id}`)).arrayBuffer(); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + expect(upstream.counts.status).toBe(1); + + const again = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(again.status).toBe("done"); + expect(upstream.counts.status).toBe(1); // served from the mutated replay job + }); + + test("failed upstream persists a failed fixture and relays {code,error}", async () => { + upstream = await startGrokVideoUpstream({ + finalStatus: "failed", + error: "content policy violation", + }); + mock = await startRecordingMock(upstream.url); + const { request_id } = await submitRecordJob(mock, "doomed record"); + + const poll = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll.status).toBe("failed"); + expect(typeof poll.code).toBe("string"); + expect(poll.error).toBe("content policy violation"); + + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + const files = readRecordedFixtureFiles(tmpDir!); + const saved = files[0].content as { + fixtures: { response: { video: Record } }[]; + }; + expect(saved.fixtures[0].response).toEqual({ + video: { id: "grok-up-1", status: "failed", error: "content policy violation" }, + }); + + // Terminal conversion: the next poll is served from memory. + const again = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(again.status).toBe("failed"); + expect(again.error).toBe("content policy violation"); + expect(upstream.counts.status).toBe(1); + }); + + test("expired upstream passes through, warns, persists nothing, keeps proxying", async () => { + upstream = await startGrokVideoUpstream({ finalStatus: "expired" }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "warn", + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + await mock.start(); + const { request_id } = await (async () => { + const res = await fetch(`${mock!.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "expired record" }), + }); + return (await res.json()) as { request_id: string }; + })(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const poll = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll.status).toBe("expired"); + expect(warnSpy.mock.calls.some((c) => c.join(" ").includes("not representable"))).toBe(true); + + // No fixture written; subsequent polls keep proxying live. + expect(readRecordedFixtureFiles(tmpDir!)).toHaveLength(0); + const again = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(again.status).toBe("expired"); + expect(upstream.counts.status).toBe(2); + }); + + test("upstream poll failure returns 502 proxy_error journaled as proxy", async () => { + upstream = await startGrokVideoUpstream({}); + mock = await startRecordingMock(upstream.url); + const { request_id } = await submitRecordJob(mock, "upstream dies"); + await upstream.close(); + upstream = undefined; + + const poll = await fetch(`${mock.url}/v1/videos/${request_id}`); + expect(poll.status).toBe(502); + expect((await poll.json()).error).toContain("Proxy to upstream failed"); + const entry = mock.journal + .getAll() + .find((e) => e.method === "GET" && e.path.startsWith(`/v1/videos/${request_id}`)); + expect(entry).toBeDefined(); + expect(entry!.response.status).toBe(502); + expect(entry!.response.source).toBe("proxy"); + }); + + test("Sora status still unaffected while a Grok record job exists", async () => { + upstream = await startGrokVideoUpstream({ pollsBeforeDone: 5 }); + tmpDir = makeTmpDir(); + mock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + // Sora fixture coexists with the live Grok record job. + mock.addFixture({ + match: { userMessage: "sora coexists", endpoint: "video" }, + response: { video: { id: "video_sora_x", status: "completed", url: "https://s/c.mp4" } }, + }); + await mock.start(); + + // Create the Grok record job (a live proxy, never terminal here). + await ( + await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "live grok" }), + }) + ).arrayBuffer(); + + // Create + poll a Sora video: it must serve the Sora envelope, NOT a Grok + // proxy poll (the Grok job map miss falls through to handleVideoStatus). + const create = await fetch(`${mock.url}/v1/videos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "sora-2", prompt: "sora coexists" }), + }); + const created = await create.json(); + const status = await fetch(`${mock.url}/v1/videos/${created.id}`); + expect(status.status).toBe(200); + const body = await status.json(); + expect(body.id).toBe(created.id); + expect(body.status).toBe("completed"); + expect(typeof body.created_at).toBe("number"); + expect(body.request_id).toBeUndefined(); + expect(body.progress).toBeUndefined(); + }); +}); + +// ─── Full record → replay round trip ───────────────────────────────────────── + +describe("Grok video record — round trip (record session then replay session)", () => { + let recordMock: LLMock | undefined; + let replayMock: LLMock | undefined; + let upstream: GrokVideoUpstream | undefined; + let tmpDir: string | undefined; + + afterEach(async () => { + await recordMock?.stop(); + recordMock = undefined; + await replayMock?.stop(); + replayMock = undefined; + await upstream?.close(); + upstream = undefined; + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function recordLifecycle(prompt: string): Promise { + if (!recordMock) throw new Error("record mock not started"); + const submit = await fetch(`${recordMock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer sk-rec" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt }), + }); + expect(submit.status).toBe(200); + const { request_id } = (await submit.json()) as { request_id: string }; + for (let i = 0; i < 10; i++) { + const poll = await ( + await fetch(`${recordMock.url}/v1/videos/${request_id}`, { + headers: { Authorization: "Bearer sk-rec" }, + }) + ).json(); + if (poll.status === "done" || poll.status === "failed") return; + } + throw new Error("record lifecycle did not terminate"); + } + + test("completed lifecycle round-trips: same url/duration, cost_in_usd_ticks reconstructed", async () => { + upstream = await startGrokVideoUpstream({ + url: "https://cdn.x.ai/round-trip.mp4", + duration: 10, + costInUsdTicks: 12_300_000_000, // 1.23 USD + pollsBeforeDone: 1, + }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-grok-video-roundtrip-")); + + // ── Session 1: record ── + recordMock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + await recordMock.start(); + await recordLifecycle("round trip render"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + await recordMock.stop(); + recordMock = undefined; + await upstream.close(); + upstream = undefined; + + // ── Session 2: replay from the recorded fixture file only ── + replayMock = new LLMock({ port: 0 }); + replayMock.loadFixtureDir(tmpDir); + await replayMock.start(); + + const submit = await fetch(`${replayMock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "round trip render" }), + }); + expect(submit.status).toBe(200); + const { request_id } = (await submit.json()) as { request_id: string }; + + const poll = await (await fetch(`${replayMock.url}/v1/videos/${request_id}`)).json(); + expect(poll.status).toBe("done"); + expect(poll.video.url).toBe("https://cdn.x.ai/round-trip.mp4"); + expect(poll.video.duration).toBe(10); + // ticks = round(1.23 * 1e10) = 12_300_000_000 + expect(poll.usage).toEqual({ cost_in_usd_ticks: 12_300_000_000 }); + }); + + test("failed lifecycle round-trips with the same error", async () => { + upstream = await startGrokVideoUpstream({ finalStatus: "failed", error: "model exploded" }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "aimock-grok-video-roundtrip-")); + + recordMock = new LLMock({ + port: 0, + logLevel: "silent", + record: { providers: { grok: upstream.url }, fixturePath: tmpDir }, + }); + await recordMock.start(); + await recordLifecycle("round trip failure"); + await waitUntil(() => readRecordedFixtureFiles(tmpDir!).length === 1); + await recordMock.stop(); + recordMock = undefined; + await upstream.close(); + upstream = undefined; + + replayMock = new LLMock({ port: 0 }); + replayMock.loadFixtureDir(tmpDir); + await replayMock.start(); + + const submit = await fetch(`${replayMock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "round trip failure" }), + }); + expect(submit.status).toBe(200); + const { request_id } = (await submit.json()) as { request_id: string }; + + const poll = await (await fetch(`${replayMock.url}/v1/videos/${request_id}`)).json(); + expect(poll.status).toBe("failed"); + expect(poll.error).toBe("model exploded"); + }); +}); diff --git a/src/__tests__/grok-video.test.ts b/src/__tests__/grok-video.test.ts new file mode 100644 index 0000000..32cd35e --- /dev/null +++ b/src/__tests__/grok-video.test.ts @@ -0,0 +1,373 @@ +import { describe, test, expect, afterEach, vi } from "vitest"; +import { LLMock } from "../llmock.js"; +import { GrokVideoJobMap, GROK_VIDEO_MAX_ENTRIES } from "../grok-video.js"; +import type { VideoResponse } from "../types.js"; + +// ─── GrokVideoJobMap unit surface ──────────────────────────────────────────── + +describe("GrokVideoJobMap", () => { + test("is exported with the bounded-entries constant", () => { + expect(GROK_VIDEO_MAX_ENTRIES).toBe(10_000); + const map = new GrokVideoJobMap(); + expect(map.size).toBe(0); + expect(map.generation).toBe(0); + map.clear(); + expect(map.generation).toBe(1); + }); +}); + +// ─── POST /v1/videos/generations (Grok submit) ─────────────────────────────── + +describe("POST /v1/videos/generations (Grok submit)", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("fixture match returns {request_id}", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "a sunset over the ocean", endpoint: "video" }, + response: { + video: { id: "vid_grok_1", status: "completed", url: "https://example.com/v.mp4" }, + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ + model: "grok-imagine-video", + prompt: "a sunset over the ocean", + }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(typeof data.request_id).toBe("string"); + expect(data.request_id.length).toBeGreaterThan(0); + }); + + test("poll synthesizes climbing progress then done with video.url/duration and usage.cost_in_usd_ticks", async () => { + mock = new LLMock({ + port: 0, + grokVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 }, + }); + mock.addFixture({ + match: { userMessage: "poll me", endpoint: "video" }, + response: { + video: { + id: "vid_grok_p", + status: "completed", + url: "https://cdn.x.ai/v.mp4", + duration: 6, + cost: 0.05, + }, + }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "poll me" }), + }); + const { request_id } = await submit.json(); + + // pollsBeforeInProgress:1, pollsBeforeCompleted:2 → poll1 = in_progress + // (wire "pending", climbing progress), poll2 = completed (wire "done"). + const poll1 = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(poll1.request_id).toBe(request_id); + expect(poll1.status).toBe("pending"); + expect(typeof poll1.progress).toBe("number"); + expect(poll1.progress).toBeLessThan(100); + + const done = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(done.status).toBe("done"); + expect(done.progress).toBe(100); + expect(done.video).toEqual({ url: "https://cdn.x.ai/v.mp4", duration: 6 }); + // cost_in_usd_ticks = round(cost * 1e10) = round(0.05 * 1e10) = 500000000 + expect(done.usage).toEqual({ cost_in_usd_ticks: 500_000_000 }); + }); + + test("0/0 progression seeds done immediately; default url placeholder when fixture omits it", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "seeded done", endpoint: "video" }, + response: { video: { id: "vid_seed", status: "completed" } }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "seeded done" }), + }); + const { request_id } = await submit.json(); + const data = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(data.status).toBe("done"); + expect(data.progress).toBe(100); + // Fixture omitted url → documented placeholder served. + expect(typeof data.video.url).toBe("string"); + expect(data.video.url.length).toBeGreaterThan(0); + expect(data.video.duration).toBe(0); + expect(data.usage).toEqual({ cost_in_usd_ticks: 0 }); + }); + + test("multipart submit 400s BEFORE body parse", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "ignored", endpoint: "video" }, + response: { video: { id: "vid_mp", status: "completed" } }, + }); + await mock.start(); + + // Deliberately invalid (non-JSON) body: a multipart reject must fire BEFORE + // any body parse, so a body that would otherwise 400-as-malformed-JSON + // still yields the multipart 400 shape. + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "multipart/form-data; boundary=xyz" }, + body: "--xyz\r\nContent-Disposition: form-data; name=prompt\r\n\r\nhi\r\n--xyz--", + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(typeof data.code).toBe("string"); + expect(typeof data.error).toBe("string"); + expect(data.error.toLowerCase()).toContain("multipart"); + }); + + test("malformed JSON submit 400s with {code,error}", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(typeof data.code).toBe("string"); + expect(typeof data.error).toBe("string"); + }); + + test("missing prompt 400s with {code,error}", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.code).toBe("invalid_request"); + expect(data.error).toContain("prompt"); + }); + + test("failed fixture polls to {code,error}", 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}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "doomed" }), + }); + const { request_id } = await submit.json(); + + const data = await (await fetch(`${mock.url}/v1/videos/${request_id}`)).json(); + expect(data.status).toBe("failed"); + expect(typeof data.code).toBe("string"); + expect(data.error).toBe("content policy violation"); + // Seed-terminal (default 0/0 progression) FAILED job: zero polls toward the + // never-reached completion target → progress-at-failure is 0. A failed job + // must NOT report 100 (100 is the wire signal for a completed/"done" job). + expect(data.progress).toBe(0); + }); + + test("strict 503; non-strict 404", async () => { + mock = new LLMock({ port: 0, strict: true }); + await mock.start(); + + const strictRes = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "no such fixture" }), + }); + expect(strictRes.status).toBe(503); + const strictData = await strictRes.json(); + expect(strictData.code).toBe("no_fixture_match"); + await mock.stop(); + + mock = new LLMock({ port: 0 }); + await mock.start(); + const looseRes = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "no such fixture" }), + }); + expect(looseRes.status).toBe(404); + const looseData = await looseRes.json(); + expect(typeof looseData.code).toBe("string"); + expect(typeof looseData.error).toBe("string"); + }); + + test("unknown request_id 404s with {code,error}", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + // A Grok job map miss for an id that is NOT a Sora video falls through to + // Sora handleVideoStatus, which 404s. The id must not be "generations". + const res = await fetch(`${mock.url}/v1/videos/no-such-grok-id`); + expect(res.status).toBe(404); + }); + + test("GET /v1/videos/generations is NOT parsed as status id=generations", async () => { + mock = new LLMock({ port: 0 }); + await mock.start(); + // The status RE guard excludes id === "generations"; a GET here must not be + // routed to the Grok/Sora status handler. It falls through to the global + // 404 (no such route for GET on that literal). + const res = await fetch(`${mock.url}/v1/videos/generations`); + expect(res.status).toBe(404); + await res.arrayBuffer(); + }); +}); + +// ─── Sora /v1/videos/{id} pinned byte-for-byte by the Grok-first dispatch ───── + +describe("Sora status unaffected by the Grok route", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + await mock?.stop(); + mock = undefined; + }); + + test("Sora POST /v1/videos then GET /v1/videos/{soraId} returns the Sora envelope; unknown id 404s Sora-style", async () => { + mock = new LLMock({ port: 0 }); + // A Sora video fixture (NOT a Grok job): Sora's POST /v1/videos seeds the + // VideoStateMap, and GET /v1/videos/{id} must serve the Sora envelope + // byte-for-byte (id/status/created_at[/url]) — proving the Grok-first + // dispatch falls through to the UNCHANGED handleVideoStatus on a miss. + mock.addFixture({ + match: { userMessage: "sora clip", endpoint: "video" }, + response: { video: { id: "video_sora_1", status: "completed", url: "https://s/v.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: "sora clip" }), + }); + expect(create.status).toBe(200); + const created = await create.json(); + const soraId: string = created.id; + expect(typeof soraId).toBe("string"); + + const status = await fetch(`${mock.url}/v1/videos/${soraId}`); + expect(status.status).toBe(200); + const body = await status.json(); + // Exact Sora envelope shape — NO Grok fields (no request_id, no progress). + expect(body.id).toBe(soraId); + expect(body.status).toBe("completed"); + expect(typeof body.created_at).toBe("number"); + expect(body.url).toBe("https://s/v.mp4"); + expect(body.request_id).toBeUndefined(); + expect(body.progress).toBeUndefined(); + + // Unknown id → Sora 404 shape (type: not_found), NOT a Grok {code,error}. + const missing = await fetch(`${mock.url}/v1/videos/totally-unknown-id`); + expect(missing.status).toBe(404); + const missData = await missing.json(); + expect(missData.error.type).toBe("not_found"); + }); +}); + +// ─── Dispatch error journaling (GET /v1/videos/{id} Grok-first) ────────────── +// +// The GET /v1/videos/{id} dispatch fronts BOTH Grok status and Sora's +// fall-through. A throw from handleGrokVideoStatus must be journaled as a 500 +// and answered with the server_error envelope, mirroring the sibling video +// dispatches (POST /v1/videos, Grok submit, Veo create/status). We force the +// handler to throw mid-poll by making its replay journal.add (status:200) throw +// AFTER a real Grok job exists — the narrowest seam at the real failure surface +// (a poll that blows up before it can respond). The dispatch wrapper's own +// status:500 journal write must still land so the failed poll is visible. +describe("GET /v1/videos/{id} dispatch journals handler errors as 500 (Grok-first)", () => { + let mock: LLMock | undefined; + + afterEach(async () => { + vi.restoreAllMocks(); + await mock?.stop(); + mock = undefined; + }); + + test("a throw from handleGrokVideoStatus returns a journaled 500", async () => { + mock = new LLMock({ port: 0 }); + // Seed a completed Grok fixture and submit so a real replay job exists; the + // subsequent poll is a genuine Grok hit (not a Sora fall-through). + mock.addFixture({ + match: { userMessage: "boom clip", endpoint: "video" }, + response: { video: { id: "vid_boom", status: "completed", url: "https://x/v.mp4" } }, + }); + await mock.start(); + + const submit = await fetch(`${mock.url}/v1/videos/generations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-imagine-video", prompt: "boom clip" }), + }); + expect(submit.status).toBe(200); + const { request_id } = await submit.json(); + expect(typeof request_id).toBe("string"); + + // Seam: the handler's replay path journals { status: 200 } BEFORE it writes + // the response; force THAT add to throw so the throw escapes the handler + // with headers unsent. The dispatch wrapper's own { status: 500 } add (and + // every prior/other add) passes through untouched. + const journal = mock.journal; + const realAdd = journal.add.bind(journal); + vi.spyOn(journal, "add").mockImplementation((entry) => { + if (entry.response.status === 200) { + throw new Error("forced handler explosion"); + } + return realAdd(entry); + }); + + const poll = await fetch(`${mock.url}/v1/videos/${request_id}`); + + // GREEN expectation: dispatch caught the throw, returned a 500 server_error + // envelope, and journaled the failed poll. (RED on un-wrapped code: the + // throw is unhandled — the socket resets / no 500 body — and no status:500 + // journal entry exists.) + expect(poll.status).toBe(500); + const body = await poll.json(); + expect(body.error.type).toBe("server_error"); + expect(body.error.message).toBe("forced handler explosion"); + + const failed = mock + .getRequests() + .filter((e) => e.response.status === 500 && e.path === `/v1/videos/${request_id}`); + expect(failed.length).toBe(1); + expect(failed[0].method).toBe("GET"); + }); +}); + +// Type-only smoke: the stored video status union remains the 3-value set +// (no "done"); the Grok wire "done" is derived at serialization. +describe("VideoResponse stored status", () => { + test("duration is part of the video object", () => { + const v: VideoResponse = { video: { id: "v", status: "completed", duration: 4 } }; + expect(v.video.duration).toBe(4); + }); +}); diff --git a/src/grok-video.ts b/src/grok-video.ts new file mode 100644 index 0000000..3439df6 --- /dev/null +++ b/src/grok-video.ts @@ -0,0 +1,1210 @@ +import type * as http from "node:http"; +import crypto from "node:crypto"; +import type { + ChatCompletionRequest, + Fixture, + HandlerDefaults, + RecordConfig, + 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"; +import { + buildFixtureMatch, + buildForwardHeaders, + persistFixture, + sanitizeHeaderValue, +} from "./recorder.js"; +import { resolveUpstreamUrl } from "./url.js"; +import { handleVideoStatus, type VideoStateMap } from "./video.js"; +import { readEnvelopeText, upstreamTimeoutSignal } from "./video-proxy-shared.js"; + +/** + * xAI Grok Imagine async video lifecycle mock. Submit + * `POST /v1/videos/generations` returns `{ request_id }`; status + * `GET /v1/videos/{request_id}` polls `pending → done | failed | expired` with + * a synthesized `progress`. Multipart submits are rejected with HTTP 400 + * BEFORE body parse (the real API is JSON-only). `video.url` is served AS-IS — + * aimock does NOT proxy or capture video bytes. With `record.providers.grok` + * configured an unmatched submit becomes a live interactive proxy (submit + + * poll forwarded 1:1, eager fixture capture of url/duration/cost on terminal + * status). Strict mode wins. + * + * Grok status shares the `/v1/videos/{id}` path with Sora: the dispatch does a + * job-map-first lookup and falls through to the UNCHANGED Sora + * `handleVideoStatus` on a miss (disjoint id namespaces → unambiguous). + * + * cost_in_usd_ticks ↔ USD: USD = ticks / 1e10 (ASSUMED). Record persists USD in + * `video.cost`; replay reconstructs ticks = round(cost * 1e10). + */ + +interface GrokVideoRequest { + model?: string; + prompt?: string; + [key: string]: unknown; +} + +const DEFAULT_GROK_VIDEO_MODEL = "grok-imagine-video"; + +// USD ←→ Grok cost_in_usd_ticks conversion (ASSUMED: 1e10 ticks per USD). +const GROK_USD_TICKS_PER_USD = 1e10; + +// Default placeholder when a completed fixture omits `video.url` — every other +// coercion on this surface is documented; the real API always returns a url. +const GROK_DEFAULT_VIDEO_URL = "https://cdn.x.ai/aimock-placeholder-video.mp4"; + +// ─── GrokVideoJobMap (TTL + bounded) ──────────────────────────────────────── + +export const GROK_VIDEO_MAX_ENTRIES = 10_000; +const GROK_VIDEO_TTL_MS = 3_600_000; // 1 hour + +type GrokVideoStatus = "pending" | "in_progress" | "completed" | "failed"; + +interface GrokVideoReplayJob { + kind: "replay"; + requestId: string; + status: GrokVideoStatus; + /** 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, url, duration, cost, error). */ + video: VideoResponse["video"]; + /** Latch for the empty-`error` authoring warn on failed polls (once per job). */ + emptyErrorWarned?: boolean; +} + +/** + * A job whose lifecycle is proxied live upstream (record mode, no fixture + * matched at submit). Every client poll is forwarded 1:1 to + * `upstreamPollingUrl`; when the upstream reports a terminal status (`done`/ + * `failed`) the entry is captured as a fixture and MUTATED into a terminal + * GrokVideoReplayJob. `expired` (and any unrepresentable status) pass through + * with a warn and keep proxying. Under `record.proxyOnly` nothing is ever + * captured or mutated. + */ +interface GrokVideoRecordJob { + kind: "record"; + /** The mock-issued requestId the client polls with. */ + requestId: string; + /** Last upstream status relayed to the client. */ + status: GrokVideoStatus; + /** Upstream's own request id (recorded as the fixture's video.id). */ + upstreamRequestId: string; + /** Origin-validated absolute upstream URL proxied on every client poll. */ + upstreamPollingUrl: string; + /** Match snapshot built at submit time (post requestTransform). */ + match: Fixture["match"]; + /** + * Set synchronously before the first await of the eager-capture sequence so + * a concurrent terminal poll relays the upstream body instead of starting a + * second capture/persist. + */ + capturing?: boolean; +} + +export type GrokVideoJob = GrokVideoReplayJob | GrokVideoRecordJob; + +interface GrokVideoEntry { + job: GrokVideoJob; + createdAt: number; +} + +/** + * Per-testId job state for the Grok video handler. Mirrors OpenRouterVideoJobMap + * (openrouter-video.ts): lazy TTL eviction on `get`, FIFO eviction of the + * oldest entries on `set` when over capacity, delete-before-set TTL refresh, + * monotonic world-generation counter for reset-mid-flight detection, no + * background sweep timer. Keys are `${testId}:${requestId}`. + */ +export class GrokVideoJobMap { + private readonly entries = new Map(); + private worldGeneration = 0; + + get generation(): number { + return this.worldGeneration; + } + + get(key: string): GrokVideoJob | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (Date.now() - entry.createdAt > GROK_VIDEO_TTL_MS) { + this.entries.delete(key); + return undefined; + } + return entry.job; + } + + set(key: string, job: GrokVideoJob): void { + this.entries.delete(key); + this.entries.set(key, { job, createdAt: Date.now() }); + if (this.entries.size > GROK_VIDEO_MAX_ENTRIES) { + const excess = this.entries.size - GROK_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(); + this.worldGeneration++; + } + + get size(): number { + return this.entries.size; + } +} + +// ─── Job progression ──────────────────────────────────────────────────────── + +/** + * Maps the fixture's terminal video status onto the job lifecycle. Anything + * that is not "failed" — including a fixture authored as "processing" — is + * treated as completed, since this surface always drives jobs to a terminal + * state. + */ +function terminalStatus(job: GrokVideoReplayJob): GrokVideoStatus { + 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: GrokVideoReplayJob): 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); + } +} + +/** + * Synthesize the Grok wire `progress` (0..100) from poll-count progress toward + * the completed threshold. A terminal-completed job is always 100 (the only way + * to reach 100 — it is the wire signal for "done"). Every other state reports + * its progress-at-failure/at-poll, capped at 99. A seed-terminal job with a + * non-positive completion target has done zero polls toward completion, so it + * reports 0 (a failed seed job is 0%, never 100). + */ +function grokProgress(job: GrokVideoReplayJob): number { + if (job.status === "completed") return 100; + const target = job.pollsBeforeCompleted; + if (target <= 0) return 0; + const ratio = Math.min(1, job.pollCount / target); + return Math.min(99, Math.round(ratio * 100)); +} + +/** Map the internal job status onto the Grok wire `status` token. */ +function grokWireStatus(status: GrokVideoStatus): "done" | "failed" | "pending" { + if (status === "completed") return "done"; + if (status === "failed") return "failed"; + return "pending"; +} + +/** + * Synthesizes a structurally valid journal body for field-validation 400s. + * Mirrors openrouter-video's validationJournalBody: `model` stays a string and + * `messages` is an empty array; underscore-prefixed keys are stripped so a + * request cannot spoof handler-set discriminators. + */ +function validationJournalBody(videoReq: GrokVideoRequest): ChatCompletionRequest { + const rawModel = videoReq.model; + const model = + typeof rawModel === "string" + ? rawModel + : rawModel === undefined + ? "" + : JSON.stringify(rawModel); + const sanitized = Object.fromEntries( + Object.entries(videoReq).filter(([key]) => !key.startsWith("_")), + ); + return { ...sanitized, model, messages: [] }; +} + +// ─── POST /v1/videos/generations — submit ──────────────────────────────────── + +export async function handleGrokVideoCreate( + req: http.IncomingMessage, + res: http.ServerResponse, + raw: string, + fixtures: Fixture[], + journal: Journal, + defaults: HandlerDefaults, + setCorsHeaders: (res: http.ServerResponse) => void, + jobs: GrokVideoJobMap, +): Promise { + setCorsHeaders(res); + const path = req.url ?? "/v1/videos/generations"; + const method = req.method ?? "POST"; + + // MULTIPART REJECT FIRST — the real Grok API is JSON-only. Reject BEFORE any + // body parse so a multipart body never reaches the JSON parser (and never + // reuses Sora's accept-multipart branch). + const contentType = Array.isArray(req.headers["content-type"]) + ? req.headers["content-type"][0] + : req.headers["content-type"]; + if ((contentType ?? "").toLowerCase().includes("multipart/form-data")) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 400, fixture: null }, + }); + writeErrorResponse( + res, + 400, + JSON.stringify({ + code: "invalid_request", + error: "multipart/form-data is not supported — the Grok video API is JSON-only", + }), + ); + return; + } + + let videoReq: GrokVideoRequest; + try { + videoReq = JSON.parse(raw) as GrokVideoRequest; + } 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({ code: "invalid_json", error: `Malformed JSON: ${detail}` }), + ); + return; + } + + // Reject bodies that parsed but are not a JSON object before touching fields. + 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({ code: "invalid_request", error: "Request body must be a JSON object" }), + ); + return; + } + + const parsedBody = validationJournalBody(videoReq); + + if (typeof videoReq.prompt !== "string" || !videoReq.prompt) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: parsedBody, + response: { status: 400, fixture: null }, + }); + 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({ code: "invalid_request", error: message })); + return; + } + + if (videoReq.model !== undefined && (typeof videoReq.model !== "string" || !videoReq.model)) { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: parsedBody, + response: { status: 400, fixture: null }, + }); + writeErrorResponse( + res, + 400, + JSON.stringify({ + code: "invalid_request", + error: "Invalid type for parameter: 'model' must be a non-empty string", + }), + ); + return; + } + + const syntheticReq: ChatCompletionRequest = { + model: videoReq.model ?? DEFAULT_GROK_VIDEO_MODEL, + messages: [{ role: "user", content: videoReq.prompt }], + _endpointType: "video", + _videoProvider: "grok", + _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 { + const snippet = videoReq.prompt.slice(0, 80); + defaults.logger.debug( + `No fixture matched for request (model=${syntheticReq.model}, msg="${snippet}")`, + ); + } + + if ( + applyChaos( + res, + fixture, + defaults.chaos, + req.headers, + journal, + { method, path, headers: flattenHeaders(req.headers), body: syntheticReq }, + fixture + ? "fixture" + : resolveStrictMode(defaults.strict, req.headers) + ? "internal" + : defaults.record?.providers.grok + ? "proxy" + : "internal", + 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({ code: "no_fixture_match", error: strictMessage }), + ); + return; + } + + if (defaults.record) { + const outcome = await proxyGrokVideoSubmit({ + req, + res, + raw, + syntheticReq, + record: defaults.record, + journal, + defaults, + jobs, + method, + path, + }); + if (outcome === "handled") return; + // outcome === "no_upstream" — fall through to 404 (fal convention). + } + + 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({ code: "not_found", error: "No fixture matched" }), + ); + return; + } + + const worldGeneration = jobs.generation; + const response = await resolveResponse(fixture, syntheticReq); + + if (isErrorResponse(response)) { + if (res.destroyed || res.writableEnded) return; + 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)) { + if (res.destroyed || res.writableEnded) return; + 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; + } + + if (res.destroyed || res.writableEnded) return; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { status: 200, fixture }, + }); + + const fixtureStatus: string = response.video.status; + if (fixtureStatus === "processing") { + defaults.logger.warn( + `Video fixture has status "processing" — treated as completed for /v1/videos/generations jobs`, + ); + } else if (fixtureStatus !== "completed" && fixtureStatus !== "failed") { + defaults.logger.warn( + `Video fixture has unknown status "${fixtureStatus}" — treating as completed for /v1/videos/generations jobs`, + ); + } + + const requestId = crypto.randomUUID(); + const progression = resolveProgression(defaults.grokVideo); + const job: GrokVideoReplayJob = { + kind: "replay", + requestId, + status: "pending", + pollCount: 0, + pollsBeforeInProgress: progression.pollsBeforeInProgress, + pollsBeforeCompleted: progression.pollsBeforeCompleted, + video: { ...response.video }, + }; + if (progression.pollsBeforeCompleted === 0) { + job.status = terminalStatus(job); + } + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${requestId}`, job); + } else { + defaults.logger.warn( + `Grok video submit resolved after a fixtures reset — not inserting job ${requestId} into the new world (its polls will 404)`, + ); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ request_id: requestId })); +} + +// ─── GET /v1/videos/{id} — status poll (Grok-first, Sora fall-through) ─────── + +export async function handleGrokVideoStatus( + req: http.IncomingMessage, + res: http.ServerResponse, + id: string, + fixtures: Fixture[], + journal: Journal, + defaults: HandlerDefaults, + setCorsHeaders: (res: http.ServerResponse) => void, + grokJobs: GrokVideoJobMap, + videoStates: VideoStateMap, +): Promise { + const testId = getTestId(req); + const key = `${testId}:${id}`; + const job = grokJobs.get(key); + if (!job) { + // Grok miss → Sora status, UNCHANGED (byte-for-byte; it sets CORS + rolls + // chaos itself). Disjoint id namespaces make this unambiguous. + handleVideoStatus(req, res, id, journal, defaults, setCorsHeaders, videoStates); + return; + } + + // Grok hit: this handler owns the response from here. Set CORS and roll + // chaos (mirroring handleOpenRouterVideoStatus — chaos rolls before any work). + setCorsHeaders(res); + const path = req.url ?? `/v1/videos/${id}`; + 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; + + if (job.kind === "record") { + if (resolveStrictMode(defaults.strict, req.headers)) { + defaults.logger.error( + `STRICT: video job ${id} is proxied live upstream (record mode) — refusing the upstream poll`, + ); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status: 503, + fixture: null, + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 503, + JSON.stringify({ + code: "no_fixture_match", + error: `Strict mode: video job ${id} is proxied live upstream (record mode) — nothing reaches an upstream under strict mode`, + }), + ); + return; + } + await proxyGrokVideoRecordPoll({ + req, + res, + job, + key, + testId, + fixtures, + journal, + defaults, + jobs: grokJobs, + method, + path, + }); + return; + } + + // Replay: guard BEFORE advancing or journaling (file convention). + if (res.destroyed || res.writableEnded) return; + advanceJob(job); + grokJobs.set(key, job); + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 200, fixture: null }, + }); + + const body = serializeGrokReplay(job, defaults); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +/** + * Serialize a replay job into the Grok wire body. STORED `video.status` is the + * 3-value VideoResponse union; the wire `status` is derived here + * (completed → "done"). cost_in_usd_ticks is reconstructed from the stored USD + * `video.cost` (round(cost * 1e10)). + */ +function serializeGrokReplay( + job: GrokVideoReplayJob, + defaults: HandlerDefaults, +): Record { + const body: Record = { + request_id: job.requestId, + status: grokWireStatus(job.status), + progress: grokProgress(job), + }; + if (job.status === "completed") { + body.video = { + url: job.video.url || GROK_DEFAULT_VIDEO_URL, + duration: job.video.duration ?? 0, + }; + body.usage = { + cost_in_usd_ticks: Math.round((job.video.cost ?? 0) * GROK_USD_TICKS_PER_USD), + }; + } else if (job.status === "failed") { + if (job.video.error === "" && !job.emptyErrorWarned) { + job.emptyErrorWarned = true; + defaults.logger.warn( + `Video fixture for job ${job.requestId} has an empty error message — using the default`, + ); + } + body.code = "generation_failed"; + body.error = job.video.error || "Video generation failed"; + } + return body; +} + +// ─── Record mode: live interactive proxy (submit) ──────────────────────────── + +const GROK_VIDEO_SUBMIT_PATH = "/v1/videos/generations"; + +/** + * Proxy an unmatched Grok video submit to the configured upstream and answer + * the client with a mock-rewritten `{ request_id }`. The upstream lifecycle is + * then driven interactively by the client's own polls. Returns "no_upstream" + * when record mode has no grok provider URL — the caller falls through to 404. + */ +async function proxyGrokVideoSubmit(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + raw: string; + syntheticReq: ChatCompletionRequest; + record: RecordConfig; + journal: Journal; + defaults: HandlerDefaults; + jobs: GrokVideoJobMap; + method: string; + path: string; +}): Promise<"handled" | "no_upstream"> { + const { req, res, raw, syntheticReq, record, journal, defaults, jobs, method, path } = args; + + const upstreamBase = record.providers.grok; + if (!upstreamBase) { + defaults.logger.warn(`No upstream URL configured for provider "grok" — cannot proxy`); + return "no_upstream"; + } + + const proxyError = (msg: string): "handled" => { + defaults.logger.error(`Grok video submit proxy failed: ${msg}`); + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 502, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + writeErrorResponse( + res, + 502, + JSON.stringify({ code: "proxy_error", error: `Proxy to upstream failed: ${msg}` }), + ); + return "handled"; + }; + + let submitUrl: URL; + let upstreamOrigin: string; + try { + submitUrl = resolveUpstreamUrl(upstreamBase, GROK_VIDEO_SUBMIT_PATH); + upstreamOrigin = new URL(upstreamBase).origin; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return proxyError(`Invalid upstream URL: ${upstreamBase} (${msg})`); + } + + defaults.logger.warn( + `NO FIXTURE MATCH — proxying video submit to ${upstreamBase}${GROK_VIDEO_SUBMIT_PATH}`, + ); + + const worldGeneration = jobs.generation; + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(submitUrl, { + method: "POST", + headers: buildForwardHeaders(req), + body: raw, + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown proxy error"; + return proxyError(msg); + } + + if (fetched.status === 401 || fetched.status === 403) { + defaults.logger.warn( + `Upstream rejected the video submit (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: fetched.status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" }); + res.end(fetched.text); + return "handled"; + } + + let upstreamRequestId: string; + { + if (fetched.status < 200 || fetched.status >= 300) { + return proxyError(`Submit ${fetched.status}: ${fetched.text.slice(0, 200)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + return proxyError(`Submit returned non-JSON: ${fetched.text.slice(0, 200)}`); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return proxyError("Submit response is not a JSON object"); + } + const envelope = parsed as Record; + upstreamRequestId = String(envelope.request_id ?? "").trim(); + if (!upstreamRequestId) { + return proxyError("Submit response missing request_id"); + } + } + + // Construct the poll URL on the configured provider origin (Grok submit + // returns only request_id, no polling_url — so there is nothing off-origin + // to validate; we always build it ourselves on the trusted origin). + void upstreamOrigin; + const upstreamPollingUrl = resolveUpstreamUrl( + upstreamBase, + `/v1/videos/${encodeURIComponent(upstreamRequestId)}`, + ).toString(); + + const testId = getTestId(req); + const matchRequest = defaults.requestTransform + ? defaults.requestTransform(syntheticReq) + : syntheticReq; + const requestId = crypto.randomUUID(); + const job: GrokVideoRecordJob = { + kind: "record", + requestId, + status: "pending", + upstreamRequestId, + upstreamPollingUrl, + match: buildFixtureMatch(matchRequest, record), + }; + if (jobs.generation === worldGeneration) { + jobs.set(`${testId}:${requestId}`, job); + } else { + defaults.logger.warn( + `Grok video submit for upstream request ${upstreamRequestId} completed after a fixtures reset — not inserting the job into the new world (its polls will 404)`, + ); + } + + if (res.destroyed || res.writableEnded) return "handled"; + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: syntheticReq, + response: { + status: 200, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ request_id: requestId })); + return "handled"; +} + +// ─── Record mode: live interactive proxy (status poll + eager capture) ─────── + +/** + * Proxy a status poll for a record-mode job 1:1 to the upstream and relay the + * result with the mock requestId substituted. On `done` the rewritten body is + * relayed IMMEDIATELY and an eager capture runs (synchronously here — there is + * NO byte download, only the url/duration/cost from the terminal poll body — + * but structured to mirror openrouter-video's terminal path). `failed` + * persists a failed fixture synchronously. `expired` (and any unrepresentable + * status) pass through with a warn and keep proxying. Every post-await map + * mutation is identity-guarded. Each successful proxied poll refreshes the TTL. + */ +async function proxyGrokVideoRecordPoll(args: { + req: http.IncomingMessage; + res: http.ServerResponse; + job: GrokVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + journal: Journal; + defaults: HandlerDefaults; + jobs: GrokVideoJobMap; + method: string; + path: string; +}): Promise { + const { req, res, job, key, testId, fixtures, journal, defaults, jobs, method, path } = args; + const logger = defaults.logger; + + const journalProxy = (status: number): void => { + journal.add({ + method, + path, + headers: flattenHeaders(req.headers), + body: null, + response: { + status, + fixture: null, + source: "proxy", + ...strictOverrideField(defaults.strict, req.headers), + }, + }); + }; + + const proxyError = (msg: string): void => { + logger.error(`Grok video poll proxy failed: ${msg}`); + if (res.destroyed || res.writableEnded) return; + journalProxy(502); + writeErrorResponse( + res, + 502, + JSON.stringify({ code: "proxy_error", error: `Proxy to upstream failed: ${msg}` }), + ); + }; + + const record = defaults.record; + if (!record) { + proxyError("record mode is no longer configured for an in-flight record job"); + return; + } + + let fetched: { status: number; contentType: string | null; text: string }; + try { + const upstreamRes = await fetch(job.upstreamPollingUrl, { + headers: buildForwardHeaders(req), + signal: upstreamTimeoutSignal(record), + }); + fetched = { + status: upstreamRes.status, + contentType: upstreamRes.headers.get("content-type"), + text: await readEnvelopeText(upstreamRes, record), + }; + } catch (err) { + proxyError(err instanceof Error ? err.message : "Unknown proxy error"); + return; + } + + if (fetched.status === 401 || fetched.status === 403) { + logger.warn( + `Upstream rejected the status poll for job ${job.upstreamRequestId} (${fetched.status}) — relaying the upstream status`, + ); + if (res.destroyed || res.writableEnded) return; + journalProxy(fetched.status); + res.writeHead(fetched.status, { "Content-Type": fetched.contentType ?? "application/json" }); + res.end(fetched.text); + return; + } + + let upstreamBody: Record; + { + if (fetched.status < 200 || fetched.status >= 300) { + proxyError(`Status ${fetched.status}: ${fetched.text.slice(0, 200)}`); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(fetched.text); + } catch { + proxyError(`Status returned non-JSON: ${fetched.text.slice(0, 200)}`); + return; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + proxyError("Status response is not a JSON object"); + return; + } + upstreamBody = parsed as Record; + } + + // Rewrite the relay so no upstream id escapes (request_id → mock id); + // status/progress/video/usage pass through verbatim. + const relayBody: Record = { ...upstreamBody, request_id: job.requestId }; + + const relayJson = (body: Record): void => { + if (res.destroyed || res.writableEnded) return; + journalProxy(200); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + + const upstreamStatus = String(upstreamBody.status ?? ""); + + if (upstreamStatus === "pending" || upstreamStatus === "in_progress") { + if (jobs.get(key) === job) { + if (!job.capturing && job.status !== "completed" && job.status !== "failed") { + job.status = upstreamStatus === "in_progress" ? "in_progress" : "pending"; + } + jobs.set(key, job); // TTL refresh + } + relayJson(relayBody); + return; + } + + if (upstreamStatus === "done") { + if (record.proxyOnly) { + if (jobs.get(key) === job) { + job.status = "completed"; + jobs.set(key, job); // TTL refresh + } + relayJson(relayBody); + return; + } + + if (job.capturing || jobs.get(key) !== job) { + if (jobs.get(key) === job) jobs.set(key, job); // TTL refresh + relayJson(relayBody); + return; + } + + // Open the capturing window SYNCHRONOUSLY. There is no byte download, so + // the capture is synchronous, but mirror the structure: latch capturing, + // set terminal status, relay, persist, mutate to a terminal replay job. + job.capturing = true; + job.status = "completed"; + jobs.set(key, job); + + relayJson(relayBody); + + captureGrokVideoRecordFixture({ + job, + key, + testId, + fixtures, + defaults, + jobs, + record, + upstreamBody, + }); + return; + } + + if (upstreamStatus === "failed") { + if (record.proxyOnly) { + if (jobs.get(key) === job) { + job.status = "failed"; + jobs.set(key, job); // TTL refresh + } + relayJson(relayBody); + return; + } + + if (jobs.get(key) !== job) { + relayJson(relayBody); + return; + } + + const rawError = upstreamBody.error; + let error: string | undefined; + if (typeof rawError === "string" && rawError) { + error = rawError; + } else if (rawError !== null && typeof rawError === "object" && !Array.isArray(rawError)) { + const message = (rawError as Record).message; + if (typeof message === "string" && message) error = message; + } + if (error === undefined && rawError !== undefined && rawError !== null) { + logger.warn( + `Upstream video job ${job.upstreamRequestId} failed with an unusable error value (${JSON.stringify(rawError).slice(0, 100)}) — recording the fixture without an error message (replay serves the default)`, + ); + } + const video: VideoResponse["video"] = { + id: job.upstreamRequestId, + status: "failed", + ...(error !== undefined ? { error } : {}), + }; + const persistResult = persistFixture({ + record, + providerKey: "grok", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + logger, + }); + if (persistResult.kind === "failed" && !res.headersSent) { + res.setHeader("X-AIMock-Record-Error", sanitizeHeaderValue(persistResult.error)); + } + jobs.set(key, { + kind: "replay", + requestId: job.requestId, + status: "failed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + relayJson(relayBody); + return; + } + + // expired / anything else: terminal upstream states not representable in + // VideoResponse.status — pass through (id rewritten), record nothing, keep + // proxying. + logger.warn( + `Upstream video job ${job.upstreamRequestId} reported status "${upstreamStatus}" — not representable in fixture video.status; passing through without recording`, + ); + if (jobs.get(key) === job) { + jobs.set(key, job); + } + relayJson(relayBody); +} + +/** + * Capture a completed Grok record job into a fixture (PERSIST only — NO byte + * download): builds `video` from the terminal poll body (id = upstream + * request_id, status = stored "completed", url/duration/cost), persists it, and + * mutates the map entry into a terminal replay job. World-generation guard + * before persist (a fixtures reset mid-flight invalidates the world). + */ +function captureGrokVideoRecordFixture(args: { + job: GrokVideoRecordJob; + key: string; + testId: string; + fixtures: Fixture[]; + defaults: HandlerDefaults; + jobs: GrokVideoJobMap; + record: RecordConfig; + upstreamBody: Record; +}): void { + const { job, key, testId, fixtures, defaults, jobs, record, upstreamBody } = args; + const logger = defaults.logger; + + try { + const upstreamVideo = + upstreamBody.video !== null && + typeof upstreamBody.video === "object" && + !Array.isArray(upstreamBody.video) + ? (upstreamBody.video as Record) + : undefined; + const rawUrl = upstreamVideo?.url; + const url = typeof rawUrl === "string" && rawUrl ? rawUrl : GROK_DEFAULT_VIDEO_URL; + const rawDuration = upstreamVideo?.duration; + const duration = typeof rawDuration === "number" ? rawDuration : undefined; + + const usage = + upstreamBody.usage !== null && + typeof upstreamBody.usage === "object" && + !Array.isArray(upstreamBody.usage) + ? (upstreamBody.usage as Record) + : undefined; + const rawTicks = usage?.cost_in_usd_ticks; + const cost = typeof rawTicks === "number" ? rawTicks / GROK_USD_TICKS_PER_USD : undefined; + + const video: VideoResponse["video"] = { + id: job.upstreamRequestId, + status: "completed", + url, + ...(duration !== undefined ? { duration } : {}), + ...(cost !== undefined ? { cost } : {}), + }; + + // World-generation guard: a fixtures reset clears the job map, so map + // identity is a valid proxy for "same world". + if (jobs.get(key) !== job) { + logger.warn( + `Grok video capture for job ${job.upstreamRequestId} discarded: the job map no longer holds this job (fixtures reset or TTL eviction) — nothing persisted`, + ); + return; + } + persistFixture({ + record, + providerKey: "grok", + testId, + fixture: { match: job.match, response: { video } }, + fixtures, + logger, + }); + + if (jobs.get(key) === job) { + jobs.set(key, { + kind: "replay", + requestId: job.requestId, + status: "completed", + pollCount: 0, + pollsBeforeInProgress: 0, + pollsBeforeCompleted: 0, + video: { ...video }, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error( + `Grok video capture for job ${job.upstreamRequestId} failed unexpectedly (${msg}) — fixture not persisted; the job keeps proxying live`, + ); + } finally { + // Reset unconditionally (defense-in-depth): the flag lives on this `job` + // object, so clearing it is always safe — harmless on an orphaned/replaced + // job (the success path swaps in a fresh replay entry; the old object is + // discarded either way). The previous `jobs.get(key) === job` guard was a + // latent landmine: if capture were ever made async/detached, an eviction + // mid-flight would leave `capturing=true` stuck forever and silently block + // re-capture. Unconditional reset removes that hazard. + job.capturing = false; + } +} From d00981ce7c9ceab0ae95e1ff43226fe3b4657278 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 26 Jun 2026 15:55:26 -0700 Subject: [PATCH 4/4] docs(video): document Veo + Grok Imagine providers + changelog (#278) Document the Veo and Grok Imagine video providers in the sidebar and README, and record the changelog entry. --- CHANGELOG.md | 7 +++++++ README.md | 2 +- docs/sidebar.js | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780d065..3cc1bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added + +- Native Google Veo async video lifecycle mock — `POST /v1beta/models/{model}:predictLongRunning` submit, `GET /v1beta/operations/{name}` poll through `done:false → done:true`, poll-count progression via `veoVideo`; the Files-API `uri` is served as-is (aimock never proxies or downloads video bytes) (#278) +- Record-mode live proxying for the Veo surface (`record.providers.veo`) — submit and poll forwarded 1:1, eager fixture capture of the Files-API uri on `done:true`; captured operations replay later (#278) +- Native xAI Grok Imagine async video lifecycle mock — `POST /v1/videos/generations` submit (JSON-only; multipart rejected with 400), `GET /v1/videos/{request_id}` poll through `pending → done | failed | expired` with synthesized `progress`, `grokVideo` progression, `cost_in_usd_ticks` units, and a Sora-safe `/v1/videos/{id}` dispatch that leaves the OpenAI video surface unchanged (#278) +- Record-mode live proxying for the Grok surface (`record.providers.grok`) — submit and poll forwarded 1:1, eager fixture capture of url/duration/cost on `done`, `failed` persisted, `expired` passed through; captured jobs replay later (#278) + ## [1.34.0] - 2026-06-24 ### Changed diff --git a/README.md b/README.md index 70207bb..6e091bb 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 - **[12 providers across 14 API surfaces](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), [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) +- **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), [Google Veo video generation](https://aimock.copilotkit.dev/veo-video) (:predictLongRunning + /v1beta/operations async lifecycle), [Grok Imagine video generation](https://aimock.copilotkit.dev/grok-video) (/v1/videos/generations 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/sidebar.js b/docs/sidebar.js index 8942220..a4c040f 100644 --- a/docs/sidebar.js +++ b/docs/sidebar.js @@ -37,6 +37,8 @@ { label: "Audio Transcription", href: "/transcription" }, { label: "Video Generation", href: "/video" }, { label: "OpenRouter Video", href: "/openrouter-video" }, + { label: "Google Veo Video", href: "/veo-video" }, + { label: "Grok Imagine Video", href: "/grok-video" }, { label: "fal.ai", href: "/fal-ai" }, ], },