fix: record image model failures and broaden failover
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 13:47:59 +08:00
parent 4c86e01956
commit 71837cc547
3 changed files with 177 additions and 3 deletions

View File

@ -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 ||

View File

@ -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);

View File

@ -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") ||