From e1c155be9ebae0f43fb97b56074c9342314cbd54 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 27 Aug 2026 16:31:27 +0800 Subject: [PATCH] fix(ai): fail over forbidden image requests --- app/(chat)/api/chat/_lib/image-failover.ts | 5 ++ app/(chat)/api/chat/route.ts | 77 +++++++++++++++++++++- lib/ai/providers/snapgen-image.ts | 2 +- lib/ai/runtime/model-health.ts | 21 +++++- 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts index 81d7a7cfc..98b36d687 100644 --- a/app/(chat)/api/chat/_lib/image-failover.ts +++ b/app/(chat)/api/chat/_lib/image-failover.ts @@ -91,6 +91,11 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean msg.includes("invalid api key") || msg.includes("unauthorized") || /(?:^|\D)401(?:\D|$)/.test(msg) || + msg === "forbidden" || + msg.includes("access forbidden") || + msg.includes("access denied") || + msg.includes("permission denied") || + /(?:^|\D)403(?:\D|$)/.test(msg) || msg.includes("vip98k task failed") || msg.includes("cannot convert argument to a bytestring") || msg.includes("model maintenance") || diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 71286129d..0f06fac7b 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -9635,6 +9635,11 @@ export async function POST(request: Request) { alibabaModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); + const currentModelEntry = resolveModelEntryByRuntimeModelId( + allModels, + actualModelId, + finalModelId, + ); const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: alibabaModelConfig?.providerId, currentModelId: alibabaModelConfig?.id, @@ -9880,6 +9885,7 @@ export async function POST(request: Request) { let imageMarkdown = ""; let usedModelId = actualModelId; + const generationStartedAt = Date.now(); // 🆕 图片生成真正开始时发出标记(用于前端显示占位图) writeAssistantTextChunk( @@ -9906,14 +9912,37 @@ export async function POST(request: Request) { } catch (primaryError) { chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError); + await recordAiModelHealthFailure({ + model: currentModelEntry?.model || alibabaModelConfig, + provider: currentModelEntry?.provider, + error: primaryError, + context: "chat_image_primary", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + runtimeModelId: actualModelId, + finalModelId, + providerType: (languageModel as any).provider, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + imageCount: + effectiveImageGenerationParams.imageCount || + defaultImageQualityParams.imageCount || + 1, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError); + }); + if (!shouldFailoverAiModelError(primaryError)) { chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error."); throw primaryError; } let fallbackSuccess = false; + const triedFallbackModelIds = new Set(); for (const candidate of fallbackCandidates) { try { + triedFallbackModelIds.add(String(candidate.model.id || "")); chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`); const fallbackResult = await generateImageWithFailoverCandidate({ candidate, @@ -9938,6 +9967,22 @@ 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_image_fallback", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + primaryModelId: actualModelId, + fallbackModelId: candidate.model.modelId, + providerType: candidate.provider.type, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError); + }); chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); } } @@ -9966,10 +10011,11 @@ export async function POST(request: Request) { currentModelKey: actualModelId, currentProviderId: alibabaModelConfig?.providerId, providerPriority, - }); + }).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || ""))); for (const candidate of crossProviderCandidates) { try { + triedFallbackModelIds.add(String(candidate.model.id || "")); chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`); if ( candidate.provider.type === "fal-image" && @@ -10053,6 +10099,22 @@ export async function POST(request: Request) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) { setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); } + await recordAiModelHealthFailure({ + model: candidate.model, + provider: candidate.provider, + error: crossError, + context: "chat_image_cross_fallback", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + primaryModelId: actualModelId, + fallbackModelId: candidate.model.modelId, + providerType: candidate.provider.type, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError); + }); chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError); } } @@ -10066,6 +10128,19 @@ export async function POST(request: Request) { chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length); chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId); + const usedModelEntry = resolveModelEntryByRuntimeModelId( + allModels, + usedModelId, + usedModelId === actualModelId ? finalModelId : undefined, + ); + await recordAiModelHealthSuccess({ + model: + usedModelEntry?.model || + (usedModelId === actualModelId ? alibabaModelConfig : null), + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image health success failed:", healthError); + }); + const usedModelDisplayName = resolveModelDisplayNameByModelId(allModels, usedModelId) || alibabaModelConfig?.displayName || diff --git a/lib/ai/providers/snapgen-image.ts b/lib/ai/providers/snapgen-image.ts index 3d0ff3b98..481f7ed3a 100644 --- a/lib/ai/providers/snapgen-image.ts +++ b/lib/ai/providers/snapgen-image.ts @@ -103,7 +103,7 @@ function normalizeResolution(imageSize?: string, apiFormat?: SnapgenApiFormat) { } function toSnapgenResolutionParam(resolution: string) { - return resolution.trim().toLowerCase(); + return resolution.trim().toUpperCase(); } function normalizeOutputFormat(outputFormat?: string): "png" | "jpeg" { diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index e5c6b62ec..6df4a9db4 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -139,6 +139,22 @@ export function classifyAiModelHealthFailure(error: unknown): { }; } + if ( + httpStatus === 403 || + /(?:^|\D)403(?:\D|$)/.test(normalized) || + normalized === "forbidden" || + normalized.includes("access forbidden") || + normalized.includes("access denied") || + normalized.includes("permission denied") + ) { + return { + eventType: "unavailable", + severity: "critical", + counted: true, + reason: "provider_access_forbidden", + }; + } + if ( normalized.includes("生成结果未通过替换质检") || normalized.includes("output_moderation") || @@ -323,7 +339,10 @@ export function shouldFailoverAiModelError(error: unknown) { normalized.includes("no task id") || normalized.includes("missing task id"); - if (classification.reason === "provider_authentication_failed") { + if ( + classification.reason === "provider_authentication_failed" || + classification.reason === "provider_access_forbidden" + ) { return true; }