diff --git a/changelog.d/features/6653-deepinfra-video-provider.md b/changelog.d/features/6653-deepinfra-video-provider.md new file mode 100644 index 0000000000..8957b95d28 --- /dev/null +++ b/changelog.d/features/6653-deepinfra-video-provider.md @@ -0,0 +1 @@ +- feat(sse): add DeepInfra as a video-generation provider via its native synchronous inference endpoint (#6653) diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 843c3d9d88..7dcd906064 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -193,6 +193,23 @@ export const VIDEO_PROVIDERS: Record = { models: RUNWAYML_SUPPORTED_VIDEO_MODELS, }, + deepinfra: { + id: "deepinfra", + // Native DeepInfra inference endpoint — same host/auth already proven for reranking + // (open-sse/config/rerankRegistry.ts). Reuses the stored deepinfra provider Bearer + // apiKey (already registered for chat) — no separate credential flow. + baseUrl: "https://api.deepinfra.com/v1/inference", + authType: "apikey", + authHeader: "bearer", + format: "deepinfra-video", + models: [ + { id: "Wan-AI/Wan2.2-T2V-A14B", name: "Wan 2.2 T2V A14B" }, + { id: "Wan-AI/Wan2.2-TI2V-5B", name: "Wan 2.2 TI2V 5B" }, + { id: "Wan-AI/Wan2.7-T2V", name: "Wan 2.7 T2V" }, + { id: "Lightricks/LTX-2.3-Distilled", name: "LTX 2.3 Distilled" }, + ], + }, + alibaba: { id: "alibaba", alias: "ali", diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index b77f44c674..c9c0323607 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -11,6 +11,8 @@ 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 { handleDeepinfraVideoGeneration } from "./videoGeneration/deepinfraHandler.ts"; +import { handleLeonardoVideoGeneration } from "./videoGeneration/leonardoHandler.ts"; import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandler.ts"; import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; @@ -106,6 +108,17 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "deepinfra-video") { + return handleDeepinfraVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "dashscope-video") { return handleDashscopeVideoGeneration({ model, @@ -997,109 +1010,6 @@ async function handleHaiperVideoGeneration({ return { success: false, status: 504, error: "Haiper video generation timed out" }; } -async function handleLeonardoVideoGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials?.apiKey || ""; - const res = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - modelId: "phoenix", - prompt: body.prompt, - width: 1024, - height: 576, - num_frames: 24, - }), - }); - if (!res.ok) { - const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; - } - const { sdGenerationJob } = await res.json(); - const genId = sdGenerationJob?.generationId; - if (!genId) { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "No generation ID returned", - }).catch(() => {}); - return { success: false, status: 502, error: "No generation ID returned" }; - } - const deadline = Date.now() + 300000; - while (Date.now() < deadline) { - await sleep(5000); - const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const status = await statusRes.json(); - const gen = status.generations_by_pk || status; - if (gen.status === "COMPLETE") { - const imgUrl = gen.generated_images?.[0]?.url; - if (imgUrl) { - const videoRes = await fetch(imgUrl); - const buf = await videoRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], - }, - }; - } - } - if (gen.status === "FAILED") { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation failed", - }).catch(() => {}); - return { success: false, status: 502, error: "Leonardo video generation failed" }; - } - } - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation timed out", - }).catch(() => {}); - return { success: false, status: 504, error: "Leonardo video generation timed out" }; -} - function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/open-sse/handlers/videoGeneration/deepinfraHandler.ts b/open-sse/handlers/videoGeneration/deepinfraHandler.ts new file mode 100644 index 0000000000..0312d6080c --- /dev/null +++ b/open-sse/handlers/videoGeneration/deepinfraHandler.ts @@ -0,0 +1,186 @@ +/** + * DeepInfra native text/image-to-video generation. + * + * DeepInfra's `/v1/inference/{model}` endpoint is already proven in this codebase for + * reranking (`open-sse/handlers/rerank.ts` + `open-sse/config/rerankRegistry.ts`) — same + * host, same Bearer auth, same non-OpenAI response shape. This reuses the stored + * `deepinfra` provider credential (already registered for chat) — no new credential flow. + * + * Confirmed against DeepInfra's own docs (https://deepinfra.com//api): the call is + * synchronous — `POST {prompt}` returns `{video_url, seed, request_id, inference_status}` + * directly, no task/poll loop like `kie-video`/`dashscope-video`. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { saveCallLog } from "@/lib/usageDb"; + +interface DeepinfraHandlerArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record & { + prompt?: unknown; + negative_prompt?: unknown; + image?: unknown; + image_url?: unknown; + seed?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + } | null; +} + +interface DeepinfraVideoResponse { + video_url?: unknown; + seed?: unknown; + request_id?: unknown; + inference_status?: { status?: unknown; error?: unknown } | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Builds the DeepInfra `/v1/inference/{model}` request body from the OmniRoute video body. */ +/* @testonly */ export function buildDeepinfraVideoRequestBody( + body: DeepinfraHandlerArgs["body"] +): Record { + const payload: Record = { + prompt: typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""), + }; + if (typeof body.negative_prompt === "string" && body.negative_prompt) { + payload.negative_prompt = body.negative_prompt; + } + const image = body.image ?? body.image_url; + if (typeof image === "string" && image) { + payload.image = image; + } + if (typeof body.seed === "number" && Number.isFinite(body.seed)) { + payload.seed = body.seed; + } + return payload; +} + +/** Extracts a human-readable error message from a DeepInfra error/inference_status payload. */ +/* @testonly */ export function extractDeepinfraErrorMessage(data: unknown): string | null { + if (!isRecord(data)) return null; + const direct = data.error ?? data.detail ?? data.message; + if (typeof direct === "string" && direct) return direct; + if (isRecord(direct) && typeof direct.message === "string" && direct.message) { + return direct.message; + } + const status = data.inference_status; + if (isRecord(status) && typeof status.error === "string" && status.error) { + return status.error; + } + return null; +} + +interface CallLogContext { + provider: string; + model: string; + startTime: number; +} + +function logDeepinfraCall( + ctx: CallLogContext, + status: number, + extra: { error?: string; responseBody?: Record } +) { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status, + model: `${ctx.provider}/${ctx.model}`, + provider: ctx.provider, + duration: Date.now() - ctx.startTime, + ...extra, + }).catch(() => {}); +} + +function buildDeepinfraFetchError( + ctx: CallLogContext, + status: number, + data: unknown, + log?: DeepinfraHandlerArgs["log"] +) { + const errorMessage = extractDeepinfraErrorMessage(data) || `DeepInfra returned HTTP ${status}`; + log?.error?.("VIDEO", `${ctx.provider} deepinfra-video error ${status}: ${errorMessage}`); + logDeepinfraCall(ctx, status, { error: errorMessage.slice(0, 500) }); + return { success: false, status, error: errorMessage }; +} + +function buildDeepinfraSuccess(ctx: CallLogContext, videoUrl: string) { + logDeepinfraCall(ctx, 200, { responseBody: { videos_count: 1 } }); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; +} + +function buildDeepinfraCatchError( + ctx: CallLogContext, + err: unknown, + log?: DeepinfraHandlerArgs["log"] +) { + const errorMessage = sanitizeErrorMessage(err) || "Video provider error"; + log?.error?.("VIDEO", `${ctx.provider} deepinfra-video error: ${errorMessage}`); + logDeepinfraCall(ctx, 502, { error: errorMessage }); + return { success: false, status: 502, error: errorMessage }; +} + +async function fetchDeepinfraVideo( + baseUrl: string, + model: string, + token: string, + requestBody: unknown +) { + const res = await fetch(`${baseUrl}/${model}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + const data: DeepinfraVideoResponse = await res.json().catch(() => ({})); + return { res, data }; +} + +export async function handleDeepinfraVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: DeepinfraHandlerArgs) { + const ctx: CallLogContext = { provider, model, startTime: Date.now() }; + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { success: false, status: 401, error: "DeepInfra API key is required" }; + } + + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const requestBody = buildDeepinfraVideoRequestBody(body); + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log?.info?.("VIDEO", `${provider}/${model} (deepinfra-video) | prompt: "${promptPreview}..."`); + + try { + const { res, data } = await fetchDeepinfraVideo(baseUrl, model, token, requestBody); + if (!res.ok) return buildDeepinfraFetchError(ctx, res.status, data, log); + + const videoUrl = typeof data.video_url === "string" ? data.video_url : null; + if (!videoUrl) { + const errorMessage = + extractDeepinfraErrorMessage(data) || "DeepInfra video generation did not return video_url"; + return { success: false, status: 502, error: errorMessage }; + } + + return buildDeepinfraSuccess(ctx, videoUrl); + } catch (err: unknown) { + return buildDeepinfraCatchError(ctx, err, log); + } +} diff --git a/open-sse/handlers/videoGeneration/leonardoHandler.ts b/open-sse/handlers/videoGeneration/leonardoHandler.ts new file mode 100644 index 0000000000..c583e4194b --- /dev/null +++ b/open-sse/handlers/videoGeneration/leonardoHandler.ts @@ -0,0 +1,116 @@ +/** + * Leonardo AI (Phoenix) video generation: submit → poll → fetch output. + * + * Extracted out of the frozen `videoGeneration.ts` god-file (unchanged behavior) to make + * room for the new DeepInfra video adapter without pushing the file-size ratchet over its + * baseline — mirrors the existing `googleFlowHandler.ts` extraction. + */ + +import { saveCallLog } from "@/lib/usageDb"; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function handleLeonardoVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || ""; + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + modelId: "phoenix", + prompt: body.prompt, + width: 1024, + height: 576, + num_frames: 24, + }), + }); + if (!res.ok) { + const errorText = await res.text(); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: res.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: res.status, error: errorText }; + } + const { sdGenerationJob } = await res.json(); + const genId = sdGenerationJob?.generationId; + if (!genId) { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "No generation ID returned", + }).catch(() => {}); + return { success: false, status: 502, error: "No generation ID returned" }; + } + const deadline = Date.now() + 300000; + while (Date.now() < deadline) { + await sleep(5000); + const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const status = await statusRes.json(); + const gen = status.generations_by_pk || status; + if (gen.status === "COMPLETE") { + const imgUrl = gen.generated_images?.[0]?.url; + if (imgUrl) { + const videoRes = await fetch(imgUrl); + const buf = await videoRes.arrayBuffer(); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], + }, + }; + } + } + if (gen.status === "FAILED") { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo video generation failed", + }).catch(() => {}); + return { success: false, status: 502, error: "Leonardo video generation failed" }; + } + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo video generation timed out", + }).catch(() => {}); + return { success: false, status: 504, error: "Leonardo video generation timed out" }; +} diff --git a/tests/unit/video-deepinfra-6653.test.ts b/tests/unit/video-deepinfra-6653.test.ts new file mode 100644 index 0000000000..63ea3e4e4e --- /dev/null +++ b/tests/unit/video-deepinfra-6653.test.ts @@ -0,0 +1,196 @@ +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-deepinfra-")); + +const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts"); +const { VIDEO_PROVIDERS, parseVideoModel } = await import( + "../../open-sse/config/videoRegistry.ts" +); +const { + buildDeepinfraVideoRequestBody, + extractDeepinfraErrorMessage, +} = await import("../../open-sse/handlers/videoGeneration/deepinfraHandler.ts"); + +const INFERENCE_URL = "https://api.deepinfra.com/v1/inference/Wan-AI/Wan2.2-T2V-A14B"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +test("VIDEO_PROVIDERS exposes the deepinfra deepinfra-video entry", () => { + assert.ok(VIDEO_PROVIDERS.deepinfra, "deepinfra video provider is registered"); + assert.equal(VIDEO_PROVIDERS.deepinfra.format, "deepinfra-video"); + assert.equal(VIDEO_PROVIDERS.deepinfra.authType, "apikey"); + assert.equal(VIDEO_PROVIDERS.deepinfra.authHeader, "bearer"); + assert.equal(VIDEO_PROVIDERS.deepinfra.baseUrl, "https://api.deepinfra.com/v1/inference"); + assert.ok( + VIDEO_PROVIDERS.deepinfra.models.some((m) => m.id === "Wan-AI/Wan2.2-T2V-A14B"), + "Wan 2.2 T2V A14B is listed" + ); +}); + +test("parseVideoModel resolves provider/model strings whose model id itself contains a slash", () => { + const parsed = parseVideoModel("deepinfra/Wan-AI/Wan2.2-T2V-A14B"); + assert.equal(parsed.provider, "deepinfra"); + assert.equal(parsed.model, "Wan-AI/Wan2.2-T2V-A14B"); +}); + +test("buildDeepinfraVideoRequestBody carries prompt/negative_prompt/image/seed", () => { + const body = buildDeepinfraVideoRequestBody({ + prompt: "a neon city in the rain", + negative_prompt: "blurry", + image: "https://example.com/frame.png", + seed: 42, + }); + assert.deepEqual(body, { + prompt: "a neon city in the rain", + negative_prompt: "blurry", + image: "https://example.com/frame.png", + seed: 42, + }); +}); + +test("buildDeepinfraVideoRequestBody omits optional fields when absent", () => { + const body = buildDeepinfraVideoRequestBody({ prompt: "just a prompt" }); + assert.deepEqual(body, { prompt: "just a prompt" }); +}); + +test("extractDeepinfraErrorMessage reads string error/detail/message and inference_status.error", () => { + assert.equal(extractDeepinfraErrorMessage({ error: "bad request" }), "bad request"); + assert.equal(extractDeepinfraErrorMessage({ detail: "invalid model" }), "invalid model"); + assert.equal( + extractDeepinfraErrorMessage({ error: { message: "nested" } }), + "nested" + ); + assert.equal( + extractDeepinfraErrorMessage({ inference_status: { error: "queue timeout" } }), + "queue timeout" + ); + assert.equal(extractDeepinfraErrorMessage({}), null); + assert.equal(extractDeepinfraErrorMessage(null), null); +}); + +test("handleVideoGeneration builds a synchronous DeepInfra request and returns the mp4 url", async () => { + const originalFetch = globalThis.fetch; + let capturedRequest; + + globalThis.fetch = async (url, options = {}) => { + capturedRequest = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return jsonResponse({ + video_url: "https://deepinfra-cdn.example.com/wan-out.mp4", + seed: 7, + request_id: "req-1", + inference_status: { status: "succeeded" }, + }); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "deepinfra/Wan-AI/Wan2.2-T2V-A14B", + prompt: "a neon city in the rain", + negative_prompt: "blurry", + }, + credentials: { apiKey: "deepinfra-key" }, + log: null, + }); + + assert.equal(capturedRequest.url, INFERENCE_URL); + assert.equal(capturedRequest.headers["Authorization"], "Bearer deepinfra-key"); + assert.equal(capturedRequest.body.prompt, "a neon city in the rain"); + assert.equal(capturedRequest.body.negative_prompt, "blurry"); + + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://deepinfra-cdn.example.com/wan-out.mp4"); + assert.equal(result.data.data[0].format, "mp4"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration rejects DeepInfra video requests without credentials", async () => { + const result = await handleVideoGeneration({ + body: { model: "deepinfra/Wan-AI/Wan2.2-T2V-A14B", prompt: "x" }, + credentials: null, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.match(result.error, /DeepInfra API key is required/); +}); + +test("handleVideoGeneration surfaces upstream HTTP errors without leaking a stack trace", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + jsonResponse({ error: "Invalid API key" }, 401); + + try { + const result = await handleVideoGeneration({ + body: { model: "deepinfra/Wan-AI/Wan2.2-T2V-A14B", prompt: "x" }, + credentials: { apiKey: "bad-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.error, "Invalid API key"); + assert.ok(!result.error.includes("at /"), "error must not leak a stack trace"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration returns 502 when DeepInfra succeeds without a video_url", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => jsonResponse({ seed: 1, request_id: "req-2" }); + + try { + const result = await handleVideoGeneration({ + body: { model: "deepinfra/Wan-AI/Wan2.2-T2V-A14B", prompt: "x" }, + credentials: { apiKey: "deepinfra-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.match(result.error, /did not return video_url/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration sanitizes network-level failures via sanitizeErrorMessage", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error( + "fetch failed\n at Object.fetch (/home/user/omniroute/node_modules/undici/lib/x.js:1:1)" + ); + }; + + try { + const result = await handleVideoGeneration({ + body: { model: "deepinfra/Wan-AI/Wan2.2-T2V-A14B", prompt: "x" }, + credentials: { apiKey: "deepinfra-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.ok(!result.error.includes("at Object.fetch"), "error must not leak a stack trace"); + assert.ok(!result.error.includes("/home/user"), "error must not leak an absolute path"); + } finally { + globalThis.fetch = originalFetch; + } +});