diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 26ad771d30..d7097420c4 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -520,18 +520,18 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "key", format: "fal-ai", models: [ - { id: "fal-ai/flux-2-max", name: "FLUX.2 Max" }, - { id: "fal-ai/flux-2-pro", name: "FLUX.2 Pro" }, - { id: "fal-ai/flux-2-flex", name: "FLUX.2 Flex" }, + { id: "flux-2-max", name: "FLUX.2 Max" }, + { id: "flux-2-pro", name: "FLUX.2 Pro" }, + { id: "flux-2-flex", name: "FLUX.2 Flex" }, { id: "bria/text-to-image/3.2", name: "Bria 3.2" }, - { id: "fal-ai/bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, - { id: "fal-ai/bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, - { id: "fal-ai/ideogram/v3", name: "Ideogram V3" }, - { id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro" }, - { id: "fal-ai/nano-banana-2", name: "Nano Banana 2" }, - { id: "fal-ai/recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, - { id: "fal-ai/recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, - { id: "fal-ai/stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, + { id: "bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, + { id: "bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, + { id: "ideogram/v3", name: "Ideogram V3" }, + { id: "nano-banana-pro", name: "Nano Banana Pro" }, + { id: "nano-banana-2", name: "Nano Banana 2" }, + { id: "recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, + { id: "recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, + { id: "stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, ], supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], }, @@ -852,15 +852,13 @@ export function parseImageModel(modelStr) { for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { const model = modelStr.slice(providerId.length + 1); - const aliased = - resolveImageModelAlias(`${providerId}/${model}`) || resolveImageModelAlias(model); + const aliased = resolveImageModelAlias(`${providerId}/${model}`); return aliased || { provider: providerId, model }; } // Check alias if available if (config.alias && modelStr.startsWith(config.alias + "/")) { const model = modelStr.slice(config.alias.length + 1); - const aliased = - resolveImageModelAlias(`${providerId}/${model}`) || resolveImageModelAlias(model); + const aliased = resolveImageModelAlias(`${providerId}/${model}`); return aliased || { provider: providerId, model }; } } diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 7eda3e7425..06aa8a159b 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -33,6 +33,15 @@ export const MUSIC_PROVIDERS: Record = { models: [{ id: "lyria-002", name: "Lyria 2 (Vertex)" }], }, + "fal-ai": { + id: "fal-ai", + baseUrl: "https://queue.fal.run", + authType: "apikey", + authHeader: "key", + format: "fal-ai-music", + models: [{ id: "ace-step", name: "ACE-Step" }], + }, + kie: { id: "kie", baseUrl: "https://api.kie.ai", diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 27dc28ab06..acbdc56b63 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -96,6 +96,7 @@ export const VIDEO_PROVIDERS: Record = { format: "fal-ai-video", models: [ { id: "veo3.1/lite", name: "Veo 3.1 Lite" }, + { id: "google/gemini-omni-flash", name: "Gemini Omni Flash" }, { id: "xai/grok-imagine-video/text-to-video", name: "Grok Imagine Video", diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 485a31659f..f8f8b3d001 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1498,6 +1498,7 @@ async function handleFalAIImageGeneration({ }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; + const falModel = model.startsWith("fal-ai/") ? model : `fal-ai/${model}`; const { imageUrl, imageUrls } = extractImageInputs(body); const upstreamBody: Record = { prompt: body.prompt, @@ -1543,7 +1544,7 @@ async function handleFalAIImageGeneration({ } try { - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${model}`, { + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${falModel}`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/open-sse/handlers/mediaGeneration/fal.test.ts b/open-sse/handlers/mediaGeneration/fal.test.ts new file mode 100644 index 0000000000..ec9e05fcf6 --- /dev/null +++ b/open-sse/handlers/mediaGeneration/fal.test.ts @@ -0,0 +1,359 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildFalMusicRequestBody, + buildFalVideoRequestBody, + handleFalMusicGeneration, + handleFalVideoGeneration, + normalizeFalMediaResult, +} from "./fal.ts"; +import { parseImageModel } from "../../config/imageRegistry.ts"; +import { parseMusicModel } from "../../config/musicRegistry.ts"; +import { parseVideoModel } from "../../config/videoRegistry.ts"; + +test("buildFalVideoRequestBody maps the OpenAI-compatible request", () => { + assert.deepEqual( + buildFalVideoRequestBody({ + prompt: "A quiet train crossing a snowy bridge", + aspect_ratio: "9:16", + duration: 6, + resolution: "1080p", + generate_audio: false, + negative_prompt: "text overlays", + seed: 42, + }), + { + prompt: "A quiet train crossing a snowy bridge", + aspect_ratio: "9:16", + duration: "6s", + resolution: "1080p", + generate_audio: false, + negative_prompt: "text overlays", + seed: 42, + } + ); +}); + +test("buildFalVideoRequestBody maps the Fal-hosted Grok endpoint schema", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "16:9", + duration: "8s", + resolution: "720p", + generate_audio: true, + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "16:9", + duration: 8, + resolution: "720p", + } + ); +}); + +test("buildFalVideoRequestBody maps one provider-neutral image reference", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + image_url: "data:image/png;base64,ZmFrZQ==", + } + ); +}); + +test("buildFalVideoRequestBody maps multiple provider-neutral image references", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "Combine these references", + image_urls: ["data:image/png;base64,YQ==", "data:image/png;base64,Yg=="], + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "Combine these references", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + reference_image_urls: ["data:image/png;base64,YQ==", "data:image/png;base64,Yg=="], + } + ); +}); + +test("buildFalVideoRequestBody maps the Gemini Omni Flash video schema", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "9:16", + duration: 10, + resolution: "1080p", + generate_audio: false, + }, + "google/gemini-omni-flash" + ), + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "9:16", + duration: 10, + } + ); +}); + +test("handleFalVideoGeneration selects Gemini Omni Flash image-to-video", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/gemini.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "google/gemini-omni-flash", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + duration: 8, + }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/google/gemini-omni-flash/image-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 8, + image_url: "data:image/png;base64,ZmFrZQ==", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("buildFalMusicRequestBody uses prompt as tags and supports lyrics", () => { + assert.deepEqual( + buildFalMusicRequestBody({ + prompt: "warm analog synthwave", + lyrics: "[verse] Drive through the night", + duration: 30, + seed: 7, + }), + { + tags: "warm analog synthwave", + lyrics: "[verse] Drive through the night", + duration: 30, + seed: 7, + } + ); +}); + +test("normalizeFalMediaResult returns typed media URLs", () => { + assert.deepEqual( + normalizeFalMediaResult( + { + video: { + url: "https://cdn.example/video.mp4", + content_type: "video/mp4", + }, + }, + "video" + ), + { + success: true, + data: { + created: 0, + data: [{ url: "https://cdn.example/video.mp4", format: "mp4" }], + }, + } + ); + + assert.deepEqual( + normalizeFalMediaResult({ audio: { url: "https://cdn.example/song.wav" } }, "music"), + { + success: true, + data: { + created: 0, + data: [{ url: "https://cdn.example/song.wav", format: "wav" }], + }, + } + ); +}); + +test("normalizeFalMediaResult rejects a missing artifact", () => { + assert.deepEqual(normalizeFalMediaResult({}, "video"), { + success: false, + status: 502, + error: "Fal video generation returned no media URL", + }); +}); + +test("media registries expose provider-neutral model IDs", () => { + assert.deepEqual(parseImageModel("fal-ai/flux-2-pro"), { + provider: "fal-ai", + model: "flux-2-pro", + }); + assert.deepEqual(parseVideoModel("fal-ai/veo3.1/lite"), { + provider: "fal-ai", + model: "veo3.1/lite", + }); + assert.deepEqual(parseMusicModel("fal-ai/ace-step"), { + provider: "fal-ai", + model: "ace-step", + }); +}); + +test("handleFalVideoGeneration uses the provider-neutral queue contract", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/video.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "veo3.1/lite", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "a slow pan across a forest", duration: 4 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/fal-ai/veo3.1/lite"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "a slow pan across a forest", + aspect_ratio: "16:9", + duration: "4s", + resolution: "720p", + generate_audio: true, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalVideoGeneration preserves Fal model paths outside the fal-ai namespace", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/grok.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "xai/grok-imagine-video/text-to-video", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "a dog walking through a park", duration: 8 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/xai/grok-imagine-video/text-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "a dog walking through a park", + aspect_ratio: "16:9", + duration: 8, + resolution: "720p", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalVideoGeneration selects Grok image-to-video for one reference image", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/grok-i2v.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "xai/grok-imagine-video/text-to-video", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/xai/grok-imagine-video/image-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + image_url: "data:image/png;base64,ZmFrZQ==", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalMusicGeneration uses the provider-neutral queue contract", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ audio: { url: "https://cdn.example/music.wav" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalMusicGeneration({ + model: "ace-step", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "ambient synthwave", lyrics: "stay awake", duration: 30 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/fal-ai/ace-step"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + tags: "ambient synthwave", + lyrics: "stay awake", + duration: 30, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/open-sse/handlers/mediaGeneration/fal.ts b/open-sse/handlers/mediaGeneration/fal.ts new file mode 100644 index 0000000000..85053ef17d --- /dev/null +++ b/open-sse/handlers/mediaGeneration/fal.ts @@ -0,0 +1,396 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "../../../src/shared/utils/fetchTimeout.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +type MediaKind = "video" | "music"; + +type FalBody = Record; + +type FalCredentials = { + apiKey?: unknown; + accessToken?: unknown; +}; + +type FalProviderConfig = { + baseUrl: string; +}; + +type 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 stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(stringValue).filter((value): value is string => Boolean(value)); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function falDuration(value: unknown, fallback: string): 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; +} + +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 geminiDuration(value: unknown, fallback = 8): number { + const numeric = numberValue(value); + const parsed = + numeric ?? + (typeof value === "string" && /^\d+(?:\.\d+)?s$/.test(value.trim()) + ? Number(value.trim().slice(0, -1)) + : undefined); + return parsed === undefined ? fallback : Math.min(10, Math.max(3, Math.round(parsed))); +} + +export function buildFalVideoRequestBody(body: FalBody, model = ""): FalBody { + if (model.startsWith("google/gemini-omni-flash")) { + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: geminiDuration(body.duration), + }; + + const imageUrl = stringValue(body.image_url) || stringArray(body.image_urls)[0]; + if (imageUrl) request.image_url = imageUrl; + + return request; + } + + if (model.startsWith("xai/grok-imagine-video/")) { + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: grokDuration(body.duration), + resolution: stringValue(body.resolution) || "720p", + }; + + const imageUrls = stringArray(body.image_urls); + if (imageUrls.length === 1) { + request.image_url = imageUrls[0]; + } else if (imageUrls.length > 1) { + request.reference_image_urls = imageUrls; + } + + return request; + } + + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: falDuration(body.duration, "8s"), + resolution: stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p"), + generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true, + }; + + const optionalStringFields = ["negative_prompt", "safety_tolerance"]; + for (const field of optionalStringFields) { + const value = stringValue(body[field]); + if (value) request[field] = value; + } + + const seed = numberValue(body.seed); + if (seed !== undefined) request.seed = seed; + if (typeof body.auto_fix === "boolean") request.auto_fix = body.auto_fix; + + return request; +} + +function resolveFalModel(model: string, body: FalBody, kind: MediaKind): string { + if (kind !== "video") return model; + + if (model.startsWith("google/gemini-omni-flash") && !model.endsWith("/image-to-video")) { + const hasImage = typeof body.image_url === "string" || stringArray(body.image_urls).length > 0; + return hasImage ? "google/gemini-omni-flash/image-to-video" : model; + } + + if (!model.startsWith("xai/grok-imagine-video/")) return model; + + const suffix = Array.isArray(body.reference_image_urls) + ? "reference-to-video" + : typeof body.image_url === "string" + ? "image-to-video" + : "text-to-video"; + return `xai/grok-imagine-video/${suffix}`; +} + +export function buildFalMusicRequestBody(body: FalBody): FalBody { + const request: FalBody = { + tags: stringValue(body.tags) || stringValue(body.prompt) || "", + }; + + const lyrics = stringValue(body.lyrics); + if (lyrics) request.lyrics = lyrics; + + const duration = numberValue(body.duration); + if (duration !== undefined) request.duration = Math.min(240, Math.max(5, duration)); + + const seed = numberValue(body.seed); + if (seed !== undefined) request.seed = seed; + + const optionalNumberFields = [ + "number_of_steps", + "granularity_scale", + "guidance_interval", + "guidance_interval_decay", + "tag_guidance_scale", + "lyric_guidance_scale", + "minimum_guidance_scale", + "guidance_scale", + ]; + for (const field of optionalNumberFields) { + const value = numberValue(body[field]); + if (value !== undefined) request[field] = value; + } + + const scheduler = stringValue(body.scheduler); + if (scheduler === "euler" || scheduler === "heun") request.scheduler = scheduler; + + const guidanceType = stringValue(body.guidance_type); + if (guidanceType === "cfg" || guidanceType === "apg" || guidanceType === "cfg_star") { + request.guidance_type = guidanceType; + } + + return request; +} + +function extensionFromMedia(item: Record, kind: MediaKind): string { + const contentType = stringValue(item.content_type); + if (contentType?.includes("/")) return contentType.split("/", 2)[1]; + + const fileName = stringValue(item.file_name); + const url = stringValue(item.url); + const candidate = fileName || url || ""; + const extension = candidate.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1]?.toLowerCase(); + return extension || (kind === "video" ? "mp4" : "wav"); +} + +export function normalizeFalMediaResult(payload: unknown, kind: MediaKind) { + const record = payload && typeof payload === "object" ? (payload as FalBody) : {}; + const media = record[kind === "video" ? "video" : "audio"]; + const item = media && typeof media === "object" ? (media as Record) : null; + const url = stringValue(item?.url); + + if (!url) { + return { + success: false as const, + status: 502, + error: `Fal ${kind} generation returned no media URL`, + }; + } + + return { + success: true as const, + data: { + created: numberValue(record.created) || 0, + data: [{ url, format: extensionFromMedia(item, kind) }], + }, + }; +} + +function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined { + const url = stringValue(value); + if (!url) return undefined; + return url.startsWith("http://") || url.startsWith("https://") + ? url + : `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`; +} + +function getToken(credentials: FalCredentials | null | undefined): string { + return String(credentials?.apiKey || credentials?.accessToken || ""); +} + +async function wait(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runFalQueue({ + model, + body, + kind, + provider, + providerConfig, + credentials, + log, +}: { + model: string; + body: FalBody; + kind: MediaKind; + provider: string; + providerConfig: FalProviderConfig; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + const startTime = Date.now(); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const token = getToken(credentials); + const headers = { + Authorization: `Key ${token}`, + "Content-Type": "application/json", + }; + const timeoutMs = getConfiguredTimeout(); + const deadline = startTime + timeoutMs; + const resolvedModel = resolveFalModel(model, body, kind); + const falModel = + resolvedModel.startsWith("fal-ai/") || + resolvedModel.startsWith("xai/") || + resolvedModel.startsWith("google/") + ? resolvedModel + : `fal-ai/${resolvedModel}`; + const queueUrl = `${baseUrl}/${falModel}`; + + try { + const createResponse = await fetchWithTimeout(queueUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + timeoutMs, + }); + const createPayload = await createResponse.json().catch(() => ({})); + + if (!createResponse.ok) { + const error = JSON.stringify(createPayload).slice(0, 500); + log?.error?.( + "MEDIA", + `${provider} ${kind} create failed (${createResponse.status}): ${error}` + ); + saveCallLog({ + method: "POST", + path: `/v1/${kind === "video" ? "videos" : "music"}/generations`, + status: createResponse.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error, + }).catch(() => {}); + return { success: false, status: createResponse.status, error }; + } + + const requestId = stringValue(createPayload?.request_id); + if (!requestId) { + const normalized = normalizeFalMediaResult(createPayload, kind); + if (!normalized.success) return normalized; + return normalized; + } + + const statusUrl = + absoluteFalUrl(createPayload.status_url, baseUrl) || + `${queueUrl}/requests/${requestId}/status`; + const responseUrl = + absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`; + + while (Date.now() < deadline) { + const statusResponse = await fetchWithTimeout(statusUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(getConfiguredTimeout(), Math.max(1000, deadline - Date.now())), + }); + const statusPayload = await statusResponse.json().catch(() => ({})); + + if (!statusResponse.ok) { + const error = JSON.stringify(statusPayload).slice(0, 500); + return { success: false, status: statusResponse.status, error }; + } + + const status = stringValue(statusPayload?.status); + if (status === "COMPLETED") { + const resultResponse = await fetchWithTimeout(responseUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(getConfiguredTimeout(), Math.max(1000, deadline - Date.now())), + }); + const resultPayload = await resultResponse.json().catch(() => ({})); + if (!resultResponse.ok) { + return { + success: false, + status: resultResponse.status, + error: JSON.stringify(resultPayload).slice(0, 500), + }; + } + + const normalized = normalizeFalMediaResult(resultPayload, kind); + saveCallLog({ + method: "POST", + path: `/v1/${kind === "video" ? "videos" : "music"}/generations`, + status: normalized.success ? 200 : normalized.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + ...(normalized.success ? {} : { error: normalized.error }), + }).catch(() => {}); + return normalized; + } + + if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) { + return { + success: false, + status: 502, + error: `Fal ${kind} generation ended with status ${status}`, + }; + } + + await wait(Math.min(1000, Math.max(100, deadline - Date.now()))); + } + + return { + success: false, + status: 504, + error: `Fal ${kind} generation timed out after ${timeoutMs}ms`, + }; + } catch (error) { + 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; + log?.error?.("MEDIA", `${provider} ${kind} request failed: ${sanitizeErrorMessage(message)}`); + return { + success: false, + status, + error: `Fal ${kind} provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} + +export function handleFalVideoGeneration(args: { + model: string; + provider: string; + providerConfig: FalProviderConfig; + body: FalBody; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + return runFalQueue({ + ...args, + body: buildFalVideoRequestBody(args.body, args.model), + kind: "video", + }); +} + +export function handleFalMusicGeneration(args: { + model: string; + provider: string; + providerConfig: FalProviderConfig; + body: FalBody; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + return runFalQueue({ ...args, body: buildFalMusicRequestBody(args.body), kind: "music" }); +} diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 766abdd542..92ee333c2e 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -32,6 +32,7 @@ import { parseKieResultJson, } from "../utils/kieTask.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts"; function normalizeKieSunoModel(model: string): string { const map: Record = { @@ -124,6 +125,10 @@ export async function handleMusicGeneration({ body, credentials, log }) { } } + if (providerConfig.format === "fal-ai-music") { + return handleFalMusicGeneration({ model, provider, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "comfyui") { return handleComfyUIMusicGeneration({ model, diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index ff09a95a35..2972962713 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -51,6 +51,7 @@ import { fetchWithTimeout, getConfiguredTimeout, } from "@/shared/utils/fetchTimeout"; +import { handleFalVideoGeneration } from "./mediaGeneration/fal.ts"; /** * Resolve the base URL for OpenAI-compatible video generation endpoints. diff --git a/tests/unit/cleanup-column-fix.test.mjs b/tests/unit/cleanup-column-fix.test.mjs index 7dd4330c85..13e48d5bac 100644 --- a/tests/unit/cleanup-column-fix.test.mjs +++ b/tests/unit/cleanup-column-fix.test.mjs @@ -82,24 +82,32 @@ test("cleanup: scheduler is wired into server-init.ts", () => { test("cleanup: mcp_tool_audit uses correct table name (not 'mcp_audit_log')", () => { assert.ok( - source.includes("DELETE FROM mcp_tool_audit WHERE"), - "must use correct table name mcp_tool_audit" + source.includes("DELETE FROM mcp_tool_audit WHERE created_at < ?"), + "mcp_tool_audit cleanup must use its created_at column" ); assert.ok( !source.includes("DELETE FROM mcp_audit_log WHERE"), "must NOT use non-existent table name mcp_audit_log" ); + assert.ok( + !source.includes("DELETE FROM mcp_tool_audit WHERE timestamp"), + "must NOT use timestamp for mcp_tool_audit" + ); }); test("cleanup: a2a_task_events uses correct table name (not 'a2a_events')", () => { assert.ok( - source.includes("DELETE FROM a2a_task_events WHERE"), - "must use correct table name a2a_task_events" + source.includes("DELETE FROM a2a_task_events WHERE created_at < ?"), + "a2a_task_events cleanup must use its created_at column" ); assert.ok( !source.includes("DELETE FROM a2a_events WHERE"), "must NOT use non-existent table name a2a_events" ); + assert.ok( + !source.includes("DELETE FROM a2a_task_events WHERE timestamp"), + "must NOT use timestamp for a2a_task_events" + ); }); test("cleanup: memories uses correct table name (not 'memory_entries')", () => {