mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 12:42:21 +03:00
feat(sse): add native xAI Grok Imagine video generation provider
OmniRoute's /v1/videos surface already supported 10 provider formats
(vertex-veo, google-flow, comfyui, sdwebui-video, kie-video, runwayml,
haiper-video, veoaifree-web, leonardo-video, dashscope-video), but xAI
had no native entry — Grok Imagine was only reachable indirectly through
the kie proxy market (kie's "grok-imagine/text-to-video" models), which
requires a separate kie.ai account and bills through kie.
This registers xai as a first-class video provider that talks to
api.x.ai/v1/videos directly, reusing the stored xai Bearer apiKey that
the existing image-generation "xai" entry in imageRegistry.ts already
uses — no new credential flow. The new xai-video handler format mirrors
the DashScope create+poll shape, adapted to xAI's request_id / status
("pending" | "processing" | "done" | "failed") job model.
User-visible effect: `xai/grok-imagine-video` works on
POST /v1/videos/generations against a user's own xAI key.
Co-authored-by: ann <daohuyentfqn2l@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2593
This commit is contained in:
@@ -205,6 +205,19 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
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" }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> & {
|
||||
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<string, unknown> = { 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).
|
||||
|
||||
236
tests/unit/video-xai-grok-imagine.test.ts
Normal file
236
tests/unit/video-xai-grok-imagine.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user