fix(external-image): improve grsai failover compatibility
This commit is contained in:
parent
8db1afdbc0
commit
482e1ef291
4
app/api/external/image/generate/route.ts
vendored
4
app/api/external/image/generate/route.ts
vendored
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user