diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 6865d963c..b1158ba78 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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> | 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> | 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(); 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 = { + "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 || diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index cb48a9e2b..0333a7223 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -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); diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index a06bec3aa..fdd778bea 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -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") ||