diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 0b26f7d673..bb0ef608b3 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -13,11 +13,10 @@ 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"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; +import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts"; import { getExecutor } from "../executors/index.ts"; import { getKieTaskId, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -34,12 +33,58 @@ import { } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; + +/** + * Resolve the base URL for OpenAI-compatible video generation endpoints. + * Prefers providerSpecificData.baseUrl (from custom node config), falls back to + * top-level credentials.baseUrl, then to the provided fallback. + */ +export function resolveVideoBaseUrl( + credentials: + { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, + fallback: string +): string { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + + if (!nodeBaseUrl) return fallback; + + // Trim trailing slashes + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + if (normalized.endsWith("/videos/generations")) return normalized; + const stripped = normalized.replace(/\/videos\/generations$/, ""); + return `${stripped}/videos/generations`; +} /** * Handle video generation request */ -export async function handleVideoGeneration({ body, credentials, log }) { - const { provider, model } = parseVideoModel(body.model); + +/** + * Handle video generation request + */ +export async function handleVideoGeneration({ body, credentials, log, resolvedProvider = null }) { + let { provider, model } = parseVideoModel(body.model); + if (resolvedProvider) { + provider = resolvedProvider; + model = body.model.startsWith(provider + "/") + ? body.model.slice(provider.length + 1) + : body.model; + } if (!provider) { return { @@ -51,11 +96,38 @@ export async function handleVideoGeneration({ body, credentials, log }) { const providerConfig = getVideoProvider(provider); if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown video provider: ${provider}`, + if (!resolvedProvider) { + return { + success: false, + status: 400, + error: `Unknown video provider: ${provider}`, + }; + } + // Custom OpenAI-compatible provider node — dispatch via the generic handler + // with a synthetic config (mirrors the images route custom-model path). + if (log) + log.info("VIDEO", `Custom model ${provider}/${model} — using OpenAI-compatible handler`); + const syntheticConfig = { + id: provider, + baseUrl: resolveVideoBaseUrl( + credentials, + "http://generative.language.googleapis.com/v1beta/openai/videos/generations" + ), + authType: "apikey", + authHeader: "bearer", + format: "openai-video", }; + return handleOpenAIVideoGeneration({ + model, + body, + credentials, + provider, + providerConfig: syntheticConfig, + log, + }); + } + if (providerConfig.format === "openai-video") { + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } if (providerConfig.format === "vertex-veo") { @@ -158,7 +230,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { log, }); } - + if (resolvedProvider) { + // Custom provider with no matching built-in format — use OpenAI-compatible fallback + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } return { success: false, status: 400, diff --git a/open-sse/handlers/videoGeneration/openai.ts b/open-sse/handlers/videoGeneration/openai.ts new file mode 100644 index 0000000000..b53ae51fea --- /dev/null +++ b/open-sse/handlers/videoGeneration/openai.ts @@ -0,0 +1,156 @@ +import { + fetchWithTimeout, + FetchTimeoutError, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +interface LogLike { + info?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string) => void; +} + +interface CredentialsLike { + providerSpecificData?: { baseUrl?: unknown } | null; + baseUrl?: unknown; + apiKey?: unknown; + accessToken?: unknown; +} + +/** + * Resolve the video generation endpoint URL from credentials and fallback. + * Handles baseUrl from providerSpecificData or top-level credentials. + */ +function resolveVideoEndpoint(credentials: unknown, fallback: string): string { + const creds = credentials as CredentialsLike | null | undefined; + const psdBaseUrl = + creds?.providerSpecificData?.baseUrl != null && + typeof creds.providerSpecificData.baseUrl === "string" && + creds.providerSpecificData.baseUrl.trim() + ? creds.providerSpecificData.baseUrl.trim() + : null; + const topLevelBaseUrl = + creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim() + ? creds.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + let n = nodeBaseUrl; + while (n.endsWith("/")) n = n.slice(0, -1); + if (n.endsWith("/videos/generations")) return n; + return `${n}/videos/generations`; +} + +/** + * Fetch the video generation endpoint with timeout and error handling. + */ +async function fetchVideoEndpoint( + url: string, + { headers, body, log }: { headers: Record; body: string; log?: LogLike } +) { + try { + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + timeoutMs: getConfiguredTimeout(), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText}`); + return { success: false, status: response.status, error: errorText }; + } + const data = await response.json(); + return { + success: true, + data: { created: data.created || Math.floor(Date.now() / 1000), data: data.data || [] }, + }; + } catch (err) { + const message = err?.message; + const isTimeout = err instanceof FetchTimeoutError || err?.name === "AbortError"; + log?.error?.( + "VIDEO", + `${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message || err)}` + ); + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Video provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } +} + +/** + * Handle OpenAI-compatible video generation. + * This handler is dispatched for custom providers with format "openai-video". + */ +export async function handleOpenAIVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; authHeader: string }; + body: unknown; + credentials: unknown; + log?: LogLike; +}) { + const startTime = Date.now(); + const creds = credentials as CredentialsLike | null | undefined; + const apiToken = creds?.apiKey || creds?.accessToken; + const endpoint = resolveVideoEndpoint(credentials, providerConfig.baseUrl); + const headers = { + "Content-Type": "application/json", + ...(providerConfig.authHeader === "x-api-key" + ? { "x-api-key": String(apiToken) } + : { Authorization: `Bearer ${apiToken}` }), + }; + const bodyObj = body as Record; + const upstreamBody = { + model, + prompt: (bodyObj.prompt ?? "") as string, + ...(typeof bodyObj.duration === "number" && { duration: bodyObj.duration }), + }; + const logRequestBody = { + model: bodyObj.model, + prompt: + typeof bodyObj.prompt === "string" + ? bodyObj.prompt.slice(0, 200) + : String(bodyObj.prompt ?? ""), + duration: bodyObj.duration, + }; + log?.info?.("VIDEO", `OpenAI-compatible video generation: ${provider}/${model} -> ${endpoint}`, { + body: logRequestBody, + }); + + const fetchResult = await fetchVideoEndpoint(endpoint, { + headers, + body: JSON.stringify(upstreamBody), + log, + }); + + if (!fetchResult.success) { + return { success: false, status: fetchResult.status, error: fetchResult.error }; + } + + // Save call log for billing/tracking + await saveCallLog({ + provider, + model: String(bodyObj.model), + endpoint: "video", + status: fetchResult.status, + durationMs: Date.now() - startTime, + tokensIn: 0, + tokensOut: 0, + requestId: null, + }); + + return { + success: true, + data: fetchResult.data, + }; +} diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 7a3cfb4481..ef32fe0818 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,6 +1,7 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { getAllCustomModels } from "@/lib/db/models"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, @@ -88,7 +89,31 @@ async function postHandler(request, context) { if (policy.rejection) return policy.rejection; // Parse model to get provider - const { provider } = parsedModel; + let { provider, model: requestedModel } = parsedModel; + let isCustomModel = false; + if (!provider) { + // Custom OpenAI-compatible provider nodes (mirrors images route): scan the + // dynamic model registry for a matching `${nodeId}/${modelId}` entry. + try { + const customModelsMap = (await getAllCustomModels()) as Record; + for (const [providerId, models] of Object.entries(customModelsMap)) { + if (!Array.isArray(models)) continue; + for (const model of models) { + if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue; + if (!model.supportedEndpoints.includes("videos")) continue; + const fullId = `${providerId}/${model.id}`; + if (fullId === body.model) { + provider = providerId; + requestedModel = model.id; + isCustomModel = true; + break; + } + } + } + } catch { + // registry read failure — fall through to invalid-model error below + } + } if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -116,11 +141,32 @@ async function postHandler(request, context) { if (isAllRateLimitedCredentials(credentials)) { return rateLimitedProviderResponse(provider, credentials); } + } else if (isCustomModel) { + credentials = await getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + requestedModel + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for custom video provider: ${provider}` + ); + } + if (isAllRateLimitedCredentials(credentials)) { + return rateLimitedProviderResponse(provider, credentials); + } } else if (providerConfig?.authType === "none") { credentials = await resolveLocalOverrideCredentials(provider); } - const result: MediaGenerationResultLike = await handleVideoGeneration({ body, credentials, log }); + const result: MediaGenerationResultLike = await handleVideoGeneration({ + body, + credentials, + log, + ...(isCustomModel && { resolvedProvider: provider }), + }); if (isMediaGenerationFailure(result)) { return failedMediaGenerationResponse(result, "Video generation provider error"); diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index c7daf83b1e..fdcd8751e4 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -249,6 +249,7 @@ export const providerModelMutationSchema = z.object({ "audio-transcriptions", "audio-speech", "images-generations", + "videos", ]) ) .default(["chat"]), diff --git a/tests/unit/video-custom-provider-route.test.ts b/tests/unit/video-custom-provider-route.test.ts new file mode 100644 index 0000000000..82e19f138d --- /dev/null +++ b/tests/unit/video-custom-provider-route.test.ts @@ -0,0 +1,174 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-video-custom-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "video-custom-route-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); + +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; + +function createResponse(body: BodyInit | null, init?: ResponseInit & { setCookies?: string[] }) { + const response = new Response(body, init); + if (init?.setCookies) { + const cookies = init.setCookies.map((c) => c).join("; "); + response.headers.set("set-cookie", cookies); + } + return response; +} + +function immediateButSafeTimeout( + callback: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] +) { + if (ms === 20_000 || ms === 5_000) { + return originalSetTimeout(callback as TimerHandler, 0, ...args); + } + return originalSetTimeout(callback as TimerHandler, ms, ...args); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +test.after(() => { + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("video route uses OpenAI-compatible handler for custom provider with videos endpoint", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + // Seed a custom model tagged with "videos" endpoint + await modelsDb.addCustomModel( + "custom-video-provider", + "super-video-v1", + "Super Video v1", + "manual", + "chat-completions", + ["videos"] + ); + + // Create a provider connection with the custom base URL + await providersDb.createProviderConnection({ + provider: "custom-video-provider", + authType: "apikey", + apiKey: "custom-key", + providerSpecificData: { baseUrl: "https://custom.example.com/v1/videos/generations" }, + }); + + let captured: { url: string; body: unknown; headers: unknown } | null = null; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + const requestBody = init?.body ? JSON.parse(String(init.body)) : {}; + + captured = { + url: stringUrl, + body: requestBody, + headers: init?.headers, + }; + + // Return a valid OpenAI-like video generation response + return createResponse( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://custom.example.com/generated.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-video-provider/super-video-v1", + prompt: "a cat playing piano", + duration: 5, + }), + }) + ); + + const payload = (await response.json()) as { + data: Array<{ b64_json?: string; url?: string; format?: string }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.data.length, 1); + assert.equal(payload.data[0].url, "https://custom.example.com/generated.mp4"); + assert.equal(payload.data[0].format, "mp4"); + + // Verify the upstream call went to the custom provider's base URL + assert.ok(captured, "fetch should have been called"); + assert.equal(captured!.url, "https://custom.example.com/v1/videos/generations"); + assert.equal(captured!.headers.Authorization, "Bearer custom-key"); + assert.deepEqual(captured!.body, { + model: "super-video-v1", + prompt: "a cat playing piano", + duration: 5, + }); +}); + +test("video route returns 400 for custom provider without videos endpoint", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + // Seed a custom model WITHOUT "videos" endpoint + await modelsDb.addCustomModel( + "custom-no-video-provider", + "text-only-model", + "Text Only Model", + "manual", + "chat-completions", + ["chat", "embeddings"] + ); + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-no-video-provider/text-only-model", + prompt: "this should fail", + }), + }) + ); + + assert.equal(response.status, 400); + const payload = await response.json(); + assert.match(payload.error.message, /Invalid video model/); +}); + +test("video route returns 400 for unknown custom provider", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "unknown-provider/unknown-model", + prompt: "this should fail", + }), + }) + ); + + assert.equal(response.status, 400); + const payload = await response.json(); + assert.match(payload.error.message, /Invalid video model/); +}); +test.after(() => { + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/tests/unit/video-generation-handler.test.ts b/tests/unit/video-generation-handler.test.ts index 32cf4a35b4..911843deff 100644 --- a/tests/unit/video-generation-handler.test.ts +++ b/tests/unit/video-generation-handler.test.ts @@ -581,3 +581,55 @@ test("handleVideoGeneration rejects Runway models that require promptImage", asy assert.equal(result.status, 400); assert.match(result.error, /requires promptImage/i); }); +test("handleVideoGeneration uses OpenAI-compatible handler for resolved custom video providers", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + body: JSON.parse(String(options.body || "{}")), + headers: options.headers, + }; + + return new Response( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://custom.example.com/video.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "custom-provider/super-video", + prompt: "a cat playing piano", + duration: 5, + }, + credentials: { + apiKey: "custom-video-key", + providerSpecificData: { + baseUrl: "https://custom.example.com/v1/videos/generations", + }, + }, + resolvedProvider: "custom-provider", + log: null, + }); + + assert.equal(result.success, true); + assert.equal(captured.url, "https://custom.example.com/v1/videos/generations"); + assert.equal(captured.headers.Authorization, "Bearer custom-video-key"); + assert.deepEqual(captured.body, { + model: "super-video", + prompt: "a cat playing piano", + duration: 5, + }); + assert.deepEqual(result.data.data, [ + { url: "https://custom.example.com/video.mp4", format: "mp4" }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +});