diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 442699b047..973240b2ae 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -205,6 +205,19 @@ export const VIDEO_PROVIDERS: Record = { format: "dashscope-video", models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], }, + + xai: { + id: "xai", + // xAI Grok Imagine async video-generation API. Reuses the stored xai + // provider Bearer apiKey (same credential the image-generation "xai" + // entry in imageRegistry.ts already uses) — no separate credential flow. + baseUrl: "https://api.x.ai/v1/videos", + statusUrl: "https://api.x.ai/v1/videos", + authType: "apikey", + authHeader: "bearer", + format: "xai-video", + models: [{ id: "grok-imagine-video", name: "Grok Imagine Video" }], + }, }; /** diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 16c9c27d41..69071df35a 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -112,6 +112,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "xai-video") { + return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + return { success: false, status: 400, @@ -271,6 +275,144 @@ async function handleDashscopeVideoGeneration({ } } +/** + * xAI Grok Imagine video generation: create async job → poll → MP4. + * Reuses the stored xai provider Bearer apiKey (same credential the + * image-generation "xai" entry in imageRegistry.ts already uses) — no + * separate credential flow. Mirrors the DashScope create+poll shape above, + * adapted to xAI's request_id / status ("pending"|"processing"|"done"|"failed") + * job shape (https://docs.x.ai/developers/rest-api-reference/inference/videos). + */ +async function handleXaiVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { + prompt?: unknown; + image?: unknown; + duration?: unknown; + aspect_ratio?: unknown; + resolution?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const statusUrl = (providerConfig.statusUrl || baseUrl).replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "xAI API key is required" }; + } + + const payload: Record = { model, prompt }; + if (typeof body.image === "string") payload.image = body.image; + if (body.duration != null) payload.duration = Number(body.duration); + if (typeof body.aspect_ratio === "string") payload.aspect_ratio = body.aspect_ratio; + if (typeof body.resolution === "string") payload.resolution = body.resolution; + + if (log) { + log.info("VIDEO", `${provider}/${model} (xai-video) | prompt: "${prompt.slice(0, 60)}..."`); + } + + try { + // Step 1: create async job + const createRes = await fetch(`${baseUrl}/generations`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const createData = await createRes.json().catch(() => ({})); + const requestId = createData?.request_id; + if (!requestId) { + const errorMessage = + createData?.error?.message || + createData?.message || + "xAI video generation did not return request_id"; + if (log) { + log.error("VIDEO", `xAI createJob failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: String(errorMessage) }; + } + + // Step 2: poll statusUrl/{request_id} until terminal + const deadline = startTime + timeoutMs; + let lastStatus = "pending"; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const pollRes = await fetch(`${statusUrl}/${requestId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const pollData = await pollRes.json().catch(() => ({})); + lastStatus = pollData?.status || "pending"; + + if (lastStatus === "done") { + const videoUrl = pollData?.video?.url; + if (!videoUrl) { + return { + success: false, + status: 502, + error: "xAI video job done but no video.url", + }; + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: 1 }, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; + } + + if (lastStatus === "failed") { + const errorMessage = pollData?.error || "xAI video job failed"; + return { success: false, status: 502, error: String(errorMessage) }; + } + // pending / processing → keep polling + } + + return { + success: false, + status: 504, + error: `xAI video job ${requestId} timed out (status: ${lastStatus})`, + }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; + } +} + // Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). // Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable // (then omitted from the payload so DashScope applies its own default). diff --git a/tests/unit/video-xai-grok-imagine.test.ts b/tests/unit/video-xai-grok-imagine.test.ts new file mode 100644 index 0000000000..3e54027ce1 --- /dev/null +++ b/tests/unit/video-xai-grok-imagine.test.ts @@ -0,0 +1,236 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-video-xai-")); + +const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts"); +const { VIDEO_PROVIDERS } = await import("../../open-sse/config/videoRegistry.ts"); + +// Makes poll-interval waits resolve instantly so tests don't sleep. +function immediateTimeout(callback, _ms, ...args) { + if (typeof callback === "function") callback(...args); + return 0; +} + +const CREATE_URL = "https://api.x.ai/v1/videos/generations"; +const POLL_URL_PREFIX = "https://api.x.ai/v1/videos/"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +test("VIDEO_PROVIDERS exposes the xai grok-imagine-video entry", () => { + assert.ok(VIDEO_PROVIDERS.xai, "xai video provider is registered"); + assert.equal(VIDEO_PROVIDERS.xai.format, "xai-video"); + assert.ok( + VIDEO_PROVIDERS.xai.models.some((m) => m.id === "grok-imagine-video"), + "grok-imagine-video is listed" + ); +}); + +test("handleVideoGeneration creates + polls an xAI Grok Imagine video job and returns mp4 URL", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + let createRequest; + let pollRequestCount = 0; + + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + + if (stringUrl === CREATE_URL) { + createRequest = { + url: stringUrl, + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return jsonResponse({ request_id: "xai-req-1", status: "pending" }); + } + + if (stringUrl === `${POLL_URL_PREFIX}xai-req-1`) { + pollRequestCount += 1; + if (pollRequestCount === 1) { + return jsonResponse({ request_id: "xai-req-1", status: "processing", progress: 40 }); + } + return jsonResponse({ + request_id: "xai-req-1", + status: "done", + progress: 100, + video: { url: "https://videos.x.ai/xai-req-1.mp4" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "xai/grok-imagine-video", + prompt: "a cinematic tracking shot through a neon city at night", + duration: 6, + }, + credentials: { apiKey: "xai-key" }, + log: null, + }); + + // Create request shape + assert.equal(createRequest.headers["Authorization"], "Bearer xai-key"); + assert.equal(createRequest.body.model, "grok-imagine-video"); + assert.equal( + createRequest.body.prompt, + "a cinematic tracking shot through a neon city at night" + ); + assert.equal(createRequest.body.duration, 6); + + // Polled at least once past "processing" before terminal "done" + assert.ok(pollRequestCount >= 2); + + // Response shape + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://videos.x.ai/xai-req-1.mp4"); + assert.equal(result.data.data[0].format, "mp4"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleVideoGeneration rejects xAI video requests without credentials", async () => { + const result = await handleVideoGeneration({ + body: { model: "xai/grok-imagine-video", prompt: "x" }, + credentials: null, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.match(result.error, /xAI API key is required/); +}); + +test("handleVideoGeneration surfaces a 502 when xAI returns no request_id", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + jsonResponse({ error: { message: "Invalid API key" } }, 401); + + try { + const result = await handleVideoGeneration({ + body: { model: "xai/grok-imagine-video", prompt: "x" }, + credentials: { apiKey: "bad-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(result.error, "Invalid API key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration returns 502 when the xAI job status is failed", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = immediateTimeout; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === CREATE_URL) { + return jsonResponse({ request_id: "xai-fail", status: "pending" }); + } + if (stringUrl === `${POLL_URL_PREFIX}xai-fail`) { + return jsonResponse({ + request_id: "xai-fail", + status: "failed", + error: "content policy violation", + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { model: "xai/grok-imagine-video", prompt: "x" }, + credentials: { apiKey: "xai-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(result.error, "content policy violation"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleVideoGeneration returns 504 when the xAI job never completes", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + const originalNow = Date.now; + globalThis.setTimeout = immediateTimeout; + + let nowCalls = 0; + Date.now = () => { + nowCalls += 1; + return nowCalls === 1 ? 1000 : nowCalls === 2 ? 2000 : 1_000_000; + }; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === CREATE_URL) { + return jsonResponse({ request_id: "xai-stuck", status: "pending" }); + } + if (stringUrl === `${POLL_URL_PREFIX}xai-stuck`) { + return jsonResponse({ request_id: "xai-stuck", status: "processing", progress: 10 }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "xai/grok-imagine-video", + prompt: "x", + timeout_ms: 5000, + poll_interval_ms: 100, + }, + credentials: { apiKey: "xai-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 504); + assert.match(result.error, /timed out/); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + Date.now = originalNow; + } +}); + +test("handleVideoGeneration never leaks a stack trace in xAI video error responses", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("connect ECONNREFUSED 127.0.0.1:443\n at TCPConnectWrap.afterConnect"); + }; + + try { + const result = await handleVideoGeneration({ + body: { model: "xai/grok-imagine-video", prompt: "x" }, + credentials: { apiKey: "xai-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.ok(!String(result.error).includes("at TCPConnectWrap")); + } finally { + globalThis.fetch = originalFetch; + } +});