261 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
}
|