import { createLogger } from "@/lib/observability/logger"; import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import type { ChatMessage } from "@/lib/types"; import { convertMessagesWithImages } from "./image-media"; const chatApiLogger = createLogger("chat.imageFailover"); export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([ "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "grsai-gpt-image", "volcano-vision", "gemini", "huanke-gemini", ]); const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000; const imageFailoverModelCooldown = new Map(); export 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); } } export 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") ); } export 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; } export function setImageFallbackModelCooldown(modelId: string, reason: string): void { imageFailoverModelCooldown.set(modelId, { until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS, reason: reason.slice(0, 500), }); } export function isImageFailoverExcludedModel(model: { excludeFromFailover?: boolean } | null | undefined) { return Boolean(model?.excludeFromFailover); } export type ImageFallbackCandidate = { model: any; provider: any; configured: boolean; sameProvider: boolean; }; export function getImageFailoverConvertProvider(providerType?: string | null) { switch (providerType) { case "bfl-image": case "ruxa-image": case "fal-image": case "openai-image": case "stepfun-image": case "midjourney": case "runway": case "krea": case "gemini": case "huanke-gemini": return providerType; case "grsai-gpt-image": return "gemini"; case "volcano-vision": return "volcano"; default: return "alibaba-image"; } } export function buildImageFallbackCandidates( allModels: Array<{ model: any; provider: any }>, options: { currentProviderId?: string; currentModelId?: string; currentModelKey?: string; configuredFallbackIds?: string[]; } ) { const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options; const configuredRefs = configuredFallbackIds .map((item) => String(item || "").trim()) .filter(Boolean); const byModelRef = new Map(); const isEligible = (item: { model: any; provider: any }, sameProvider: boolean) => { if ( !item?.model?.supportsImageGen || !item?.model?.isEnabled || item?.model?.isSubModel || isImageFailoverExcludedModel(item?.model) || item?.model?.modelId === currentModelKey || item?.model?.id === currentModelId ) { return false; } const providerType = String(item?.provider?.type || ""); if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { return false; } if (isImageFallbackModelCoolingDown(item.model.id)) { const cooldown = imageFailoverModelCooldown.get(item.model.id); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`); return false; } return true; }; allModels.forEach((item) => { if (!item?.model) return; byModelRef.set(String(item.model.id), item); if (item.model.modelId) { byModelRef.set(String(item.model.modelId), item); } }); const candidates: ImageFallbackCandidate[] = []; const seenModelIds = new Set(); const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => { if (!item?.model) return; const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId); if (!isEligible(item, sameProvider)) return; const modelId = String(item.model.id || ""); if (!modelId || seenModelIds.has(modelId)) return; seenModelIds.add(modelId); candidates.push({ model: item.model, provider: item.provider, configured, sameProvider, }); }; configuredRefs.forEach((modelRef) => { pushCandidate(byModelRef.get(modelRef), true); }); if (currentProviderId) { allModels .filter((item) => item.provider?.id === currentProviderId) .forEach((item) => pushCandidate(item, false)); } return candidates; } export async function generateImageWithFailoverCandidate(params: { candidate: ImageFallbackCandidate; messages: ChatMessage[]; imageParams: any; fallbackImageParams: any; }) { const { candidate, messages, imageParams, fallbackImageParams } = params; const providerType = String(candidate.provider?.type || ""); const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || ""); if ( providerType === "fal-image" && isFalGptImage2EditModelId(candidateRuntimeModelId) ) { throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移"); } const candidateLanguageModel = await getLanguageModelById(candidate.model.id); if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`); } const candidateMessages = await convertMessagesWithImages( messages, getImageFailoverConvertProvider(providerType) ); const candidateResult = await (candidateLanguageModel as any).doGenerate({ messages: candidateMessages, imageParams: imageParams || fallbackImageParams, modelDisplayName: candidate.model.displayName, overrideModelId: candidate.model.modelId, }); if (candidateResult?.finishReason === "error") { throw new Error(candidateResult.text || "图片生成失败"); } return { text: candidateResult.text || "图片生成失败", usedModelId: String(candidate.model.modelId || candidate.model.id || ""), displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""), }; } export 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 || isImageFailoverExcludedModel(m?.model) ) { 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); chatApiLogger.info(`[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); }); }