mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
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:
@@ -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,
|
||||
|
||||
156
open-sse/handlers/videoGeneration/openai.ts
Normal file
156
open-sse/handlers/videoGeneration/openai.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -249,6 +249,7 @@ export const providerModelMutationSchema = z.object({
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
"videos",
|
||||
])
|
||||
)
|
||||
.default(["chat"]),
|
||||
|
||||
174
tests/unit/video-custom-provider-route.test.ts
Normal file
174
tests/unit/video-custom-provider-route.test.ts
Normal 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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user