diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts index 9e624631c..2a11501db 100644 --- a/app/api/external/image/generate/route.ts +++ b/app/api/external/image/generate/route.ts @@ -18,7 +18,7 @@ import { } from "@/lib/external-image-api/service"; export const runtime = "nodejs"; -export const maxDuration = 120; +export const maxDuration = 360; const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__"; const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2; @@ -300,6 +300,8 @@ export async function POST(request: Request) { const payload = await parseRequestPayload(request); const candidateModels = await buildExternalImageApiModelCandidates({ referenceImageCount: payload.referenceImages?.length || 0, + aspectRatio: payload.aspectRatio, + imageSize: payload.imageSize, }); const selectedModel = candidateModels[0]; const ownerUserId = await resolveExternalImageApiOwnerUserId( diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 2c6c57b38..426b79ce9 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -791,11 +791,6 @@ async function sendGeminiRequest( prompt: prompt, }; - // 添加参考图参数 - if (processedUrls.length > 0) { - requestBody.urls = processedUrls; - } - if (isGptImageModel) { const requestedAspectRatio = imageParams?.aspectRatio || "auto"; const inferredAspectRatio = @@ -805,16 +800,25 @@ async function sendGeminiRequest( const resolvedAspectRatio = inferredAspectRatio || requestedAspectRatio; const normalizedRequestedAspectRatio = normalizeAspectRatio(requestedAspectRatio); const resolvedSize = resolveGrsaiGptImageSize(resolvedAspectRatio, imageParams?.imageSize); + const shouldPassAspectRatio = + Boolean(normalizedRequestedAspectRatio && normalizedRequestedAspectRatio !== "1:1"); + + if (normalizedRequestedAspectRatio === "1:1") { + logger.warn( + `${apiLogPrefix} Received 1:1 aspect ratio for GPT image on ${baseURL}. Skip aspect ratio params for compatibility.`, + ); + } // GPT-image(如 gpt-image-2-vip)在不同网关中参数差异较大: // - 有的网关只识别 size; // - 有的网关同时支持 aspectRatio/aspect_ratio; // - 有的会读取 imageSize/image_size; - // 为避免接口兼容问题,保留核心 size 后补齐兼容字段。 + // 兼容不同网关:优先使用 OpenAI 原生 size;对容易兼容失败的比例(如 1:1)不再附带 aspect 变体参数。 requestBody.size = resolvedSize; requestBody.imageSize = imageParams?.imageSize; requestBody.image_size = imageParams?.imageSize; - if (normalizedRequestedAspectRatio) { + + if (shouldPassAspectRatio) { requestBody.aspectRatio = normalizedRequestedAspectRatio; requestBody.aspect_ratio = normalizedRequestedAspectRatio; requestBody.aspectRatioRaw = requestedAspectRatio; @@ -822,11 +826,8 @@ async function sendGeminiRequest( requestBody.shutProgress = false; if (processedUrls.length > 0) { - requestBody.images = processedUrls; - if (processedUrls.length === 1) { - requestBody.image = processedUrls[0]; - } - requestBody.imageUrls = processedUrls; + requestBody.image = processedUrls[0]; + requestBody.imageUrls = [processedUrls[0]]; } logger.debug( `${apiLogPrefix} GPT image payload mapping: requestedAspectRatio=${requestedAspectRatio}, normalizedRequestedAspectRatio=${normalizedRequestedAspectRatio || "n/a"}, resolvedAspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"}, resolvedSize=${resolvedSize}` @@ -839,6 +840,10 @@ async function sendGeminiRequest( ); } else { // 添加 Nano Banana 特定参数 + if (processedUrls.length > 0) { + requestBody.urls = processedUrls; + } + if (imageParams?.aspectRatio) { requestBody.aspectRatio = imageParams.aspectRatio; } diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts index 859c974ef..7546e447b 100644 --- a/lib/external-image-api/service.ts +++ b/lib/external-image-api/service.ts @@ -6,6 +6,7 @@ import { db } from "@/lib/db"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { user } from "@/lib/db/schema"; import { generateDummyPassword } from "@/lib/db/utils"; +import { createLogger } from "@/lib/observability/logger"; import { DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL, EXTERNAL_IMAGE_API_CONFIG_KEY, @@ -55,6 +56,8 @@ type ExternalImageApiGeneratedImage = { imageSize: string; }; +const logger = createLogger("external-image-api"); + function normalizeText(value: unknown) { return String(value || "").trim(); } @@ -367,6 +370,120 @@ function findExternalImageApiModelMatch( ); } +function normalizeImageRatioForCandidateFilter(value: unknown) { + const normalized = normalizeText(value).toLowerCase(); + if (!normalized || normalized === "auto") return null; + const normalizedParts = normalized + .split(":") + .map((part) => part.trim()) + .filter(Boolean); + + if (normalizedParts.length !== 2) { + return null; + } + + const [widthRaw, heightRaw] = normalizedParts; + if (!/^\d+$/.test(widthRaw) || !/^\d+$/.test(heightRaw)) { + return null; + } + + return `${Number(widthRaw)}:${Number(heightRaw)}`; +} + +function shouldFilterCandidateForExternalImageApiRequest( + entry: ImageModelEntry, + params: { + referenceImageCount: number; + aspectRatio?: string | null; + imageSize?: string | null; + }, +) { + const normalizedReferenceCount = Math.max(0, params.referenceImageCount); + const providerType = normalizeText(entry.provider.type).toLowerCase(); + const modelRuntimeId = normalizeText(entry.model.modelId).toLowerCase(); + const modelDisplayName = normalizeText(entry.model.displayName).toLowerCase(); + const normalizedAspectRatio = normalizeImageRatioForCandidateFilter(params.aspectRatio); + + const supportsReferenceImageEdit = supportsImageEditForReferenceCount( + entry, + normalizedReferenceCount, + ); + if (!supportsReferenceImageEdit) { + return { filtered: false }; + } + + if (normalizedReferenceCount <= 0) { + return { filtered: false }; + } + + if ( + providerType === "grsai-gpt-image" && + (modelRuntimeId.includes("gpt-image-2") || modelDisplayName.includes("gpt-image-2")) + ) { + if (normalizedAspectRatio === "1:1") { + return { + filtered: true, + reason: + "gpt-image-2 在某些 grsai 网关不兼容 1:1 参数,改走其他候选模型", + }; + } + } + + return { filtered: false }; +} + +function filterCompatibleCandidates( + candidates: ImageModelEntry[], + params: { + referenceImageCount: number; + aspectRatio?: string | null; + imageSize?: string | null; + }, +) { + if (!candidates.length) { + return candidates; + } + + const acceptedCandidates = [] as ImageModelEntry[]; + const filteredCandidates: ImageModelEntry[] = []; + let reason = ""; + + for (const candidate of candidates) { + const filterDecision = shouldFilterCandidateForExternalImageApiRequest( + candidate, + params, + ); + if (filterDecision.filtered) { + filteredCandidates.push(candidate); + if (!reason) { + reason = filterDecision.reason || "参数兼容性过滤"; + } + continue; + } + acceptedCandidates.push(candidate); + } + + if (acceptedCandidates.length === 0) { + logger.debug( + [ + "[External Image API] No compatible candidate after request-aware filtering,", + `fallback to unfiltered candidates (referenceImageCount=${params.referenceImageCount},`, + `aspectRatio=${params.aspectRatio || "auto"}, imageSize=${params.imageSize || "default"},`, + `filteredCount=${filteredCandidates.length}, reason=${reason || "none"})`, + ].join(" "), + ); + return candidates; + } + + if (filteredCandidates.length > 0) { + logger.debug( + `[External Image API] Filtered ${filteredCandidates.length} request-incompatible candidates (referenceImageCount=${params.referenceImageCount}, aspectRatio=${params.aspectRatio || "auto"}, imageSize=${params.imageSize || "default")} reason=${reason || "compatibility"}`, + ); + } + + return acceptedCandidates; +} + export async function ensureHostedImageMarkdown( markdown: string, imageSize = "2K", @@ -582,6 +699,8 @@ export async function resolveExternalImageApiModel( export async function buildExternalImageApiModelCandidates(params?: { modelRef?: string | null; referenceImageCount?: number; + aspectRatio?: string | null; + imageSize?: string | null; }) { const referenceImageCount = Math.max(0, params?.referenceImageCount ?? 1); const allModels = sortExternalImageApiModels( @@ -602,13 +721,19 @@ export async function buildExternalImageApiModelCandidates(params?: { supportsImageEditForReferenceCount(entry, referenceImageCount), ); + const compatibleModels = filterCompatibleCandidates(supportedModels, { + referenceImageCount, + aspectRatio: params?.aspectRatio || null, + imageSize: params?.imageSize || null, + }); + if (!supportedModels.length) { throw new Error("当前没有启用可用于图生图的模型"); } const explicitMatch = findExternalImageApiModelMatch(allModels, params?.modelRef); if (!params?.modelRef) { - return supportedModels; + return compatibleModels; } if (!explicitMatch) {