diff --git a/src/app/api/v1/_shared/mediaGenerationRoute.ts b/src/app/api/v1/_shared/mediaGenerationRoute.ts index 1947bccef7..6d756c9987 100644 --- a/src/app/api/v1/_shared/mediaGenerationRoute.ts +++ b/src/app/api/v1/_shared/mediaGenerationRoute.ts @@ -14,8 +14,22 @@ type MediaModelListEntry = { }; type MediaGenerationResult = - | { success: true; data: unknown } - | { success: false; error: unknown; status: number }; + { success: true; data: unknown } | { success: false; error: unknown; status: number }; + +type MediaGenerationFailure = Extract; + +export type MediaGenerationResultLike = { + success: boolean; + data?: unknown; + error?: unknown; + status?: number; +}; + +export function isMediaGenerationFailure( + result: MediaGenerationResultLike +): result is MediaGenerationFailure { + return result.success === false && "error" in result && typeof result.status === "number"; +} type MediaGenerationBody = { model: string; @@ -24,8 +38,7 @@ type MediaGenerationBody = { } & Record; type ValidatedMediaGenerationBody = - | { state: "ok"; body: MediaGenerationBody } - | { state: "invalid"; response: Response }; + { state: "ok"; body: MediaGenerationBody } | { state: "invalid"; response: Response }; export function mediaGenerationOptionsResponse() { return new Response(null, { @@ -128,6 +141,12 @@ export function failedMediaGenerationResponse( result: MediaGenerationResult, fallbackMessage: string ) { + if (!isMediaGenerationFailure(result)) { + return new Response(JSON.stringify(toJsonErrorPayload(undefined, fallbackMessage)), { + headers: { "Content-Type": "application/json" }, + }); + } + const errorPayload = toJsonErrorPayload(result.error, fallbackMessage); return new Response(JSON.stringify(errorPayload), { status: result.status, diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 9b9e9f2d13..7a3cfb4481 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -16,11 +16,13 @@ import { } from "@/app/api/v1/_shared/rateLimit"; import { failedMediaGenerationResponse, + isMediaGenerationFailure, mediaGenerationOptionsResponse, promptRequiredResponse, readMediaGenerationBody, successfulMediaGenerationResponse, } from "@/app/api/v1/_shared/mediaGenerationRoute"; +import type { MediaGenerationResultLike } from "@/app/api/v1/_shared/mediaGenerationRoute"; import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalog"; export const dynamic = "force-dynamic"; @@ -118,21 +120,21 @@ async function postHandler(request, context) { credentials = await resolveLocalOverrideCredentials(provider); } - const result = await handleVideoGeneration({ body, credentials, log }); + const result: MediaGenerationResultLike = await handleVideoGeneration({ body, credentials, log }); - if (result.success) { - await clearRecoveredProviderState(credentials); - return successfulMediaGenerationResponse({ - result, - billingMode: "video", - provider, - model: body.model, - startTime, - duration: body.duration, - }); + if (isMediaGenerationFailure(result)) { + return failedMediaGenerationResponse(result, "Video generation provider error"); } - return failedMediaGenerationResponse(result, "Video generation provider error"); + await clearRecoveredProviderState(credentials); + return successfulMediaGenerationResponse({ + result: { data: result.data }, + billingMode: "video", + provider, + model: body.model, + startTime, + duration: body.duration, + }); } export const POST = withInjectionGuard(postHandler); diff --git a/tests/unit/media-cost-headers-handlers.test.ts b/tests/unit/media-cost-headers-handlers.test.ts index 95af65d78b..341c2871c7 100644 --- a/tests/unit/media-cost-headers-handlers.test.ts +++ b/tests/unit/media-cost-headers-handlers.test.ts @@ -16,6 +16,7 @@ const rerankHandler = await import("../../open-sse/handlers/rerank.ts"); const moderationHandler = await import("../../open-sse/handlers/moderations.ts"); const speechRoute = await import("../../src/app/api/v1/audio/speech/route.ts"); const transcriptionRoute = await import("../../src/app/api/v1/audio/transcriptions/route.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); const originalFetch = globalThis.fetch; @@ -33,6 +34,33 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); +test("v1 video generation failure preserves provider status and error payload", async () => { + globalThis.fetch = (async (url: unknown) => { + assert.equal(String(url), "http://localhost:7860/animatediff/v1/generate"); + return new Response("provider busy", { status: 503 }); + }) 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: "sdwebui/animatediff-webui", + prompt: "media failure test", + }), + }) + ); + + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { + error: { + message: "provider busy", + type: "upstream_error", + code: "upstream_error", + }, + }); +}); + // Shared assertions: every successful media Response must carry the // X-OmniRoute-* cost telemetry headers (parity with chat/embeddings). // Cost may legitimately be 0 (free / unpriced modality) — formatOmniRouteCost