mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 12:42:21 +03:00
fix(sse): extract xAI Grok Imagine video handler to fix file-size ratchet
videoGeneration.ts grew to 1407 lines (frozen cap 1265) after adding the Grok Imagine handler. Extract handleXaiVideoGeneration into a co-located module (open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts), following the googleFlowHandler.ts precedent — same pattern already used for the Google Flow video handler. File now sits at 1261 lines, under cap. No behavior change; existing tests (video-xai-grok-imagine.test.ts) cover the handler through the public handleVideoGeneration() entry point and pass unmodified.
This commit is contained in:
@@ -1,24 +1,17 @@
|
||||
/**
|
||||
* Video Generation Handler
|
||||
*
|
||||
* Handles POST /v1/videos/generations requests.
|
||||
* Proxies to upstream video generation providers.
|
||||
*
|
||||
* Supported provider formats:
|
||||
* - ComfyUI: submit AnimateDiff/SVD workflow → poll → fetch video
|
||||
* - SD WebUI: POST to AnimateDiff extension endpoint
|
||||
*
|
||||
* Response format (OpenAI-like):
|
||||
* {
|
||||
* "created": 1234567890,
|
||||
* "data": [{ "b64_json": "...", "format": "mp4" }]
|
||||
* }
|
||||
* Handles POST /v1/videos/generations requests. Proxies to upstream video
|
||||
* generation providers (ComfyUI AnimateDiff/SVD, SD WebUI AnimateDiff, and
|
||||
* more — see the per-format handlers below). Response format (OpenAI-like):
|
||||
* { "created": 1234567890, "data": [{ "b64_json": "...", "format": "mp4" }] }
|
||||
*/
|
||||
|
||||
import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts";
|
||||
import { kieExecutor } from "../executors/kie.ts";
|
||||
import { vertexGenerateVideo } from "../executors/vertexMedia.ts";
|
||||
import { handleGoogleFlowVideoGeneration } from "./videoGeneration/googleFlowHandler.ts";
|
||||
import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts";
|
||||
import { getExecutor } from "../executors/index.ts";
|
||||
import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts";
|
||||
import {
|
||||
@@ -275,144 +268,6 @@ 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).
|
||||
|
||||
148
open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts
Normal file
148
open-sse/handlers/videoGeneration/xaiGrokImagineHandler.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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 in
|
||||
* videoGeneration.ts, adapted to xAI's request_id / status
|
||||
* ("pending"|"processing"|"done"|"failed") job shape
|
||||
* (https://docs.x.ai/developers/rest-api-reference/inference/videos).
|
||||
*/
|
||||
|
||||
import { isJsonObject } from "../../utils/kieTask.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
|
||||
interface XaiVideoBody {
|
||||
prompt?: unknown;
|
||||
image?: unknown;
|
||||
duration?: unknown;
|
||||
aspect_ratio?: unknown;
|
||||
resolution?: unknown;
|
||||
timeout_ms?: unknown;
|
||||
poll_interval_ms?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface XaiVideoLog {
|
||||
info: (scope: string, message: string) => void;
|
||||
error: (scope: string, message: string) => void;
|
||||
}
|
||||
|
||||
export async function handleXaiVideoGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
}: {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig: { baseUrl: string; statusUrl?: string };
|
||||
body: XaiVideoBody;
|
||||
credentials?: { apiKey?: string; accessToken?: string } | null;
|
||||
log?: XaiVideoLog | 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user