Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions open-sse/config/videoRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
],
},

"fal-ai": {
id: "fal-ai",
baseUrl: "https://queue.fal.run",
authType: "apikey",
authHeader: "key",
format: "fal-ai-video",
models: [
{ id: "veo3.1/lite", name: "Veo 3.1 Lite" },
{
id: "xai/grok-imagine-video/text-to-video",
name: "Grok Imagine Video",
},
],
},

googleflow: {
id: "googleflow",
alias: "flow",
Expand Down
5 changes: 5 additions & 0 deletions open-sse/handlers/videoGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 { handleFalVideoGeneration } from "./videoGeneration/falHandler.ts";
import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts";
import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts";
import {
Expand Down Expand Up @@ -201,6 +202,10 @@ export async function handleVideoGeneration({ body, credentials, log, resolvedPr
return handleVertexVeoGeneration({ model, body, credentials, log });
}

if (providerConfig.format === "fal-ai-video") {
return handleFalVideoGeneration({ model, provider, providerConfig, body, credentials, log });
}

if (providerConfig.format === "google-flow") {
return handleGoogleFlowVideoGeneration({ model, providerConfig, body, credentials, log });
}
Expand Down
251 changes: 251 additions & 0 deletions open-sse/handlers/videoGeneration/falHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { saveCallLog } from "@/lib/usageDb";
import {
FetchTimeoutError,
fetchWithTimeout,
getConfiguredTimeout,
} from "@/shared/utils/fetchTimeout";
import { sanitizeErrorMessage } from "../../utils/error.ts";

interface FalVideoBody {
prompt?: unknown;
aspect_ratio?: unknown;
duration?: unknown;
resolution?: unknown;
quality?: unknown;
generate_audio?: unknown;
poll_interval_ms?: unknown;
[key: string]: unknown;
}

interface FalCredentials {
apiKey?: unknown;
accessToken?: unknown;
}

interface FalProviderConfig {
baseUrl: string;
}

interface FalLog {
info?: (scope: string, message: string, meta?: unknown) => void;
error?: (scope: string, message: string) => void;
}

function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function grokDuration(value: unknown, fallback = 6): number {
const numeric = numberValue(value);
if (numeric !== undefined) return Math.round(numeric);

if (typeof value === "string") {
const match = value.trim().match(/^(\d+)s$/);
if (match) return Number(match[1]);
}

return fallback;
}

function falDuration(value: unknown, fallback = "8s"): string {
if (typeof value === "string" && /^(4|6|8)s$/.test(value)) return value;

const numeric = numberValue(value);
return numeric && [4, 6, 8].includes(numeric) ? `${numeric}s` : fallback;
}

export function buildFalVideoPayload(model: string, body: FalVideoBody): Record<string, unknown> {
const prompt = stringValue(body.prompt) || "";
const aspectRatio = stringValue(body.aspect_ratio) || "16:9";
const resolution = stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p");

if (model.startsWith("xai/grok-imagine-video/")) {
return {
prompt,
aspect_ratio: aspectRatio,
duration: grokDuration(body.duration),
resolution,
};
}

return {
prompt,
aspect_ratio: aspectRatio,
duration: falDuration(body.duration),
resolution,
generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true,
};
}

function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined {
const url = stringValue(value);
if (!url) return undefined;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
return `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
}

function normalizeFalVideoResponse(payload: unknown) {
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const video = record.video && typeof record.video === "object" ? record.video : null;
const url = video && typeof video.url === "string" ? video.url.trim() : "";

if (!url) {
return {
success: false as const,
status: 502,
error: "Fal video generation returned no video URL",
};
}

return {
success: true as const,
data: {
created: typeof record.created === "number" ? record.created : Math.floor(Date.now() / 1000),
data: [{ url, format: "mp4" }],
},
};
}

function falModelPath(model: string): string {
return model.startsWith("xai/") ? model : `fal-ai/${model}`;
}

function getToken(credentials: FalCredentials | null | undefined): string {
return String(credentials?.apiKey || credentials?.accessToken || "");
}

function responseError(payload: unknown): string {
return sanitizeErrorMessage(JSON.stringify(payload).slice(0, 500));
}

export async function handleFalVideoGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: {
model: string;
provider: string;
providerConfig: FalProviderConfig;
body: FalVideoBody;
credentials: FalCredentials | null | undefined;
log?: FalLog | null;
}) {
const token = getToken(credentials);
if (!token) return { success: false as const, status: 401, error: "Fal API key is required" };

const startTime = Date.now();
const timeoutMs = getConfiguredTimeout();
const pollIntervalMs = Math.max(100, numberValue(body.poll_interval_ms) || 1000);
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
const queueUrl = `${baseUrl}/${falModelPath(model)}`;
const headers = {
Authorization: `Key ${token}`,
"Content-Type": "application/json",
};

log?.info?.("VIDEO", `${provider}/${model} (fal-ai-video)`, {
prompt: stringValue(body.prompt)?.slice(0, 200) || "",
});

try {
const createResponse = await fetchWithTimeout(queueUrl, {
method: "POST",
headers,
body: JSON.stringify(buildFalVideoPayload(model, body)),
timeoutMs,
});
const createPayload = await createResponse.json().catch(() => ({}));

if (!createResponse.ok) {
const error = responseError(createPayload);
log?.error?.("VIDEO", `Fal create failed (${createResponse.status}): ${error}`);
return { success: false as const, status: createResponse.status, error };
}

const requestId = stringValue(createPayload.request_id);
if (!requestId) return normalizeFalVideoResponse(createPayload);

const statusUrl =
absoluteFalUrl(createPayload.status_url, baseUrl) ||
`${queueUrl}/requests/${requestId}/status`;
const responseUrl =
absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`;
const deadline = startTime + timeoutMs;

while (Date.now() < deadline) {
const remainingMs = Math.max(1000, deadline - Date.now());
const statusResponse = await fetchWithTimeout(statusUrl, {
headers: { Authorization: `Key ${token}` },
timeoutMs: Math.min(timeoutMs, remainingMs),
});
const statusPayload = await statusResponse.json().catch(() => ({}));

if (!statusResponse.ok) {
return {
success: false as const,
status: statusResponse.status,
error: responseError(statusPayload),
};
}

const status = stringValue(statusPayload.status);
if (status === "COMPLETED") {
const resultResponse = await fetchWithTimeout(responseUrl, {
headers: { Authorization: `Key ${token}` },
timeoutMs: Math.min(timeoutMs, Math.max(1000, deadline - Date.now())),
});
const resultPayload = await resultResponse.json().catch(() => ({}));
if (!resultResponse.ok) {
return {
success: false as const,
status: resultResponse.status,
error: responseError(resultPayload),
};
}

const result = normalizeFalVideoResponse(resultPayload);
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: result.success ? 200 : result.status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
...(result.success ? {} : { error: result.error }),
}).catch(() => {});
return result;
}

if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) {
return {
success: false as const,
status: 502,
error: `Fal video generation ended with status ${status}`,
};
}

await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs)));
}

return {
success: false as const,
status: 504,
error: `Fal video generation timed out after ${timeoutMs}ms`,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const isTimeout =
error instanceof FetchTimeoutError || (error as { name?: string }).name === "AbortError";
const status = isTimeout ? 504 : 502;
const safeMessage = sanitizeErrorMessage(message);
log?.error?.("VIDEO", `Fal request failed: ${safeMessage}`);
return { success: false as const, status, error: `Fal video provider error: ${safeMessage}` };
}
}
99 changes: 99 additions & 0 deletions tests/unit/video-fal-grok.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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-fal-grok-"));

const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts");
const { VIDEO_PROVIDERS } = await import("../../open-sse/config/videoRegistry.ts");

function immediateTimeout(callback: (...args: unknown[]) => void, _ms: number, ...args: unknown[]) {
callback(...args);
return 0;
}

function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

test("VIDEO_PROVIDERS exposes the Fal-hosted Grok video model", () => {
assert.ok(VIDEO_PROVIDERS["fal-ai"]);
assert.equal(VIDEO_PROVIDERS["fal-ai"].format, "fal-ai-video");
assert.ok(
VIDEO_PROVIDERS["fal-ai"].models.some(
(model) => model.id === "xai/grok-imagine-video/text-to-video"
)
);
});

test("handleVideoGeneration sends the Fal-hosted Grok request and returns the video URL", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const requests: Array<{ url: string; headers: Record<string, string>; body?: unknown }> = [];
globalThis.setTimeout = immediateTimeout as typeof globalThis.setTimeout;

globalThis.fetch = async (url, options = {}) => {
const stringUrl = String(url);
const headers = Object.fromEntries(new Headers(options.headers).entries());
const body = options.body ? JSON.parse(String(options.body)) : undefined;
requests.push({ url: stringUrl, headers, body });

if (requests.length === 1) {
return jsonResponse({
request_id: "fal-grok-1",
status_url:
"https://queue.fal.run/xai/grok-imagine-video/text-to-video/requests/fal-grok-1/status",
response_url:
"https://queue.fal.run/xai/grok-imagine-video/text-to-video/requests/fal-grok-1",
});
}
if (stringUrl.endsWith("/status")) return jsonResponse({ status: "COMPLETED" });
return jsonResponse({ video: { url: "https://cdn.example/fal-grok-1.mp4" } });
};

try {
const result = await handleVideoGeneration({
body: {
model: "fal-ai/xai/grok-imagine-video/text-to-video",
prompt: "a dog walking through a park",
duration: "8s",
resolution: "720p",
generate_audio: true,
},
credentials: { apiKey: "fal-key" },
log: null,
});

assert.equal(requests[0]?.url, "https://queue.fal.run/xai/grok-imagine-video/text-to-video");
assert.equal(requests[0]?.headers.authorization, "Key fal-key");
assert.deepEqual(requests[0]?.body, {
prompt: "a dog walking through a park",
aspect_ratio: "16:9",
duration: 8,
resolution: "720p",
});
assert.equal(result.success, true);
assert.equal(result.data.data[0].url, "https://cdn.example/fal-grok-1.mp4");
assert.equal(result.data.data[0].format, "mp4");
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
});

test("handleVideoGeneration rejects Fal video requests without credentials", async () => {
const result = await handleVideoGeneration({
body: { model: "fal-ai/xai/grok-imagine-video/text-to-video", prompt: "x" },
credentials: null,
log: null,
});

assert.equal(result.success, false);
assert.equal(result.status, 401);
assert.match(result.error, /Fal API key is required/);
});