feat: default external image api to img2img 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-18 16:29:46 +08:00
parent 11c2f8f423
commit cc320f6334
3 changed files with 556 additions and 66 deletions

View File

@ -6,29 +6,145 @@ import { externalImageApiRequestLog } from "@/lib/db/schema";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import {
authenticateExternalImageApiRequest,
buildExternalImageApiModelCandidates,
buildExternalImageApiKeyFingerprint,
ensureHostedImageMarkdown,
ensureHostedReferenceImageUrls,
extractGeneratedImagesFromMarkdown,
generateImagesWithModel,
generateImagesWithFailover,
getRequesterIp,
resolveExternalImageApiModel,
resolveExternalImageApiOwnerUserId,
serializeExternalImageApiModel,
uploadExternalImageApiFile,
} from "@/lib/external-image-api/service";
export const runtime = "nodejs";
export const maxDuration = 120;
const requestSchema = z.object({
model: z.string().trim().min(1).optional(),
prompt: z.string().trim().min(1, "prompt 不能为空"),
negativePrompt: z.string().trim().optional(),
imageCount: z.coerce.number().int().min(1).max(8).optional(),
aspectRatio: z.string().trim().max(32).optional(),
imageSize: z.string().trim().max(32).optional(),
metadata: z.record(z.string(), z.any()).optional(),
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
});
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
return String(value || "").trim();
}
function parseOptionalInteger(value: FormDataEntryValue | null) {
const normalized = normalizeString(value);
if (!normalized) return undefined;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseOptionalJsonRecord(value: FormDataEntryValue | null) {
const normalized = normalizeString(value);
if (!normalized) return undefined;
try {
const parsed = JSON.parse(normalized);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// ignore invalid metadata JSON
}
return undefined;
}
function normalizeReferenceImageInputs(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value
.flatMap((item) => normalizeReferenceImageInputs(item))
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
async function parseRequestPayload(request: Request) {
const contentType = request.headers.get("content-type") || "";
if (contentType.toLowerCase().includes("multipart/form-data")) {
const formData = await request.formData();
const fileEntries = [
...formData.getAll("images"),
...formData.getAll("image"),
...formData.getAll("referenceImages"),
...formData.getAll("referenceImage"),
...formData.getAll("files"),
];
const files = fileEntries.filter((item): item is File => item instanceof File);
const uploadedReferenceImages = await Promise.all(
files.map((file) => uploadExternalImageApiFile(file)),
);
const inlineReferenceImages = [
...formData.getAll("imageUrls"),
...formData.getAll("imageUrl"),
...formData.getAll("referenceImageUrls"),
...formData.getAll("referenceImages"),
...formData.getAll("referenceImage"),
]
.filter((item): item is string => typeof item === "string")
.flatMap((item) => normalizeReferenceImageInputs(item));
const hostedInlineImages = await ensureHostedReferenceImageUrls(
inlineReferenceImages,
);
const parsed = requestSchema.parse({
prompt: normalizeString(formData.get("prompt")),
negativePrompt: normalizeString(formData.get("negativePrompt")) || undefined,
imageCount: parseOptionalInteger(formData.get("imageCount")),
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
imageSize: normalizeString(formData.get("imageSize")) || undefined,
metadata: parseOptionalJsonRecord(formData.get("metadata")),
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
});
if (!parsed.referenceImages?.length) {
throw new z.ZodError([
{
code: "custom",
path: ["referenceImages"],
message: "至少上传 1 张参考图",
},
]);
}
return parsed;
}
const json = await request.json();
const parsed = requestSchema.parse({
...json,
referenceImages: await ensureHostedReferenceImageUrls([
...normalizeReferenceImageInputs((json as any)?.referenceImages),
...normalizeReferenceImageInputs((json as any)?.imageUrls),
...normalizeReferenceImageInputs((json as any)?.images),
...normalizeReferenceImageInputs((json as any)?.image),
]),
});
if (!parsed.referenceImages?.length) {
throw new z.ZodError([
{
code: "custom",
path: ["referenceImages"],
message: "至少上传 1 张参考图",
},
]);
}
return parsed;
}
export async function POST(request: Request) {
const authResult = await authenticateExternalImageApiRequest(request);
if (!authResult.accessKey) {
@ -45,20 +161,11 @@ export async function POST(request: Request) {
let logId: string | null = null;
try {
const json = await request.json();
const parsed = requestSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.errors[0]?.message || "参数错误",
},
{ status: 400 },
);
}
const payload = parsed.data;
const selectedModel = await resolveExternalImageApiModel(payload.model);
const payload = await parseRequestPayload(request);
const candidateModels = await buildExternalImageApiModelCandidates({
referenceImageCount: payload.referenceImages?.length || 0,
});
const selectedModel = candidateModels[0];
const ownerUserId = await resolveExternalImageApiOwnerUserId(
authResult.config.defaultOwnerEmail,
);
@ -84,6 +191,7 @@ export async function POST(request: Request) {
requesterUserAgent,
requestPayload: {
...payload,
candidateModels: candidateModels.map(serializeExternalImageApiModel),
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
authResult.accessKey,
),
@ -93,14 +201,17 @@ export async function POST(request: Request) {
logId = createdLog?.id || null;
const rawMarkdown = await generateImagesWithModel({
modelDbId: selectedModel.model.id,
const generationResult = await generateImagesWithFailover({
candidates: candidateModels,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt || null,
imageCount: payload.imageCount || 1,
aspectRatio: payload.aspectRatio || null,
imageSize: payload.imageSize || null,
referenceImageUrls: payload.referenceImages || [],
});
const actualModel = generationResult.usedModel;
const rawMarkdown = generationResult.markdown;
const hostedMarkdown = await ensureHostedImageMarkdown(
rawMarkdown,
@ -117,7 +228,7 @@ export async function POST(request: Request) {
null,
null,
payload.prompt,
selectedModel.model.id,
actualModel.model.id,
{
sourceType: "external_api_generated",
userPrompt: payload.prompt,
@ -125,6 +236,8 @@ export async function POST(request: Request) {
requestedImageCount: payload.imageCount || images.length,
aspectRatio: payload.aspectRatio,
imageSize: payload.imageSize,
sourceImageUrls: payload.referenceImages || [],
relationType: "edited_from",
skipBilling: true,
},
);
@ -133,11 +246,15 @@ export async function POST(request: Request) {
await db
.update(externalImageApiRequestLog)
.set({
modelDbId: actualModel.model.id,
modelRuntimeId: actualModel.model.modelId,
modelDisplayName: actualModel.model.displayName,
status: "succeeded",
responsePayload: {
model: serializeExternalImageApiModel(selectedModel),
model: serializeExternalImageApiModel(actualModel),
markdown: hostedMarkdown,
images,
attempts: generationResult.attempts,
},
generatedImageIds: persistResult.savedImageIds,
generatedAssetIds: persistResult.savedAssetIds,
@ -152,21 +269,35 @@ export async function POST(request: Request) {
success: true,
data: {
requestId: logId,
model: serializeExternalImageApiModel(selectedModel),
model: serializeExternalImageApiModel(actualModel),
images,
generatedImageIds: persistResult.savedImageIds,
generatedAssetIds: persistResult.savedAssetIds,
attempts: generationResult.attempts,
},
});
} catch (error) {
console.error("[External Image API] Generate failed:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: error.errors[0]?.message || "参数错误",
},
{ status: 400 },
);
}
if (logId) {
await db
.update(externalImageApiRequestLog)
.set({
status: "failed",
errorMessage: error instanceof Error ? error.message : "图片生成失败",
responsePayload: {
error: error instanceof Error ? error.message : "图片生成失败",
},
durationMs: Date.now() - startedAt,
completedAt: new Date(),
})

View File

@ -12,7 +12,8 @@
2. 发起生图
`POST /api/external/image/generate`
这套接口直接复用我们系统后台已启用的生图模型与模型名称。
这套接口是 **图生图接口**。第三方调用方只需要上传参考图和提示词,**不需要也不允许指定内部模型名**。
我们服务端会按后台默认优先级自动选择模型,优先尝试 `GPT-image-2`,失败后自动故障迁移到其他可用图生图模型,尽量返回图片。
生成结果图片会先上传到我们的储存桶,再写入我们的数据库与媒体资产表。
## 2. 鉴权方式
@ -33,12 +34,12 @@ x-api-key: YOUR_API_KEY
如果 API Key 无效、已停用,或后台未启用第三方生图 API,接口会返回 `401` 或 `403`。
## 3. 获取模型列表
## 3. 获取模型列表(可选)
### 请求
```bash
curl -X GET "https://你的域名/api/external/image/models" \
curl -X GET "https://ai.yydd.ai/api/external/image/models" \
-H "Authorization: Bearer YOUR_API_KEY"
```
@ -56,7 +57,7 @@ curl -X GET "https://你的域名/api/external/image/models" \
"description": "轻量版商品生图模型",
"providerType": "volcano-vision",
"providerName": "火山视觉",
"avatarUrl": "https://你的域名/uploads/xxx.png",
"avatarUrl": "https://ai.yydd.ai/uploads/xxx.png",
"imageGenConfig": {
"creditCost": 20
}
@ -68,20 +69,25 @@ curl -X GET "https://你的域名/api/external/image/models" \
### 字段说明
- `id`:系统内部模型 UUID,推荐第三方优先使用这个值调用生成接口
- `id`:系统内部模型 UUID
- `modelId`:上游模型 ID
- `displayName`:系统中展示给用户的模型名称
- `providerType`:模型服务商类型
- `providerName`:模型服务商显示名称
- `imageGenConfig`:当前模型的生图配置,包含积分等后台配置
说明:
- 这个接口主要用于联调排查或查看当前可用能力
- 正式调用生图接口时,第三方 **无需传模型名**
## 4. 发起生图
### 请求地址
```http
POST /api/external/image/generate
Content-Type: application/json
POST https://ai.yydd.ai/api/external/image/generate
Content-Type: application/json 或 multipart/form-data
Authorization: Bearer YOUR_API_KEY
```
@ -89,12 +95,15 @@ Authorization: Bearer YOUR_API_KEY
```json
{
"model": "9b8f7c10-1111-4d22-9abc-1234567890ab",
"prompt": "请生成一张高端现代客厅空间图,暖光,真实摄影感,适合商业首页展示",
"prompt": "保留原空间结构,将这张衣帽间草图改成真实摄影效果,木色更高级,灯光柔和",
"negativePrompt": "不要人物,不要文字水印,不要畸变",
"imageCount": 2,
"aspectRatio": "16:9",
"imageSize": "2K",
"referenceImages": [
"https://ai.yydd.ai/uploads/source-1.jpg",
"https://ai.yydd.ai/uploads/source-2.jpg"
],
"metadata": {
"bizId": "partner-order-20260518-001"
}
@ -103,17 +112,22 @@ Authorization: Bearer YOUR_API_KEY
### 参数说明
- `model`:可传以下任意一种
- 模型 UUID `id`
- 模型运行 ID `modelId`
- 模型显示名 `displayName`
- `prompt`:正向提示词,必填
- `negativePrompt`:反向提示词,可选
- `imageCount`:生成张数,可选,默认 `1`,最大 `8`
- `aspectRatio`:宽高比,可选,例如 `1:1`、`16:9`
- `imageSize`:图片规格,可选,例如 `1K`、`2K`
- `referenceImages`:参考图地址数组,必填,至少 `1` 张,最多 `14` 张
- `metadata`:业务扩展字段,可选,会原样记录到请求日志里
### multipart/form-data 调用方式
可直接上传图片文件,不必先把图片上传到公网:
- 文本字段:`prompt`、`negativePrompt`、`imageCount`、`aspectRatio`、`imageSize`
- 文件字段:`images`(可重复上传多张)
- 也支持混合传参:文件字段 `images` + URL 字段 `imageUrls`
## 5. 生图成功响应
```json
@ -136,14 +150,14 @@ Authorization: Bearer YOUR_API_KEY
"images": [
{
"id": 1,
"originalUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
"thumbnailUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
"originalUrl": "https://ai.yydd.ai/uploads/2026xxxx-orig.jpg",
"thumbnailUrl": "https://ai.yydd.ai/uploads/2026xxxx-orig.jpg",
"imageSize": "2K"
},
{
"id": 2,
"originalUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
"thumbnailUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
"originalUrl": "https://ai.yydd.ai/uploads/2026yyyy-orig.jpg",
"thumbnailUrl": "https://ai.yydd.ai/uploads/2026yyyy-orig.jpg",
"imageSize": "2K"
}
],
@ -152,6 +166,14 @@ Authorization: Bearer YOUR_API_KEY
],
"generatedAssetIds": [
"b7b19e10-1111-4db8-aeb2-1234567890ab"
],
"attempts": [
{
"modelId": "07e2304a-a63a-4fd3-9c76-63ba98de464d",
"modelRuntimeId": "openai/gpt-image-2/edit",
"displayName": "GPT-image-2",
"success": true
}
]
}
}
@ -166,6 +188,7 @@ Authorization: Bearer YOUR_API_KEY
- `imageSize`:记录的图片规格
- `generatedImageIds`:写入 `GeneratedImage` 表后的记录 ID
- `generatedAssetIds`:写入媒体资产表后的记录 ID
- `attempts`:本次自动选模 / 故障迁移尝试明细
## 6. 失败响应示例
@ -180,14 +203,14 @@ Authorization: Bearer YOUR_API_KEY
- API Key 缺失或无效
- 第三方生图 API 后台未开启
- 请求的模型不存在或未启用
- 未上传参考图
- 上游模型生成失败
- 上游返回内容中未解析出图片
## 7. 调用建议
1. 先调用模型列表接口,缓存可用模型
2. 生成时优先传模型 UUID `id`,最稳定
1. 这条接口按“图生图”设计,请始终上传至少 1 张参考图
2. 第三方不要传模型名,交给服务端按默认优先级和故障迁移处理
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载

View File

@ -16,6 +16,13 @@ import { extractImageUrlsFromText } from "@/lib/image-url";
import { uploadFile } from "@/lib/storage/service";
type ImageModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
type ExternalImageApiGenerateAttempt = {
modelId: string;
modelRuntimeId: string;
displayName: string;
success: boolean;
error?: string;
};
function normalizeText(value: unknown) {
return String(value || "").trim();
@ -30,6 +37,16 @@ function mimeTypeToExtension(mimeType: string) {
return "jpg";
}
function sanitizeUploadFilename(filename: string, fallbackPrefix: string) {
const normalized = String(filename || "")
.trim()
.replace(/[^\w.\-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
if (normalized) return normalized;
return `${fallbackPrefix}-${Date.now()}.png`;
}
function getExtensionFromUrl(url: string): string {
try {
const pathname = new URL(url).pathname;
@ -136,6 +153,146 @@ async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
return uploadRemoteImageToStorage(normalizedUrl);
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function getSupportedImageModes(capabilities: unknown) {
const raw = toRecord(capabilities)?.supportedModes;
if (!Array.isArray(raw)) return new Set<string>();
return new Set(
raw
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean),
);
}
function getMaxReferenceImages(capabilities: unknown): number | null {
const raw = toRecord(capabilities)?.maxReferenceImages;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.round(parsed);
}
function supportsImageEditForReferenceCount(
entry: ImageModelEntry,
referenceImageCount: number,
) {
if (referenceImageCount <= 0) {
return true;
}
const capabilities = toRecord(entry.model.capabilities);
const kind = normalizeText(capabilities?.kind).toLowerCase();
const supportedModes = getSupportedImageModes(entry.model.capabilities);
const maxReferenceImages = getMaxReferenceImages(entry.model.capabilities);
const autoRouteEditEndpoint = capabilities?.autoRouteEditEndpoint === true;
const supportsMultiImageReference =
capabilities?.supportsMultiImageReference === true;
const runtimeModelId = normalizeText(entry.model.modelId).toLowerCase();
const supportsReferenceEdit =
kind === "image-edit" ||
supportedModes.has("image-edit") ||
supportedModes.has("reference-images") ||
autoRouteEditEndpoint ||
runtimeModelId.endsWith("/edit");
if (!supportsReferenceEdit) {
return false;
}
if (
maxReferenceImages !== null &&
referenceImageCount > maxReferenceImages
) {
return false;
}
if (referenceImageCount > 1) {
if (maxReferenceImages !== null) {
return referenceImageCount <= maxReferenceImages;
}
return supportsMultiImageReference || autoRouteEditEndpoint;
}
return true;
}
function isPreferredGptImage2Model(entry: ImageModelEntry) {
const candidates = [
entry.model.displayName,
entry.model.modelId,
entry.model.id,
]
.map((item) =>
normalizeText(item)
.toLowerCase()
.replace(/[\s_]+/g, "-"),
)
.filter(Boolean);
return candidates.some(
(item) =>
item.includes("gpt-image-2") ||
item.includes("gptimage2") ||
item.includes("gpt-image2"),
);
}
function sortExternalImageApiModels(
models: ImageModelEntry[],
referenceImageCount: number,
) {
return [...models].sort((left, right) => {
const leftPreferred = isPreferredGptImage2Model(left) ? 1 : 0;
const rightPreferred = isPreferredGptImage2Model(right) ? 1 : 0;
if (leftPreferred !== rightPreferred) {
return rightPreferred - leftPreferred;
}
const leftRefSupport = supportsImageEditForReferenceCount(
left,
referenceImageCount,
)
? 1
: 0;
const rightRefSupport = supportsImageEditForReferenceCount(
right,
referenceImageCount,
)
? 1
: 0;
if (leftRefSupport !== rightRefSupport) {
return rightRefSupport - leftRefSupport;
}
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
});
}
function findExternalImageApiModelMatch(
models: ImageModelEntry[],
modelRef?: string | null,
) {
const normalizedRef = normalizeText(modelRef).toLowerCase();
if (!normalizedRef) return null;
return (
models.find((entry) => {
const candidates = [
entry.model.id,
entry.model.modelId,
entry.model.displayName,
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean);
return candidates.includes(normalizedRef);
}) || null
);
}
export async function ensureHostedImageMarkdown(
markdown: string,
imageSize = "2K",
@ -257,17 +414,23 @@ export async function authenticateExternalImageApiRequest(request: Request) {
export async function getExternalImageApiModels() {
const models = await getEnabledAiModelsWithProvider(false, false);
return models.filter((entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
modelId: entry.model.modelId,
capabilities: entry.model.capabilities,
supportsImageGen: entry.model.supportsImageGen,
}),
return sortExternalImageApiModels(
models.filter(
(entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
modelId: entry.model.modelId,
capabilities: entry.model.capabilities,
supportsImageGen: entry.model.supportsImageGen,
}) && supportsImageEditForReferenceCount(entry, 1),
),
1,
);
}
export function serializeExternalImageApiModel(entry: ImageModelEntry) {
const capabilities = toRecord(entry.model.capabilities);
const maxReferenceImages = getMaxReferenceImages(entry.model.capabilities);
return {
id: entry.model.id,
modelId: entry.model.modelId,
@ -277,38 +440,127 @@ export function serializeExternalImageApiModel(entry: ImageModelEntry) {
providerName: entry.provider.name,
avatarUrl: entry.model.avatarUrl,
imageGenConfig: entry.model.imageGenConfig,
supportsReferenceImages: supportsImageEditForReferenceCount(entry, 1),
supportsMultiImageReference:
capabilities?.supportsMultiImageReference === true ||
(maxReferenceImages !== null && maxReferenceImages > 1),
maxReferenceImages,
preferred: isPreferredGptImage2Model(entry),
};
}
export async function resolveExternalImageApiModel(modelRef?: string | null) {
const models = await getExternalImageApiModels();
export async function resolveExternalImageApiModel(
modelRef?: string | null,
options?: {
referenceImageCount?: number;
},
) {
const referenceImageCount = Math.max(0, options?.referenceImageCount ?? 1);
const models = sortExternalImageApiModels(
(
await getEnabledAiModelsWithProvider(false, false)
).filter((entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
modelId: entry.model.modelId,
capabilities: entry.model.capabilities,
supportsImageGen: entry.model.supportsImageGen,
}),
),
referenceImageCount,
);
if (!models.length) {
throw new Error("当前没有启用可用于生图的模型");
}
const normalizedRef = normalizeText(modelRef).toLowerCase();
if (!normalizedRef) {
return models[0];
const matched = findExternalImageApiModelMatch(models, modelRef);
if (!modelRef) {
const preferred = models.find((entry) =>
supportsImageEditForReferenceCount(entry, referenceImageCount),
);
if (!preferred) {
throw new Error("当前没有启用可用于图生图的模型");
}
return preferred;
}
const matched = models.find((entry) => {
const candidates = [
entry.model.id,
entry.model.modelId,
entry.model.displayName,
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean);
return candidates.includes(normalizedRef);
});
if (!matched) {
throw new Error("未找到匹配的生图模型");
}
if (!supportsImageEditForReferenceCount(matched, referenceImageCount)) {
const maxReferenceImages = getMaxReferenceImages(matched.model.capabilities);
if (
referenceImageCount > 1 &&
maxReferenceImages !== null &&
referenceImageCount > maxReferenceImages
) {
throw new Error(
`模型「${matched.model.displayName}」最多支持 ${maxReferenceImages} 张参考图`,
);
}
throw new Error(`模型「${matched.model.displayName}」不支持当前图生图调用`);
}
return matched;
}
export async function buildExternalImageApiModelCandidates(params?: {
modelRef?: string | null;
referenceImageCount?: number;
}) {
const referenceImageCount = Math.max(0, params?.referenceImageCount ?? 1);
const allModels = sortExternalImageApiModels(
(
await getEnabledAiModelsWithProvider(false, false)
).filter((entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
modelId: entry.model.modelId,
capabilities: entry.model.capabilities,
supportsImageGen: entry.model.supportsImageGen,
}),
),
referenceImageCount,
);
const supportedModels = allModels.filter((entry) =>
supportsImageEditForReferenceCount(entry, referenceImageCount),
);
if (!supportedModels.length) {
throw new Error("当前没有启用可用于图生图的模型");
}
const explicitMatch = findExternalImageApiModelMatch(allModels, params?.modelRef);
if (!params?.modelRef) {
return supportedModels;
}
if (!explicitMatch) {
throw new Error("未找到匹配的生图模型");
}
if (!supportsImageEditForReferenceCount(explicitMatch, referenceImageCount)) {
const maxReferenceImages = getMaxReferenceImages(explicitMatch.model.capabilities);
if (
referenceImageCount > 1 &&
maxReferenceImages !== null &&
referenceImageCount > maxReferenceImages
) {
throw new Error(
`模型「${explicitMatch.model.displayName}」最多支持 ${maxReferenceImages} 张参考图`,
);
}
throw new Error(`模型「${explicitMatch.model.displayName}」不支持当前图生图调用`);
}
return [
explicitMatch,
...supportedModels.filter((entry) => entry.model.id !== explicitMatch.model.id),
];
}
export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
@ -348,6 +600,7 @@ export async function generateImagesWithModel(params: {
imageCount: number;
aspectRatio?: string | null;
imageSize?: string | null;
referenceImageUrls?: string[];
}) {
const languageModel = await getLanguageModelById(params.modelDbId);
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
@ -367,7 +620,13 @@ export async function generateImagesWithModel(params: {
messages: [
{
role: "user",
content: [{ type: "text", text: composedPrompt }],
content: [
{ type: "text", text: composedPrompt },
...(params.referenceImageUrls || []).map((imageUrl) => ({
type: "image",
image: imageUrl,
})),
],
},
],
imageParams: {
@ -389,6 +648,83 @@ export async function generateImagesWithModel(params: {
return text;
}
export async function generateImagesWithFailover(params: {
candidates: ImageModelEntry[];
prompt: string;
negativePrompt?: string | null;
imageCount: number;
aspectRatio?: string | null;
imageSize?: string | null;
referenceImageUrls?: string[];
}) {
const attempts: ExternalImageApiGenerateAttempt[] = [];
for (const candidate of params.candidates) {
try {
const markdown = await generateImagesWithModel({
modelDbId: candidate.model.id,
prompt: params.prompt,
negativePrompt: params.negativePrompt || null,
imageCount: params.imageCount,
aspectRatio: params.aspectRatio || null,
imageSize: params.imageSize || null,
referenceImageUrls: params.referenceImageUrls || [],
});
attempts.push({
modelId: candidate.model.id,
modelRuntimeId: candidate.model.modelId,
displayName: candidate.model.displayName,
success: true,
});
return {
markdown,
usedModel: candidate,
attempts,
};
} catch (error) {
attempts.push({
modelId: candidate.model.id,
modelRuntimeId: candidate.model.modelId,
displayName: candidate.model.displayName,
success: false,
error: error instanceof Error ? error.message : "图片生成失败",
});
}
}
const summary = attempts
.filter((item) => !item.success)
.map((item) => `${item.displayName}: ${item.error || "图片生成失败"}`)
.join(";");
throw new Error(summary || "所有候选生图模型均调用失败");
}
export async function ensureHostedReferenceImageUrls(imageUrls: string[]) {
const deduped = imageUrls
.map((item) => normalizeText(item))
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index);
return Promise.all(deduped.map((url) => ensureHostedImageUrl(url)));
}
export async function uploadExternalImageApiFile(file: File) {
if (!(file instanceof File)) {
throw new Error("无效的图片文件");
}
const arrayBuffer = await file.arrayBuffer();
if (!arrayBuffer.byteLength) {
throw new Error("上传的图片文件为空");
}
const filename = sanitizeUploadFilename(file.name, "external-reference");
const uploaded = await uploadFile(filename, arrayBuffer);
return uploaded.url;
}
export function buildExternalImageApiKeyFingerprint(
accessKey: ExternalImageApiAccessKey | null,
) {