feat: generic OpenAI-compatible video custom provider

Adds a generic OpenAI-compatible video generation path so users can add
custom video providers (base URL + API key) without per-provider code.

Changes:
- open-sse/handlers/videoGeneration/openai.ts (new): generic handler
  with resolveVideoEndpoint, fetchVideoEndpoint, handleOpenAIVideoGeneration
- open-sse/handlers/videoGeneration.ts: added resolveVideoBaseUrl(),
  dispatch for 'openai-video' format before 'vertex-veo', synthetic config
  for custom providers, fallback for resolvedProvider
- src/app/api/v1/videos/generations/route.ts: scans custom models for
  supportedEndpoints.includes('videos'), resolves credentials via
  getProviderCredentialsWithQuotaPreflight, passes resolvedProvider
- src/shared/validation/schemas/provider.ts: added 'videos' to
  supportedEndpoints enum
- tests/unit/video-generation-handler.test.ts: handler-level test for
  custom provider
- tests/unit/video-custom-provider-route.test.ts (new): route-level tests
  covering custom provider with/without videos endpoint, unknown provider

All verification:
- typecheck:core passes
- 17 video tests pass (3 new route tests + 1 new handler test)
- no regressions in image generation tests
This commit is contained in:
oyi77
2026-08-09 02:50:25 +07:00
committed by diegosouzapw
parent aae408f585
commit cb8d9ef833
6 changed files with 515 additions and 11 deletions

View File

@@ -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,

View File

@@ -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<string, string>; 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<string, unknown>;
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,
};
}

View File

@@ -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<string, any>;
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");

View File

@@ -249,6 +249,7 @@ export const providerModelMutationSchema = z.object({
"audio-transcriptions",
"audio-speech",
"images-generations",
"videos",
])
)
.default(["chat"]),

View File

@@ -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 });
});

View File

@@ -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;
}
});