From ce3be0926935b37c53e110887abe9fe9cfdbabab Mon Sep 17 00:00:00 2001 From: Praveen K Palaniswamy Date: Sat, 31 Oct 2026 10:30:49 -0400 Subject: [PATCH 1/4] fix(sse): send Leonardo image requests a platform-model UUID, not the catalog id Leonardo's v1 generations API takes a platform-model UUID as modelId. The handler sent the catalog id (phoenix, sdxl), which Leonardo answers with HTTP 500 "internal error", so every leonardo/* image request failed. Map phoenix and sdxl to the Phoenix 1.0 and SDXL 1.0 UUIDs; a raw UUID passes through so any Leonardo model can be used. --- .../imageGeneration/providers/leonardo.ts | 19 ++++- tests/unit/leonardo-image-model-id.test.ts | 84 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/unit/leonardo-image-model-id.test.ts diff --git a/open-sse/handlers/imageGeneration/providers/leonardo.ts b/open-sse/handlers/imageGeneration/providers/leonardo.ts index c407693f4660..c285a71e7109 100644 --- a/open-sse/handlers/imageGeneration/providers/leonardo.ts +++ b/open-sse/handlers/imageGeneration/providers/leonardo.ts @@ -6,6 +6,19 @@ import { saveCallLog } from "@/lib/usageDb"; import { sleep } from "../../../utils/sleep.ts"; import { sanitizeErrorMessage } from "../../../utils/error.ts"; +// Leonardo's v1 API takes a platform-model UUID as `modelId` and answers a catalog id such +// as "phoenix" with HTTP 500 "internal error". UUIDs from GET /api/rest/v1/platformModels. +const LEONARDO_MODEL_IDS: Record = { + phoenix: "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3", // Phoenix 1.0 + sdxl: "16e7060a-803e-4df3-97ee-edcfa5dc9cc8", // SDXL 1.0 +}; + +/** The catalog id's platform-model UUID; anything else (e.g. a raw UUID) passes through. */ +export function resolveLeonardoModelId(model: string | undefined): string { + const id = model || "phoenix"; + return LEONARDO_MODEL_IDS[id] ?? id; +} + export async function handleLeonardoImageGeneration({ model, provider, @@ -13,6 +26,7 @@ export async function handleLeonardoImageGeneration({ body, credentials, log, + pollIntervalMs = 5000, }) { const startTime = Date.now(); const token = credentials?.apiKey || ""; @@ -25,7 +39,7 @@ export async function handleLeonardoImageGeneration({ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ - modelId: model || "phoenix", + modelId: resolveLeonardoModelId(model), prompt, width: body.width || 1024, height: body.height || 1024, @@ -61,7 +75,7 @@ export async function handleLeonardoImageGeneration({ } const deadline = Date.now() + 300000; while (Date.now() < deadline) { - await sleep(5000); + await sleep(pollIntervalMs); const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { headers: { Authorization: `Bearer ${token}` }, }); @@ -137,4 +151,3 @@ export async function handleLeonardoImageGeneration({ }; } } - diff --git a/tests/unit/leonardo-image-model-id.test.ts b/tests/unit/leonardo-image-model-id.test.ts new file mode 100644 index 000000000000..02496791b59f --- /dev/null +++ b/tests/unit/leonardo-image-model-id.test.ts @@ -0,0 +1,84 @@ +/** + * Leonardo's v1 REST API takes a platform-model UUID as `modelId`. The handler sent the + * catalog id (`phoenix`, `sdxl`) as-is, and Leonardo answers that with HTTP 500 + * `{"error":"internal error","code":"unexpected"}`, so every `leonardo/*` image request + * failed. The UUIDs below are from `GET /api/rest/v1/platformModels`. + */ + +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-model-id-")); + +const { handleLeonardoImageGeneration } = + await import("../../open-sse/handlers/imageGeneration/providers/leonardo.ts"); + +const BASE_URL = "https://cloud.leonardo.ai/api/rest/v1/generations"; +const PHOENIX_1_0 = "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3"; +const SDXL_1_0 = "16e7060a-803e-4df3-97ee-edcfa5dc9cc8"; + +const originalFetch = globalThis.fetch; +test.after(() => { + globalThis.fetch = originalFetch; +}); + +function json(payload: unknown) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** Fake Leonardo: records the submitted modelId and completes the generation on first poll. */ +async function submittedModelId(model: string): Promise { + let modelId: unknown; + globalThis.fetch = (async (url: unknown, options?: RequestInit) => { + const href = String(url); + if (href === BASE_URL && options?.method === "POST") { + modelId = JSON.parse(String(options.body)).modelId; + return json({ sdGenerationJob: { generationId: "gen-1" } }); + } + if (href === `${BASE_URL}/gen-1`) { + return json({ + generations_by_pk: { + status: "COMPLETE", + generated_images: [{ url: "https://cdn.leonardo.ai/img.png" }], + }, + }); + } + if (href === "https://cdn.leonardo.ai/img.png") return new Response(new Uint8Array([1, 2, 3])); + throw new Error(`unexpected fetch ${href}`); + }) as typeof fetch; + + const result = await handleLeonardoImageGeneration({ + model, + provider: "leonardo", + providerConfig: { baseUrl: BASE_URL }, + body: { prompt: "a red apple" }, + credentials: { apiKey: "test-key" }, + log: null, + pollIntervalMs: 0, + }); + assert.equal(result.success, true); + return modelId; +} + +test("the phoenix catalog id is sent as the Phoenix 1.0 model UUID", async () => { + assert.equal(await submittedModelId("phoenix"), PHOENIX_1_0); +}); + +test("the sdxl catalog id is sent as the SDXL 1.0 model UUID", async () => { + assert.equal(await submittedModelId("sdxl"), SDXL_1_0); +}); + +test("a model UUID passes through unchanged, so any Leonardo model can be used", async () => { + const custom = "b24e16ff-06e3-43eb-8d33-4416c2d75876"; + assert.equal(await submittedModelId(custom), custom); +}); + +test("no model falls back to Phoenix 1.0", async () => { + assert.equal(await submittedModelId(""), PHOENIX_1_0); +}); From 02df76145783a4c762dfa041b46152233e23ab8e Mon Sep 17 00:00:00 2001 From: Praveen K Palaniswamy Date: Sat, 31 Oct 2026 10:41:36 -0400 Subject: [PATCH 2/4] fix(sse): send Leonardo video to the text-to-video endpoint and return the mp4 The video handler posted an image-generation body (modelId "phoenix", num_frames) to /generations, which Leonardo rejects with 400 "Unexpected variable num_frames", so leonardo video never worked. Submit to /generations-text-to-video with a model enum and resolution, read motionVideoGenerationJob, poll the generations status URL, and download motionMP4URL (url is a jpg thumbnail). The catalog lists Motion 2.0 and Motion 2.0 Fast; the old phoenix id maps to Motion 2.0. --- open-sse/config/videoRegistry.ts | 7 +- .../videoGeneration/leonardoHandler.ts | 68 ++++++++-- tests/unit/leonardo-video-generation.test.ts | 126 ++++++++++++++++++ 3 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 tests/unit/leonardo-video-generation.test.ts diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 2517b6383b7b..6232511be47e 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -197,12 +197,15 @@ export const VIDEO_PROVIDERS: Record = { }, leonardo: { id: "leonardo", - baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", + baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations-text-to-video", statusUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", authType: "apikey", authHeader: "bearer", format: "leonardo-video", - models: [{ id: "phoenix", name: "Phoenix Video" }], + models: [ + { id: "motion-2.0", name: "Motion 2.0" }, + { id: "motion-2.0-fast", name: "Motion 2.0 Fast" }, + ], }, pollinations: { id: "pollinations", diff --git a/open-sse/handlers/videoGeneration/leonardoHandler.ts b/open-sse/handlers/videoGeneration/leonardoHandler.ts index edecce432cba..57969590cc4c 100644 --- a/open-sse/handlers/videoGeneration/leonardoHandler.ts +++ b/open-sse/handlers/videoGeneration/leonardoHandler.ts @@ -1,5 +1,5 @@ /** - * Leonardo AI (Phoenix) video generation: submit → poll → fetch output. + * Leonardo AI (Motion 2.0) text-to-video: submit → poll → fetch the mp4. * * Extracted out of the frozen `videoGeneration.ts` god-file (unchanged behavior) to make * room for the new DeepInfra video adapter without pushing the file-size ratchet over its @@ -9,6 +9,49 @@ import { saveCallLog } from "@/lib/usageDb"; import { abortedVideoResult, POLL_ABORTED, pollJsonAfter } from "./abort.ts"; +// Catalog id -> Leonardo `model` enum for POST /generations-text-to-video. `phoenix` was the +// old catalog id (an image model); it keeps working as Motion 2.0. Anything else passes +// through as the enum itself. +const LEONARDO_VIDEO_MODELS: Record = { + "motion-2.0": "MOTION2", + "motion-2.0-fast": "MOTION2FAST", + phoenix: "MOTION2", +}; + +/** Catalog id -> Leonardo `model` enum; an unknown id is sent as the enum itself. */ +export function resolveLeonardoVideoModel(model: string): string { + return LEONARDO_VIDEO_MODELS[model] ?? model; +} + +// Leonardo's video sizes as [long side, short side]. +const LEONARDO_VIDEO_SIZES = { + RESOLUTION_480: [832, 480], + RESOLUTION_720: [1280, 720], +} as const; + +function parseVideoSize(size: unknown): [number, number] { + const match = typeof size === "string" ? size.trim().match(/^(\d+)x(\d+)$/i) : null; + return match ? [Number(match[1]), Number(match[2])] : [0, 0]; +} + +/** A `WxH` size with a short side <= 480 picks RESOLUTION_480, else RESOLUTION_720. */ +export function resolveLeonardoVideoSize(size: unknown) { + const [width, height] = parseVideoSize(size); + const resolution = + width > 0 && Math.min(width, height) <= 480 ? "RESOLUTION_480" : "RESOLUTION_720"; + const [long, short] = LEONARDO_VIDEO_SIZES[resolution]; + return height > width + ? { resolution, width: short, height: long } + : { resolution, width: long, height: short }; +} + +/** The finished video: `motionMP4URL`, since `url` on a video generation is a jpg thumbnail. */ +function leonardoVideoUrl(generation: { + generated_images?: Array<{ motionMP4URL?: string }>; +}): string | undefined { + return generation.generated_images?.[0]?.motionMP4URL; +} + export async function handleLeonardoVideoGeneration({ model, provider, @@ -17,6 +60,7 @@ export async function handleLeonardoVideoGeneration({ credentials, log, signal = null, + pollIntervalMs = 5000, }) { const startTime = Date.now(); const token = credentials?.apiKey || ""; @@ -24,11 +68,10 @@ export async function handleLeonardoVideoGeneration({ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ - modelId: "phoenix", + model: resolveLeonardoVideoModel(model), prompt: body.prompt, - width: 1024, - height: 576, - num_frames: 24, + ...resolveLeonardoVideoSize(body.size), + isPublic: false, }), }); if (!res.ok) { @@ -44,8 +87,8 @@ export async function handleLeonardoVideoGeneration({ }).catch(() => {}); return { success: false, status: res.status, error: errorText }; } - const { sdGenerationJob } = await res.json(); - const genId = sdGenerationJob?.generationId; + const { motionVideoGenerationJob } = await res.json(); + const genId = motionVideoGenerationJob?.generationId; if (!genId) { saveCallLog({ method: "POST", @@ -61,18 +104,17 @@ export async function handleLeonardoVideoGeneration({ const deadline = Date.now() + 300000; while (Date.now() < deadline) { const status = await pollJsonAfter( - 5000, - - `${providerConfig.baseUrl}/${genId}`, + pollIntervalMs, + `${providerConfig.statusUrl}/${genId}`, { Authorization: `Bearer ${token}` }, signal ); if (status === POLL_ABORTED) return abortedVideoResult(); const gen = status.generations_by_pk || status; if (gen.status === "COMPLETE") { - const imgUrl = gen.generated_images?.[0]?.url; - if (imgUrl) { - const videoRes = await fetch(imgUrl); + const videoUrl = leonardoVideoUrl(gen); + if (videoUrl) { + const videoRes = await fetch(videoUrl); const buf = await videoRes.arrayBuffer(); saveCallLog({ method: "POST", diff --git a/tests/unit/leonardo-video-generation.test.ts b/tests/unit/leonardo-video-generation.test.ts new file mode 100644 index 000000000000..4c61cb8b0a00 --- /dev/null +++ b/tests/unit/leonardo-video-generation.test.ts @@ -0,0 +1,126 @@ +/** + * Leonardo video: the handler posted an image-generation body (`modelId: "phoenix"`, + * `num_frames`) to the image endpoint, which Leonardo rejects with + * `400 Unexpected variable num_frames`. Video goes to `POST /generations-text-to-video` + * with a `model` enum and a `resolution`, the job comes back as `motionVideoGenerationJob`, + * and the mp4 is `generated_images[0].motionMP4URL` (`url` is a jpg thumbnail). + */ + +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-video-")); + +const { handleLeonardoVideoGeneration } = + await import("../../open-sse/handlers/videoGeneration/leonardoHandler.ts"); +const { getVideoProvider } = await import("../../open-sse/config/videoRegistry.ts"); + +const providerConfig = getVideoProvider("leonardo"); +const SUBMIT_URL = "https://cloud.leonardo.ai/api/rest/v1/generations-text-to-video"; +const STATUS_URL = "https://cloud.leonardo.ai/api/rest/v1/generations"; +const MP4 = new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112]); + +const originalFetch = globalThis.fetch; +test.after(() => { + globalThis.fetch = originalFetch; +}); + +function json(payload: unknown) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** Fake Leonardo: records the submit request and completes the job on the first poll. */ +async function generate(model: string, body: Record = {}) { + let submitted: { url: string; body: Record } | null = null; + globalThis.fetch = (async (url: unknown, options?: RequestInit) => { + const href = String(url); + if (options?.method === "POST") { + submitted = { url: href, body: JSON.parse(String(options.body)) }; + return json({ motionVideoGenerationJob: { generationId: "vid-1" } }); + } + if (href === `${STATUS_URL}/vid-1`) { + return json({ + generations_by_pk: { + status: "COMPLETE", + generated_images: [ + { + url: "https://cdn.leonardo.ai/thumb.jpg", + motionMP4URL: "https://cdn.leonardo.ai/video.mp4", + }, + ], + }, + }); + } + if (href === "https://cdn.leonardo.ai/video.mp4") return new Response(MP4); + if (href === "https://cdn.leonardo.ai/thumb.jpg") return new Response("jpeg"); + throw new Error(`unexpected fetch ${href}`); + }) as typeof fetch; + + const result = await handleLeonardoVideoGeneration({ + model, + provider: "leonardo", + providerConfig, + body: { prompt: "a paper boat drifting on a pond", ...body }, + credentials: { apiKey: "test-key" }, + log: null, + pollIntervalMs: 0, + }); + return { result, submitted }; +} + +test("a video request goes to the text-to-video endpoint with a model enum and resolution", async () => { + const { submitted } = await generate("motion-2.0-fast"); + assert.equal(submitted?.url, SUBMIT_URL); + assert.equal(submitted?.body.model, "MOTION2FAST"); + assert.equal(submitted?.body.resolution, "RESOLUTION_720"); + assert.equal(submitted?.body.width, 1280); + assert.equal(submitted?.body.height, 720); + assert.equal(submitted?.body.prompt, "a paper boat drifting on a pond"); + assert.equal(submitted?.body.num_frames, undefined); + assert.equal(submitted?.body.modelId, undefined); +}); + +test("the result is the mp4 from motionMP4URL, not the jpg thumbnail", async () => { + const { result } = await generate("motion-2.0"); + assert.equal(result.success, true); + const item = (result as { data: { data: Array<{ b64_json: string; format: string }> } }).data + .data[0]; + assert.equal(item.format, "mp4"); + assert.deepEqual(new Uint8Array(Buffer.from(item.b64_json, "base64")), MP4); +}); + +test("motion-2.0 and the old phoenix id both send Motion 2.0", async () => { + assert.equal((await generate("motion-2.0")).submitted?.body.model, "MOTION2"); + assert.equal((await generate("phoenix")).submitted?.body.model, "MOTION2"); +}); + +test("a 480p size picks RESOLUTION_480 and keeps a portrait orientation", async () => { + const landscape = (await generate("motion-2.0-fast", { size: "832x480" })).submitted?.body; + assert.deepEqual( + [landscape?.resolution, landscape?.width, landscape?.height], + ["RESOLUTION_480", 832, 480] + ); + const portrait = (await generate("motion-2.0-fast", { size: "480x832" })).submitted?.body; + assert.deepEqual( + [portrait?.resolution, portrait?.width, portrait?.height], + ["RESOLUTION_480", 480, 832] + ); + const portrait720 = (await generate("motion-2.0", { size: "720x1280" })).submitted?.body; + assert.deepEqual( + [portrait720?.resolution, portrait720?.width, portrait720?.height], + ["RESOLUTION_720", 720, 1280] + ); +}); + +test("the catalog lists the Motion 2.0 models", () => { + assert.deepEqual( + providerConfig.models.map((m: { id: string }) => m.id), + ["motion-2.0", "motion-2.0-fast"] + ); +}); From 6deb70bb8319224b9698b63e351a769347e561c4 Mon Sep 17 00:00:00 2001 From: Praveen K Palaniswamy Date: Sat, 31 Oct 2026 10:46:32 -0400 Subject: [PATCH 3/4] docs(changelog): add the Leonardo image and video fix --- changelog.d/fixes/18670-leonardo-image-video.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fixes/18670-leonardo-image-video.md diff --git a/changelog.d/fixes/18670-leonardo-image-video.md b/changelog.d/fixes/18670-leonardo-image-video.md new file mode 100644 index 000000000000..0fd94874b1c0 --- /dev/null +++ b/changelog.d/fixes/18670-leonardo-image-video.md @@ -0,0 +1 @@ +- **fix(sse):** Leonardo image and video generation work again; image requests sent the catalog id (`phoenix`, `sdxl`) where Leonardo expects a platform-model UUID and got HTTP 500 "internal error", and video posted an image body to the image endpoint (400 "Unexpected variable num_frames"); video now uses `/generations-text-to-video` with Motion 2.0 / Motion 2.0 Fast and returns the mp4 instead of the thumbnail ([#18670](https://github.com/cheaper-inference/OmniRoute/pull/18670)) From b5f727741600135564dbca5c7d092a33d1bf0787 Mon Sep 17 00:00:00 2001 From: Praveen K Palaniswamy Date: Tue, 3 Nov 2026 19:08:30 -0500 Subject: [PATCH 4/4] fix(sse): honour Leonardo image size and bound the video download Addresses the CodeRabbit review on #18670: - image: parse an OpenAI-style size ("576x1024") into width/height; explicit width/height still win, 1024 stays the default - video: the mp4 download follows the client's abort, times out after 120s, and a non-2xx download is a 502/504 upstream error instead of a corrupt video - video: the 720p input (1280x720 with RESOLUTION_720) is kept; checked live on Motion 2.0 Fast, the job completes as a 1280x720 mp4 - tests drain call-log saves, close the DB and remove their DATA_DIR --- .../fixes/18670-leonardo-image-video.md | 2 +- .../imageGeneration/providers/leonardo.ts | 19 ++- .../videoGeneration/leonardoHandler.ts | 147 ++++++++++-------- tests/unit/leonardo-image-model-id.test.ts | 48 ++++-- tests/unit/leonardo-video-generation.test.ts | 79 +++++++++- 5 files changed, 211 insertions(+), 84 deletions(-) diff --git a/changelog.d/fixes/18670-leonardo-image-video.md b/changelog.d/fixes/18670-leonardo-image-video.md index 0fd94874b1c0..6a0e855702f2 100644 --- a/changelog.d/fixes/18670-leonardo-image-video.md +++ b/changelog.d/fixes/18670-leonardo-image-video.md @@ -1 +1 @@ -- **fix(sse):** Leonardo image and video generation work again; image requests sent the catalog id (`phoenix`, `sdxl`) where Leonardo expects a platform-model UUID and got HTTP 500 "internal error", and video posted an image body to the image endpoint (400 "Unexpected variable num_frames"); video now uses `/generations-text-to-video` with Motion 2.0 / Motion 2.0 Fast and returns the mp4 instead of the thumbnail ([#18670](https://github.com/cheaper-inference/OmniRoute/pull/18670)) +- **fix(sse):** Leonardo image and video generation work again; image requests sent the catalog id (`phoenix`, `sdxl`) where Leonardo expects a platform-model UUID and got HTTP 500 "internal error", and video posted an image body to the image endpoint (400 "Unexpected variable num_frames"); video now uses `/generations-text-to-video` with Motion 2.0 / Motion 2.0 Fast and returns the mp4 instead of the thumbnail; an image `size` such as `576x1024` is now honoured instead of always 1024x1024, and the mp4 download stops on client abort, times out, and reports a failed download as an upstream error ([#18670](https://github.com/cheaper-inference/OmniRoute/pull/18670)) diff --git a/open-sse/handlers/imageGeneration/providers/leonardo.ts b/open-sse/handlers/imageGeneration/providers/leonardo.ts index c285a71e7109..03d635f1b346 100644 --- a/open-sse/handlers/imageGeneration/providers/leonardo.ts +++ b/open-sse/handlers/imageGeneration/providers/leonardo.ts @@ -19,6 +19,22 @@ export function resolveLeonardoModelId(model: string | undefined): string { return LEONARDO_MODEL_IDS[id] ?? id; } +/** + * Output size: explicit `width`/`height` win, then an OpenAI-style `size` ("576x1024"), + * then 1024 for whatever is still missing. + */ +export function resolveLeonardoImageSize(body: { + size?: unknown; + width?: number; + height?: number; +}): { width: number; height: number } { + const match = typeof body.size === "string" ? body.size.trim().match(/^(\d+)x(\d+)$/i) : null; + return { + width: body.width || (match ? Number(match[1]) : 1024), + height: body.height || (match ? Number(match[2]) : 1024), + }; +} + export async function handleLeonardoImageGeneration({ model, provider, @@ -41,8 +57,7 @@ export async function handleLeonardoImageGeneration({ body: JSON.stringify({ modelId: resolveLeonardoModelId(model), prompt, - width: body.width || 1024, - height: body.height || 1024, + ...resolveLeonardoImageSize(body), num_images: 1, }), }); diff --git a/open-sse/handlers/videoGeneration/leonardoHandler.ts b/open-sse/handlers/videoGeneration/leonardoHandler.ts index 57969590cc4c..65367d2787a0 100644 --- a/open-sse/handlers/videoGeneration/leonardoHandler.ts +++ b/open-sse/handlers/videoGeneration/leonardoHandler.ts @@ -23,7 +23,8 @@ export function resolveLeonardoVideoModel(model: string): string { return LEONARDO_VIDEO_MODELS[model] ?? model; } -// Leonardo's video sizes as [long side, short side]. +// Leonardo's video sizes as [long side, short side]. 1280x720 with RESOLUTION_720 was checked +// live (Motion 2.0 Fast, 2026-11-03): the job completes as a 1280x720 mp4. const LEONARDO_VIDEO_SIZES = { RESOLUTION_480: [832, 480], RESOLUTION_720: [1280, 720], @@ -52,6 +53,70 @@ function leonardoVideoUrl(generation: { return generation.generated_images?.[0]?.motionMP4URL; } +// A stalled CDN must not hold the request open after the job itself has finished. +const LEONARDO_VIDEO_DOWNLOAD_TIMEOUT_MS = 120000; + +/** + * Downloads the finished mp4, cut short by the client's abort or the download timeout. + * Returns the bytes, `POLL_ABORTED` when the client went away, or an upstream error. + */ +async function downloadLeonardoVideo( + videoUrl: string, + signal: AbortSignal | null, + timeoutMs = LEONARDO_VIDEO_DOWNLOAD_TIMEOUT_MS +): Promise { + const timeout = AbortSignal.timeout(timeoutMs); + try { + const res = await fetch(videoUrl, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (!res.ok) + return { status: 502, error: `Leonardo video download failed (HTTP ${res.status})` }; + return await res.arrayBuffer(); + } catch (err) { + if (signal?.aborted) return POLL_ABORTED; + if (timeout.aborted) return { status: 504, error: "Leonardo video download timed out" }; + throw err; + } +} + +/** Logs one Leonardo video call; `error` is set on failures. */ +function logLeonardoVideoCall( + provider: string, + model: string, + startTime: number, + status: number, + error?: string +) { + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + ...(error ? { error } : {}), + }).catch(() => {}); +} + +/** The handler result for a finished download: the mp4, an abort, or a logged failure. */ +function videoDownloadResult( + buf: Awaited>, + fail: (status: number, error: string) => { success: boolean; status: number; error: string }, + logSuccess: () => void +) { + if (buf === POLL_ABORTED) return abortedVideoResult(); + if (!(buf instanceof ArrayBuffer)) return fail(buf.status, buf.error); + logSuccess(); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], + }, + }; +} + export async function handleLeonardoVideoGeneration({ model, provider, @@ -61,9 +126,14 @@ export async function handleLeonardoVideoGeneration({ log, signal = null, pollIntervalMs = 5000, + downloadTimeoutMs = LEONARDO_VIDEO_DOWNLOAD_TIMEOUT_MS, }) { const startTime = Date.now(); const token = credentials?.apiKey || ""; + const fail = (status: number, error: string, logged = error) => { + logLeonardoVideoCall(provider, model, startTime, status, logged); + return { success: false, status, error }; + }; const res = await fetch(providerConfig.baseUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, @@ -76,31 +146,11 @@ export async function handleLeonardoVideoGeneration({ }); if (!res.ok) { const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; + return fail(res.status, errorText, errorText.slice(0, 500)); } const { motionVideoGenerationJob } = await res.json(); const genId = motionVideoGenerationJob?.generationId; - if (!genId) { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "No generation ID returned", - }).catch(() => {}); - return { success: false, status: 502, error: "No generation ID returned" }; - } + if (!genId) return fail(502, "No generation ID returned"); const deadline = Date.now() + 300000; while (Date.now() < deadline) { const status = await pollJsonAfter( @@ -111,49 +161,14 @@ export async function handleLeonardoVideoGeneration({ ); if (status === POLL_ABORTED) return abortedVideoResult(); const gen = status.generations_by_pk || status; - if (gen.status === "COMPLETE") { - const videoUrl = leonardoVideoUrl(gen); - if (videoUrl) { - const videoRes = await fetch(videoUrl); - const buf = await videoRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp4" }], - }, - }; - } - } - if (gen.status === "FAILED") { - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation failed", - }).catch(() => {}); - return { success: false, status: 502, error: "Leonardo video generation failed" }; + const videoUrl = gen.status === "COMPLETE" ? leonardoVideoUrl(gen) : undefined; + if (videoUrl) { + const buf = await downloadLeonardoVideo(videoUrl, signal, downloadTimeoutMs); + return videoDownloadResult(buf, fail, () => + logLeonardoVideoCall(provider, model, startTime, 200) + ); } + if (gen.status === "FAILED") return fail(502, "Leonardo video generation failed"); } - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo video generation timed out", - }).catch(() => {}); - return { success: false, status: 504, error: "Leonardo video generation timed out" }; + return fail(504, "Leonardo video generation timed out"); } diff --git a/tests/unit/leonardo-image-model-id.test.ts b/tests/unit/leonardo-image-model-id.test.ts index 02496791b59f..ac431c5289ed 100644 --- a/tests/unit/leonardo-image-model-id.test.ts +++ b/tests/unit/leonardo-image-model-id.test.ts @@ -6,23 +6,30 @@ */ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-model-id-")); +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-model-id-")); +process.env.DATA_DIR = TEST_DATA_DIR; -const { handleLeonardoImageGeneration } = +const { handleLeonardoImageGeneration, resolveLeonardoImageSize } = await import("../../open-sse/handlers/imageGeneration/providers/leonardo.ts"); const BASE_URL = "https://cloud.leonardo.ai/api/rest/v1/generations"; const PHOENIX_1_0 = "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3"; const SDXL_1_0 = "16e7060a-803e-4df3-97ee-edcfa5dc9cc8"; +const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + const originalFetch = globalThis.fetch; -test.after(() => { +test.after(async () => { globalThis.fetch = originalFetch; + await waitForCallLogSaves(1_000); + resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); function json(payload: unknown) { @@ -32,13 +39,16 @@ function json(payload: unknown) { }); } -/** Fake Leonardo: records the submitted modelId and completes the generation on first poll. */ -async function submittedModelId(model: string): Promise { - let modelId: unknown; +/** Fake Leonardo: records the submitted body and completes the generation on first poll. */ +async function submitted( + model: string, + body: Record = {} +): Promise> { + let sent: Record = {}; globalThis.fetch = (async (url: unknown, options?: RequestInit) => { const href = String(url); if (href === BASE_URL && options?.method === "POST") { - modelId = JSON.parse(String(options.body)).modelId; + sent = JSON.parse(String(options.body)); return json({ sdGenerationJob: { generationId: "gen-1" } }); } if (href === `${BASE_URL}/gen-1`) { @@ -57,13 +67,17 @@ async function submittedModelId(model: string): Promise { model, provider: "leonardo", providerConfig: { baseUrl: BASE_URL }, - body: { prompt: "a red apple" }, + body: { prompt: "a red apple", ...body }, credentials: { apiKey: "test-key" }, log: null, pollIntervalMs: 0, }); assert.equal(result.success, true); - return modelId; + return sent; +} + +async function submittedModelId(model: string): Promise { + return (await submitted(model)).modelId; } test("the phoenix catalog id is sent as the Phoenix 1.0 model UUID", async () => { @@ -82,3 +96,17 @@ test("a model UUID passes through unchanged, so any Leonardo model can be used", test("no model falls back to Phoenix 1.0", async () => { assert.equal(await submittedModelId(""), PHOENIX_1_0); }); + +test("an OpenAI-style size is sent as Leonardo's width and height", async () => { + const body = await submitted("phoenix", { size: "576x1024" }); + assert.deepEqual([body.width, body.height], [576, 1024]); +}); + +test("explicit width/height win over size, and a missing size defaults to 1024", () => { + assert.deepEqual(resolveLeonardoImageSize({ size: "576x1024", width: 768 }), { + width: 768, + height: 1024, + }); + assert.deepEqual(resolveLeonardoImageSize({}), { width: 1024, height: 1024 }); + assert.deepEqual(resolveLeonardoImageSize({ size: "auto" }), { width: 1024, height: 1024 }); +}); diff --git a/tests/unit/leonardo-video-generation.test.ts b/tests/unit/leonardo-video-generation.test.ts index 4c61cb8b0a00..f96c4d8978b0 100644 --- a/tests/unit/leonardo-video-generation.test.ts +++ b/tests/unit/leonardo-video-generation.test.ts @@ -7,12 +7,13 @@ */ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-video-")); +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-leonardo-video-")); +process.env.DATA_DIR = TEST_DATA_DIR; const { handleLeonardoVideoGeneration } = await import("../../open-sse/handlers/videoGeneration/leonardoHandler.ts"); @@ -23,9 +24,15 @@ const SUBMIT_URL = "https://cloud.leonardo.ai/api/rest/v1/generations-text-to-vi const STATUS_URL = "https://cloud.leonardo.ai/api/rest/v1/generations"; const MP4 = new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112]); +const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + const originalFetch = globalThis.fetch; -test.after(() => { +test.after(async () => { globalThis.fetch = originalFetch; + await waitForCallLogSaves(1_000); + resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); function json(payload: unknown) { @@ -36,7 +43,19 @@ function json(payload: unknown) { } /** Fake Leonardo: records the submit request and completes the job on the first poll. */ -async function generate(model: string, body: Record = {}) { +async function generate( + model: string, + body: Record = {}, + { + fetchVideo = async () => new Response(MP4), + signal = null, + downloadTimeoutMs, + }: { + fetchVideo?: (options?: RequestInit) => Promise; + signal?: AbortSignal | null; + downloadTimeoutMs?: number; + } = {} +) { let submitted: { url: string; body: Record } | null = null; globalThis.fetch = (async (url: unknown, options?: RequestInit) => { const href = String(url); @@ -57,7 +76,7 @@ async function generate(model: string, body: Record = {}) { }, }); } - if (href === "https://cdn.leonardo.ai/video.mp4") return new Response(MP4); + if (href === "https://cdn.leonardo.ai/video.mp4") return fetchVideo(options); if (href === "https://cdn.leonardo.ai/thumb.jpg") return new Response("jpeg"); throw new Error(`unexpected fetch ${href}`); }) as typeof fetch; @@ -69,7 +88,9 @@ async function generate(model: string, body: Record = {}) { body: { prompt: "a paper boat drifting on a pond", ...body }, credentials: { apiKey: "test-key" }, log: null, + signal, pollIntervalMs: 0, + ...(downloadTimeoutMs ? { downloadTimeoutMs } : {}), }); return { result, submitted }; } @@ -124,3 +145,51 @@ test("the catalog lists the Motion 2.0 models", () => { ["motion-2.0", "motion-2.0-fast"] ); }); + +/** A CDN response that never arrives; it rejects only when the request's signal aborts. */ +function stalledDownload(options?: RequestInit): Promise { + return new Promise((_, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason)); + }); +} + +test("a failed mp4 download is an upstream error, not a corrupt video", async () => { + const { result } = await generate( + "motion-2.0-fast", + {}, + { + fetchVideo: async () => new Response("AccessDenied", { status: 403 }), + } + ); + assert.equal(result.success, false); + assert.equal(result.status, 502); +}); + +test("a client abort during the mp4 download stops it", async () => { + const controller = new AbortController(); + const { result } = await generate( + "motion-2.0-fast", + {}, + { + signal: controller.signal, + fetchVideo: (options) => { + setTimeout(() => controller.abort(), 5); + return stalledDownload(options); + }, + } + ); + assert.equal(result.status, 499); +}); + +test("a stalled mp4 download times out", async () => { + const { result } = await generate( + "motion-2.0-fast", + {}, + { + downloadTimeoutMs: 20, + fetchVideo: stalledDownload, + } + ); + assert.equal(result.success, false); + assert.equal(result.status, 504); +});