fix(types): narrow media generation failures (#9093)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates: typecheck/complexity/cognitive/changelog/vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-222248-suite.log
This commit is contained in:
backryun
2026-08-06 10:29:55 +09:00
committed by GitHub
parent da87422d9e
commit a174ea6d33
3 changed files with 65 additions and 16 deletions

View File

@@ -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<MediaGenerationResult, { success: false }>;
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<string, unknown>;
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,

View File

@@ -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);

View File

@@ -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