ai-chatbot/app/(chat)/api/chat/_lib/image-failover.ts
2026-06-06 16:39:08 +08:00

261 lines
8.4 KiB
TypeScript

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<string, { until: number; reason: string }>();
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<string, { model: any; provider: any }>();
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<string>();
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,
imageGenConfig: candidate.model.imageGenConfig || null,
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<string, number>;
}
) {
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);
});
}