From 947495ccfb54eb29db1d9eb5fd669a31e7bc75e6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Feb 2026 00:00:24 +0800 Subject: [PATCH] fix: harden image failover candidate selection --- app/(chat)/api/chat/route.ts | 158 +++++++++++++++++++++++++--------- lib/ai/providers/fal-image.ts | 8 +- lib/ai/providers/fal-video.ts | 8 +- 3 files changed, 132 insertions(+), 42 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 8d18e15c7..1b68d533f 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -222,6 +222,96 @@ type AgentVideoPlan = { recommendedVideoParams?: AgentRecommendedVideoParams; }; +const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([ + "alibaba-image", + "ruxa-image", + "fal-image", + "volcano-vision", + "gemini", + "huanke-gemini", +]); + +const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000; +const imageFailoverModelCooldown = new Map(); + +function normalizeFailoverErrorMessage(error: unknown): string { + if (!error) return ""; + if (typeof error === "string") return error; + if (error instanceof Error) return error.message || String(error); + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function shouldCooldownImageFallbackModel(errorMessage: string): boolean { + const msg = errorMessage.toLowerCase(); + return ( + msg.includes("model not found") || + msg.includes("model_not_found") || + msg.includes("no available channel for model") || + msg.includes("unknown model") || + msg.includes("invalid model") || + msg.includes("api key is missing") || + msg.includes("invalid api key") || + msg.includes("unauthorized") || + msg.includes("cannot convert argument to a bytestring") + ); +} + +function isImageFallbackModelCoolingDown(modelId: string): boolean { + const snapshot = imageFailoverModelCooldown.get(modelId); + if (!snapshot) return false; + if (snapshot.until <= Date.now()) { + imageFailoverModelCooldown.delete(modelId); + return false; + } + return true; +} + +function setImageFallbackModelCooldown(modelId: string, reason: string): void { + imageFailoverModelCooldown.set(modelId, { + until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS, + reason: reason.slice(0, 500), + }); +} + +function getImageCrossProviderCandidates( + allModels: any[], + options: { + currentModelId: string; + currentProviderId?: string; + providerPriority: Record; + } +) { + return allModels + .filter((m) => { + if (!m?.model?.supportsImageGen || !m?.model?.isEnabled || m?.model?.isSubModel) { + return false; + } + if (!IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(m?.provider?.type || ""))) { + return false; + } + if (m.model.id === options.currentModelId) return false; + if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; + if (isImageFallbackModelCoolingDown(m.model.id)) { + const cooldown = imageFailoverModelCooldown.get(m.model.id); + console.log( + `[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}` + ); + return false; + } + return true; + }) + .sort((a, b) => { + const left = options.providerPriority[a.provider.type as string] ?? 99; + const right = options.providerPriority[b.provider.type as string] ?? 99; + if (left !== right) return left - right; + return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); + }); +} + function hasUserCustomizedImageParams(params?: PostRequestBody["imageParams"]): boolean { if (!params) return false; const customizedAspectRatio = @@ -2363,27 +2453,17 @@ export async function POST(request: Request) { if (!fallbackSuccess) { const providerPriority: Record = { "volcano-vision": 0, - gemini: 1, - "huanke-gemini": 2, - "alibaba-image": 3, - "ruxa-image": 4, - "fal-image": 5, + "alibaba-image": 1, + "ruxa-image": 2, + "fal-image": 3, + gemini: 4, + "huanke-gemini": 5, }; - const crossProviderCandidates = allModels - .filter((m) => { - if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) { - return false; - } - if (m.model.id === finalModelId) return false; - if (m.provider.id === alibabaModelConfig?.providerId) return false; - return true; - }) - .sort((a, b) => { - const left = providerPriority[a.provider.type as string] ?? 99; - const right = providerPriority[b.provider.type as string] ?? 99; - if (left !== right) return left - right; - return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); - }); + const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { + currentModelId: finalModelId, + currentProviderId: alibabaModelConfig?.providerId, + providerPriority, + }); for (const candidate of crossProviderCandidates) { try { @@ -2439,6 +2519,10 @@ export async function POST(request: Request) { ); break; } catch (crossError) { + const crossErrorMessage = normalizeFailoverErrorMessage(crossError); + if (shouldCooldownImageFallbackModel(crossErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); + } console.error( `[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError @@ -2713,28 +2797,18 @@ export async function POST(request: Request) { } if (!fallbackSuccess) { const providerPriority: Record = { - gemini: 0, - "huanke-gemini": 1, - "alibaba-image": 2, - "ruxa-image": 3, - "fal-image": 4, + "alibaba-image": 0, + "ruxa-image": 1, + "fal-image": 2, + gemini: 3, + "huanke-gemini": 4, "volcano-vision": 5, }; - const crossProviderCandidates = allModels - .filter((m) => { - if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) { - return false; - } - if (m.model.id === finalModelId) return false; - if (m.provider.id === volcanoModelConfig?.providerId) return false; - return true; - }) - .sort((a, b) => { - const left = providerPriority[a.provider.type as string] ?? 99; - const right = providerPriority[b.provider.type as string] ?? 99; - if (left !== right) return left - right; - return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); - }); + const crossProviderCandidates = getImageCrossProviderCandidates(allModels, { + currentModelId: finalModelId, + currentProviderId: volcanoModelConfig?.providerId, + providerPriority, + }); for (const candidate of crossProviderCandidates) { try { @@ -2790,6 +2864,10 @@ export async function POST(request: Request) { ); break; } catch (crossError) { + const crossErrorMessage = normalizeFailoverErrorMessage(crossError); + if (shouldCooldownImageFallbackModel(crossErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); + } console.error( `[Chat API] ❌ Volcano cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 9a113bd36..9187634f0 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -29,7 +29,13 @@ const DEFAULT_SAFETY_TOLERANCE = "4"; const DEFAULT_TIMEOUT_MS = 180000; function normalizeFalCredentials(apiKey: string) { - return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, ""); + const normalized = apiKey.replace(/\u00a0/g, " ").trim(); + const stripped = normalized.replace(/^bearer\s+/i, "").replace(/^key\s+/i, ""); + const kvMatch = stripped.match(/^(?:fal[_-]?key|api[_-]?key|token)\s*[::]\s*(.+)$/i); + if (kvMatch?.[1]) { + return kvMatch[1].trim(); + } + return stripped; } function normalizeUrlForCompare(url: string) { diff --git a/lib/ai/providers/fal-video.ts b/lib/ai/providers/fal-video.ts index 5567dc249..7b7476e3a 100644 --- a/lib/ai/providers/fal-video.ts +++ b/lib/ai/providers/fal-video.ts @@ -23,7 +23,13 @@ const DEFAULT_SHOT_TYPE = "customize"; const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; function normalizeFalCredentials(apiKey: string) { - return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, ""); + const normalized = apiKey.replace(/\u00a0/g, " ").trim(); + const stripped = normalized.replace(/^bearer\s+/i, "").replace(/^key\s+/i, ""); + const kvMatch = stripped.match(/^(?:fal[_-]?key|api[_-]?key|token)\s*[::]\s*(.+)$/i); + if (kvMatch?.[1]) { + return kvMatch[1].trim(); + } + return stripped; } function normalizeDuration(value?: number) {