fix: record image model failures and broaden failover
This commit is contained in:
parent
4c86e01956
commit
71837cc547
@ -143,6 +143,10 @@ import {
|
||||
parseImageAltMetadata,
|
||||
saveGeneratedImages,
|
||||
} from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import {
|
||||
recordAiModelHealthFailure,
|
||||
recordAiModelHealthSuccess,
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
|
||||
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
|
||||
import { resolveCanvasAgentClarificationGate } from "@/lib/ai/runtime/middlewares/canvas-agent-clarification-gate";
|
||||
@ -1381,6 +1385,7 @@ const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
"grsai-gpt-image",
|
||||
"volcano-vision",
|
||||
"gemini",
|
||||
"huanke-gemini",
|
||||
@ -1456,6 +1461,8 @@ function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "gemini":
|
||||
case "huanke-gemini":
|
||||
return providerType;
|
||||
case "grsai-gpt-image":
|
||||
return "gemini";
|
||||
case "volcano-vision":
|
||||
return "volcano";
|
||||
default:
|
||||
@ -2814,6 +2821,26 @@ function resolveModelDisplayNameByModelId(
|
||||
return String(match?.model?.displayName || "");
|
||||
}
|
||||
|
||||
function resolveModelEntryByRuntimeModelId(
|
||||
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>> | undefined,
|
||||
modelId: string | undefined,
|
||||
fallbackRef?: string,
|
||||
) {
|
||||
const candidates = [modelId, fallbackRef]
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean);
|
||||
if (!candidates.length || !models?.length) return null;
|
||||
|
||||
return (
|
||||
models.find((item) =>
|
||||
candidates.some(
|
||||
(candidate) =>
|
||||
item.model.modelId === candidate || item.model.id === candidate,
|
||||
),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelRefByRuntimeModelId(
|
||||
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>> | undefined,
|
||||
modelId: string | undefined,
|
||||
@ -10148,6 +10175,11 @@ export async function POST(request: Request) {
|
||||
currentModelConfig?.fallbackModelId2,
|
||||
].filter(Boolean) as string[];
|
||||
const allModels = await getEnabledAiModelsWithProvider();
|
||||
const currentModelEntry = resolveModelEntryByRuntimeModelId(
|
||||
allModels,
|
||||
actualModelId,
|
||||
finalModelId,
|
||||
);
|
||||
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
|
||||
currentProviderId: currentModelConfig?.providerId,
|
||||
currentModelId: currentModelConfig?.id,
|
||||
@ -10239,6 +10271,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
let imageMarkdown = ""; // 初始化为空字符串
|
||||
let usedModelId = actualModelId;
|
||||
const generationStartedAt = Date.now();
|
||||
|
||||
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
|
||||
writeAssistantTextChunk(
|
||||
@ -10265,11 +10298,33 @@ export async function POST(request: Request) {
|
||||
imageMarkdown = await tryGenerateImage(actualModelId, false);
|
||||
} catch (primaryError) {
|
||||
console.error("[Chat API] Primary model failed:", primaryError);
|
||||
await recordAiModelHealthFailure({
|
||||
model: currentModelEntry?.model || currentModelConfig,
|
||||
provider: currentModelEntry?.provider,
|
||||
error: primaryError,
|
||||
context: "chat_gemini_primary",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
runtimeModelId: actualModelId,
|
||||
finalModelId,
|
||||
providerType: (languageModel as any).provider,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
imageCount:
|
||||
effectiveImageParams?.imageCount ||
|
||||
defaultImageQualityParams.imageCount ||
|
||||
1,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
console.warn("[Chat API] Record Gemini primary health failure failed:", healthError);
|
||||
});
|
||||
|
||||
// 🆕 尝试备用模型(支持跨服务商切换)
|
||||
let fallbackSuccess = false;
|
||||
const triedFallbackModelIds = new Set<string>();
|
||||
for (const candidate of fallbackCandidates) {
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
console.log(
|
||||
`[Chat API] 🔄 Trying fallback model: ${candidate.model.displayName} (${candidate.provider.type})`
|
||||
);
|
||||
@ -10294,12 +10349,101 @@ export async function POST(request: Request) {
|
||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
await recordAiModelHealthFailure({
|
||||
model: candidate.model,
|
||||
provider: candidate.provider,
|
||||
error: fallbackError,
|
||||
context: "chat_gemini_fallback",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
primaryModelId: actualModelId,
|
||||
fallbackModelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
console.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
|
||||
});
|
||||
console.error(
|
||||
`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`,
|
||||
fallbackError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackSuccess) {
|
||||
const providerPriority: Record<string, number> = {
|
||||
"volcano-vision": 0,
|
||||
"alibaba-image": 1,
|
||||
"bfl-image": 2,
|
||||
"ruxa-image": 3,
|
||||
"fal-image": 4,
|
||||
"openai-image": 5,
|
||||
"stepfun-image": 6,
|
||||
"grsai-gpt-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
gemini: 9,
|
||||
"huanke-gemini": 10,
|
||||
};
|
||||
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||
currentModelId: finalModelId,
|
||||
currentProviderId: currentModelConfig?.providerId,
|
||||
providerPriority,
|
||||
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
|
||||
|
||||
for (const candidate of crossProviderCandidates) {
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
console.log(
|
||||
`[Chat API] 🔁 Gemini cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})`
|
||||
);
|
||||
const fallbackResult = await generateImageWithFailoverCandidate({
|
||||
candidate,
|
||||
messages: buildMessagesForGemini(),
|
||||
imageParams: effectiveImageParams,
|
||||
fallbackImageParams: {
|
||||
aspectRatio: defaultImageQualityParams.aspectRatio,
|
||||
imageSize: defaultImageQualityParams.imageSize,
|
||||
imageCount: defaultImageQualityParams.imageCount,
|
||||
},
|
||||
});
|
||||
imageMarkdown = fallbackResult.text;
|
||||
usedModelId = fallbackResult.usedModelId;
|
||||
fallbackSuccess = true;
|
||||
console.log(
|
||||
`[Chat API] ✅ Gemini cross-provider fallback model ${fallbackResult.displayName} succeeded!`
|
||||
);
|
||||
break;
|
||||
} catch (fallbackError) {
|
||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
await recordAiModelHealthFailure({
|
||||
model: candidate.model,
|
||||
provider: candidate.provider,
|
||||
error: fallbackError,
|
||||
context: "chat_gemini_cross_fallback",
|
||||
elapsedMs: Date.now() - generationStartedAt,
|
||||
metadata: {
|
||||
primaryModelId: actualModelId,
|
||||
fallbackModelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
aspectRatio: effectiveImageParams?.aspectRatio,
|
||||
imageSize: effectiveImageParams?.imageSize,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
console.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
|
||||
});
|
||||
console.error(
|
||||
`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`,
|
||||
fallbackError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有备用模型都失败,抛出原始错误触发火山引擎故障转移
|
||||
if (!fallbackSuccess) {
|
||||
@ -10311,6 +10455,17 @@ export async function POST(request: Request) {
|
||||
console.log("[Chat API] Gemini image markdown:", imageMarkdown.substring(0, 200));
|
||||
console.log("[Chat API] Used model ID:", usedModelId);
|
||||
|
||||
const usedModelEntry = resolveModelEntryByRuntimeModelId(
|
||||
allModels,
|
||||
usedModelId,
|
||||
usedModelId === actualModelId ? finalModelId : undefined,
|
||||
);
|
||||
await recordAiModelHealthSuccess({
|
||||
model: usedModelEntry?.model || (usedModelId === actualModelId ? currentModelConfig : null),
|
||||
}).catch((healthError) => {
|
||||
console.warn("[Chat API] Record Gemini health success failed:", healthError);
|
||||
});
|
||||
|
||||
const usedModelDisplayName =
|
||||
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
|
||||
currentModelConfig?.displayName ||
|
||||
|
||||
@ -630,7 +630,13 @@ async function sendGeminiRequest(
|
||||
|
||||
// 如果失败,抛出错误(不被catch捕获)
|
||||
if (data.status === "failed") {
|
||||
const errorMsg = `图片生成失败: ${data.failure_reason || data.error || "未知错误"}`;
|
||||
const failureReason = String(data.failure_reason || "").trim();
|
||||
const providerError = String(data.error || "").trim();
|
||||
const detailParts = [failureReason, providerError].filter(
|
||||
(part, index, arr) =>
|
||||
part && arr.findIndex((candidate) => candidate === part) === index
|
||||
);
|
||||
const errorMsg = `图片生成失败: ${detailParts.join(" / ") || "未知错误"}`;
|
||||
console.error("[Gemini Image] Generation failed:", data.failure_reason, data.error);
|
||||
clearTimeout(timeoutId);
|
||||
throw new Error(errorMsg);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
aiModel,
|
||||
@ -37,7 +37,16 @@ function normalizeText(value: unknown) {
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return normalizeText(error.message);
|
||||
if (error instanceof Error) {
|
||||
const parts = [normalizeText(error.message)];
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (cause instanceof Error) {
|
||||
parts.push(normalizeText(cause.message));
|
||||
} else if (typeof cause === "string") {
|
||||
parts.push(normalizeText(cause));
|
||||
}
|
||||
return parts.filter(Boolean).join(": ");
|
||||
}
|
||||
return normalizeText(error);
|
||||
}
|
||||
|
||||
@ -124,6 +133,10 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
normalized.includes("503") ||
|
||||
normalized.includes("502") ||
|
||||
normalized.includes("504") ||
|
||||
normalized.includes("model maintenance") ||
|
||||
normalized.includes("maintenance") ||
|
||||
normalized.includes("修复中") ||
|
||||
normalized.includes("系统繁忙") ||
|
||||
normalized.includes("no available channel") ||
|
||||
normalized.includes("no supported channel") ||
|
||||
normalized.includes("service unavailable") ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user