717 lines
23 KiB
TypeScript
717 lines
23 KiB
TypeScript
import "server-only";
|
|
|
|
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
|
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
|
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
|
import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
|
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
|
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
|
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
|
import { createRunway } from "@/lib/ai/providers/runway";
|
|
import { createKrea } from "@/lib/ai/providers/krea";
|
|
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
|
import {
|
|
acquireCanvasImageModelSlot,
|
|
getCanvasImageModelQueueStatus,
|
|
type CanvasImageModelQueueStatus,
|
|
normalizeCanvasImageModelKey,
|
|
recordCanvasImageModelExecutionDuration,
|
|
} from "./image-concurrency";
|
|
import {
|
|
recordAiModelHealthFailure,
|
|
recordAiModelHealthSuccess,
|
|
shouldFailoverAiModelError,
|
|
} from "@/lib/ai/runtime/model-health";
|
|
|
|
export interface CanvasGenerateImageRequest {
|
|
prompt: string;
|
|
negativePrompt?: string;
|
|
model?: string;
|
|
aspectRatio?: string;
|
|
imageSize?: string;
|
|
imageCount?: number;
|
|
promptExtend?: boolean;
|
|
referenceImage?: string;
|
|
referenceImages?: string[];
|
|
billingFeature?: "multi-view" | "repaint";
|
|
ownerKey?: string;
|
|
debugLabel?: string;
|
|
debugMeta?: Record<string, unknown>;
|
|
allowFailover?: boolean;
|
|
internalFailoverTriedModelRefs?: string[];
|
|
onQueueStatusChange?: (
|
|
status: CanvasImageModelQueueStatus & {
|
|
modelName?: string | null;
|
|
modelRef?: string | null;
|
|
}
|
|
) => void | Promise<void>;
|
|
}
|
|
|
|
export interface ParsedImage {
|
|
thumbnailUrl: string;
|
|
originalUrl: string;
|
|
}
|
|
|
|
export interface CanvasGenerateImageResult {
|
|
images: ParsedImage[];
|
|
model: string;
|
|
modelRef: string;
|
|
prompt: string;
|
|
}
|
|
|
|
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
|
"gemini",
|
|
"grsai-gpt-image",
|
|
"alibaba-image",
|
|
"bfl-image",
|
|
"ruxa-image",
|
|
"fal-image",
|
|
"openai-image",
|
|
"stepfun-image",
|
|
"midjourney",
|
|
"runway",
|
|
"krea",
|
|
]);
|
|
|
|
function summarizeReferenceImageHosts(urls: string[]) {
|
|
return Array.from(
|
|
new Set(
|
|
urls
|
|
.map((value) => {
|
|
try {
|
|
return new URL(value).host;
|
|
} catch {
|
|
return "";
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
}
|
|
|
|
function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
|
|
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
const matches = [...markdown.matchAll(imageRegex)];
|
|
const parsed: ParsedImage[] = [];
|
|
|
|
for (const match of matches) {
|
|
const altText = match[1] || "";
|
|
const thumbnailUrl = match[2];
|
|
let originalUrl = thumbnailUrl;
|
|
|
|
if (altText.includes("thumb|||")) {
|
|
const content = altText.substring(altText.indexOf("thumb|||") + 8);
|
|
const parts = content.split("|||");
|
|
originalUrl = parts[0] || thumbnailUrl;
|
|
} else if (altText.includes("thumb$$")) {
|
|
const content = altText.substring(altText.indexOf("thumb$$") + 7);
|
|
const parts = content.split("$$");
|
|
originalUrl = parts[0] || thumbnailUrl;
|
|
} else if (altText.includes("thumb|")) {
|
|
const content = altText.substring(altText.indexOf("thumb|") + 6);
|
|
const parts = content.split("|");
|
|
originalUrl = parts[0] || thumbnailUrl;
|
|
}
|
|
|
|
parsed.push({ thumbnailUrl, originalUrl });
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function getProviderBatchLimit(providerType: string) {
|
|
switch (providerType) {
|
|
case "ruxa-image":
|
|
case "bfl-image":
|
|
case "openai-image":
|
|
case "stepfun-image":
|
|
case "midjourney":
|
|
case "runway":
|
|
return 1;
|
|
case "krea":
|
|
return 4;
|
|
case "gemini":
|
|
case "grsai-gpt-image":
|
|
return 2;
|
|
case "alibaba-image":
|
|
case "fal-image":
|
|
return 6;
|
|
default:
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
type CanvasModelWithProvider = Awaited<
|
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
|
>[number];
|
|
|
|
function getCanvasModelRefKeys(item: CanvasModelWithProvider) {
|
|
return [
|
|
String(item.model.id || "").trim(),
|
|
String(item.model.modelId || "").trim(),
|
|
].filter(Boolean);
|
|
}
|
|
|
|
function findCanvasFailoverModel(params: {
|
|
selectedModel: CanvasModelWithProvider;
|
|
imageModels: CanvasModelWithProvider[];
|
|
failedModelRefs: string[];
|
|
}) {
|
|
const failedRefs = new Set(
|
|
params.failedModelRefs.map((item) => String(item || "").trim()).filter(Boolean)
|
|
);
|
|
const selectedFallbackRefs = [
|
|
params.selectedModel.model.fallbackModelId1,
|
|
params.selectedModel.model.fallbackModelId2,
|
|
]
|
|
.map((item) => String(item || "").trim())
|
|
.filter(Boolean);
|
|
|
|
const isUsableFallback = (item: CanvasModelWithProvider) => {
|
|
if (!item.model.isEnabled || item.model.isSubModel || item.model.excludeFromFailover) {
|
|
return false;
|
|
}
|
|
if (!item.model.supportsImageGen) return false;
|
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(item.provider.type)) return false;
|
|
return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key));
|
|
};
|
|
|
|
for (const fallbackRef of selectedFallbackRefs) {
|
|
const configured = params.imageModels.find(
|
|
(item) =>
|
|
isUsableFallback(item) &&
|
|
(item.model.id === fallbackRef || item.model.modelId === fallbackRef)
|
|
);
|
|
if (configured) return configured;
|
|
}
|
|
|
|
const sameProviderType = params.imageModels.find(
|
|
(item) =>
|
|
isUsableFallback(item) &&
|
|
item.provider.type === params.selectedModel.provider.type
|
|
);
|
|
if (sameProviderType) return sameProviderType;
|
|
|
|
return params.imageModels.find(isUsableFallback) || null;
|
|
}
|
|
|
|
export async function generateCanvasImage(
|
|
request: CanvasGenerateImageRequest
|
|
): Promise<CanvasGenerateImageResult> {
|
|
const startedAt = Date.now();
|
|
const prompt = String(request.prompt || "").trim();
|
|
if (!prompt) {
|
|
throw new Error("请输入提示词");
|
|
}
|
|
|
|
const negativePrompt =
|
|
typeof request.negativePrompt === "string" ? request.negativePrompt : undefined;
|
|
const model = typeof request.model === "string" ? request.model : undefined;
|
|
const aspectRatio =
|
|
typeof request.aspectRatio === "string" && request.aspectRatio.trim()
|
|
? request.aspectRatio.trim()
|
|
: "auto";
|
|
const imageSize =
|
|
typeof request.imageSize === "string" && request.imageSize.trim()
|
|
? request.imageSize.trim()
|
|
: "2K";
|
|
const imageCount =
|
|
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
|
|
? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
|
|
: 1;
|
|
const promptExtend =
|
|
typeof request.promptExtend === "boolean" ? request.promptExtend : undefined;
|
|
const ownerKey =
|
|
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
|
? request.ownerKey.trim()
|
|
: undefined;
|
|
const referenceImage =
|
|
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
|
const referenceImages = Array.isArray(request.referenceImages)
|
|
? request.referenceImages
|
|
: [];
|
|
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
|
const debugMeta =
|
|
request.debugMeta && typeof request.debugMeta === "object"
|
|
? request.debugMeta
|
|
: undefined;
|
|
|
|
const allModels = await getEnabledAiModelsWithProvider();
|
|
const imageModels = allModels.filter((m) => m.model.supportsImageGen);
|
|
|
|
if (!imageModels || imageModels.length === 0) {
|
|
throw new Error("暂无可用的图片生成模型");
|
|
}
|
|
|
|
let selectedModel = model
|
|
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
|
|
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
|
|
|
|
if (!selectedModel) {
|
|
selectedModel = imageModels[0];
|
|
}
|
|
|
|
let provider = selectedModel.provider;
|
|
|
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
|
|
let fallbackModel = selectedModel;
|
|
let fallbackProvider = provider;
|
|
for (const candidate of imageModels) {
|
|
const candidateProvider = candidate.provider;
|
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(candidateProvider.type)) continue;
|
|
fallbackModel = candidate;
|
|
fallbackProvider = candidateProvider;
|
|
break;
|
|
}
|
|
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
|
|
throw new Error(
|
|
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
|
|
);
|
|
}
|
|
if (fallbackModel.model.id !== selectedModel.model.id) {
|
|
console.warn("[Canvas Generate Image] Fallback to supported image model:", {
|
|
requestedModel: selectedModel.model.modelId || selectedModel.model.id,
|
|
fallbackModel: fallbackModel.model.modelId || fallbackModel.model.id,
|
|
requestedProviderType: provider.type,
|
|
fallbackProviderType: fallbackProvider.type,
|
|
});
|
|
}
|
|
selectedModel = fallbackModel;
|
|
provider = fallbackProvider;
|
|
}
|
|
|
|
const normalizedReferenceImages = Array.from(
|
|
new Set(
|
|
[
|
|
...referenceImages,
|
|
...(referenceImage ? [referenceImage] : []),
|
|
]
|
|
.map((item) => String(item || "").trim())
|
|
.filter((url) => /^https?:\/\//i.test(url))
|
|
)
|
|
).slice(0, 12);
|
|
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
|
|
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
|
|
const effectiveReferenceImages = normalizedReferenceImages;
|
|
if (unreachableReferenceImages.length > 0) {
|
|
console.warn("[Canvas Generate Image] Local reachability check failed for some reference images:", {
|
|
count: unreachableReferenceImages.length,
|
|
urls: unreachableReferenceImages.slice(0, 6),
|
|
hosts: summarizeReferenceImageHosts(unreachableReferenceImages),
|
|
});
|
|
}
|
|
if (normalizedReferenceImages.length > 0 && reachableReferenceImages.length === 0) {
|
|
console.warn(
|
|
"[Canvas Generate Image] All reference images failed local reachability check; proceeding with original URLs.",
|
|
{
|
|
count: normalizedReferenceImages.length,
|
|
hosts: summarizeReferenceImageHosts(normalizedReferenceImages),
|
|
debugLabel,
|
|
}
|
|
);
|
|
}
|
|
|
|
console.info("[Canvas Generate Image] Started:", {
|
|
debugLabel,
|
|
promptLength: prompt.length,
|
|
aspectRatio,
|
|
imageSize,
|
|
imageCount,
|
|
requestedReferenceImageCount: normalizedReferenceImages.length,
|
|
reachableReferenceImageCount: reachableReferenceImages.length,
|
|
effectiveReferenceImageCount: effectiveReferenceImages.length,
|
|
referenceImageHosts: summarizeReferenceImageHosts(effectiveReferenceImages),
|
|
selectedModelRef: selectedModel.model.id,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
providerType: provider.type,
|
|
...debugMeta,
|
|
});
|
|
|
|
const messages = [
|
|
{
|
|
role: "user" as const,
|
|
content:
|
|
effectiveReferenceImages.length > 0
|
|
? [
|
|
{ type: "text" as const, text: prompt },
|
|
...effectiveReferenceImages.map((url) => ({
|
|
type: "image_url" as const,
|
|
image_url: { url },
|
|
})),
|
|
]
|
|
: prompt,
|
|
},
|
|
];
|
|
|
|
const imageParams = {
|
|
aspectRatio,
|
|
imageSize,
|
|
imageCount: 1,
|
|
negativePrompt,
|
|
promptExtend,
|
|
};
|
|
|
|
let executeGenerateBatch:
|
|
| ((batchImageCount: number) => Promise<string>)
|
|
| null = null;
|
|
|
|
if (provider.type === "gemini" || provider.type === "grsai-gpt-image") {
|
|
const geminiProvider = createGeminiImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = geminiProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "alibaba-image") {
|
|
const alibabaProvider = createAlibabaImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = alibabaProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "bfl-image") {
|
|
const bflProvider = createBflImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = bflProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (_batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams,
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "ruxa-image") {
|
|
const ruxaProvider = createRuxaImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = ruxaProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "fal-image") {
|
|
const falProvider = createFalImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = falProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "openai-image") {
|
|
const openAIImageProvider = createOpenAIImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = openAIImageProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "stepfun-image") {
|
|
const stepfunProvider = createStepfunImage({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = stepfunProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (_batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams,
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "midjourney") {
|
|
const midjourneyProvider = createMidjourney({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = midjourneyProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "runway") {
|
|
const runwayProvider = createRunway({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = runwayProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (_batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams,
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else if (provider.type === "krea") {
|
|
const kreaProvider = createKrea({
|
|
apiKey: provider.apiKey,
|
|
baseURL: provider.baseUrl,
|
|
});
|
|
const modelInstance = kreaProvider.chat(selectedModel.model.modelId);
|
|
executeGenerateBatch = async (batchImageCount) => {
|
|
const result = await modelInstance.doGenerate({
|
|
messages,
|
|
imageParams: {
|
|
...imageParams,
|
|
imageCount: batchImageCount,
|
|
},
|
|
});
|
|
return result.text || "";
|
|
};
|
|
} else {
|
|
throw new Error(
|
|
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
|
|
);
|
|
}
|
|
|
|
const providerBatchLimit = getProviderBatchLimit(provider.type);
|
|
const targetImageCount = Math.max(1, imageCount);
|
|
const collectedImages: ParsedImage[] = [];
|
|
const maxBatches = Math.max(1, Math.ceil(targetImageCount / providerBatchLimit));
|
|
const resolvedModelKey = normalizeCanvasImageModelKey(
|
|
selectedModel.model.modelId || selectedModel.model.id || model
|
|
);
|
|
const slot = await acquireCanvasImageModelSlot({
|
|
modelKey: resolvedModelKey,
|
|
ownerKey,
|
|
debugLabel,
|
|
debugMeta: {
|
|
selectedModelId: selectedModel.model.modelId,
|
|
selectedModelRef: selectedModel.model.id,
|
|
providerType: provider.type,
|
|
...debugMeta,
|
|
},
|
|
onQueued: request.onQueueStatusChange
|
|
? async (queueStatus) => {
|
|
await request.onQueueStatusChange?.({
|
|
...queueStatus,
|
|
modelName: selectedModel.model.displayName || null,
|
|
modelRef: selectedModel.model.id || null,
|
|
});
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
if (slot.waitMs > 0 && request.onQueueStatusChange) {
|
|
const runningStatus = await getCanvasImageModelQueueStatus({
|
|
modelKey: resolvedModelKey,
|
|
});
|
|
await request.onQueueStatusChange({
|
|
...runningStatus,
|
|
status: "running",
|
|
queuePosition: 0,
|
|
estimatedWaitMs: 0,
|
|
modelName: selectedModel.model.displayName || null,
|
|
modelRef: selectedModel.model.id || null,
|
|
});
|
|
}
|
|
|
|
let caughtError: unknown = null;
|
|
|
|
try {
|
|
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
|
|
const remaining = targetImageCount - collectedImages.length;
|
|
if (remaining <= 0) break;
|
|
const batchImageCount = Math.min(providerBatchLimit, remaining);
|
|
const batchStartedAt = Date.now();
|
|
console.info("[Canvas Generate Image] Batch started:", {
|
|
debugLabel,
|
|
batchIndex: batchIndex + 1,
|
|
totalBatches: maxBatches,
|
|
batchImageCount,
|
|
currentCollectedCount: collectedImages.length,
|
|
providerType: provider.type,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
...debugMeta,
|
|
});
|
|
const imageMarkdown = await executeGenerateBatch(batchImageCount);
|
|
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
|
|
console.info("[Canvas Generate Image] Batch completed:", {
|
|
debugLabel,
|
|
batchIndex: batchIndex + 1,
|
|
totalBatches: maxBatches,
|
|
batchImageCount,
|
|
parsedImageCount: parsedImages.length,
|
|
elapsedMs: Date.now() - batchStartedAt,
|
|
...debugMeta,
|
|
});
|
|
if (parsedImages.length === 0) {
|
|
if (collectedImages.length > 0) {
|
|
break;
|
|
}
|
|
throw new Error("图片生成失败");
|
|
}
|
|
collectedImages.push(...parsedImages.slice(0, batchImageCount));
|
|
}
|
|
|
|
if (collectedImages.length === 0) {
|
|
throw new Error("图片生成失败");
|
|
}
|
|
|
|
const finalImages = collectedImages.slice(0, targetImageCount);
|
|
console.info("[Canvas Generate Image] Completed:", {
|
|
debugLabel,
|
|
elapsedMs: Date.now() - startedAt,
|
|
waitMs: slot.waitMs,
|
|
singleModelConcurrency: slot.maxConcurrent,
|
|
finalImageCount: finalImages.length,
|
|
providerType: provider.type,
|
|
selectedModelRef: selectedModel.model.id,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
...debugMeta,
|
|
});
|
|
|
|
await recordAiModelHealthSuccess({
|
|
model: selectedModel.model,
|
|
}).catch((healthError) => {
|
|
console.warn("[Canvas Generate Image] Record model health success failed:", {
|
|
selectedModelRef: selectedModel.model.id,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
error: healthError instanceof Error ? healthError.message : String(healthError),
|
|
});
|
|
});
|
|
|
|
recordCanvasImageModelExecutionDuration(
|
|
resolvedModelKey,
|
|
Date.now() - startedAt
|
|
);
|
|
|
|
return {
|
|
images: finalImages,
|
|
model: selectedModel.model.displayName,
|
|
modelRef: selectedModel.model.id,
|
|
prompt,
|
|
};
|
|
} catch (error) {
|
|
console.error("[Canvas Generate Image] Failed:", {
|
|
debugLabel,
|
|
elapsedMs: Date.now() - startedAt,
|
|
waitMs: slot.waitMs,
|
|
singleModelConcurrency: slot.maxConcurrent,
|
|
providerType: provider.type,
|
|
selectedModelRef: selectedModel.model.id,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
...debugMeta,
|
|
});
|
|
caughtError = error;
|
|
} finally {
|
|
await slot.release();
|
|
}
|
|
|
|
await recordAiModelHealthFailure({
|
|
model: selectedModel.model,
|
|
provider,
|
|
error: caughtError,
|
|
context: debugLabel,
|
|
elapsedMs: Date.now() - startedAt,
|
|
metadata: {
|
|
aspectRatio,
|
|
imageSize,
|
|
imageCount,
|
|
requestedReferenceImageCount: normalizedReferenceImages.length,
|
|
...debugMeta,
|
|
},
|
|
}).catch((healthError) => {
|
|
console.warn("[Canvas Generate Image] Record model health failure failed:", {
|
|
selectedModelRef: selectedModel.model.id,
|
|
selectedModelId: selectedModel.model.modelId,
|
|
error: healthError instanceof Error ? healthError.message : String(healthError),
|
|
});
|
|
});
|
|
|
|
const failedModelRefs = Array.from(
|
|
new Set([
|
|
...(Array.isArray(request.internalFailoverTriedModelRefs)
|
|
? request.internalFailoverTriedModelRefs
|
|
: []),
|
|
selectedModel.model.id,
|
|
selectedModel.model.modelId,
|
|
].filter(Boolean))
|
|
);
|
|
const allowFailover = request.allowFailover !== false;
|
|
const fallbackModel = allowFailover && shouldFailoverAiModelError(caughtError)
|
|
? findCanvasFailoverModel({
|
|
selectedModel,
|
|
imageModels,
|
|
failedModelRefs,
|
|
})
|
|
: null;
|
|
|
|
if (fallbackModel) {
|
|
console.warn("[Canvas Generate Image] Failover to alternate image model:", {
|
|
debugLabel,
|
|
failedModelRef: selectedModel.model.id,
|
|
failedModelId: selectedModel.model.modelId,
|
|
fallbackModelRef: fallbackModel.model.id,
|
|
fallbackModelId: fallbackModel.model.modelId,
|
|
fallbackProviderType: fallbackModel.provider.type,
|
|
error: caughtError instanceof Error ? caughtError.message : String(caughtError),
|
|
...debugMeta,
|
|
});
|
|
|
|
return await generateCanvasImage({
|
|
...request,
|
|
model: fallbackModel.model.id,
|
|
internalFailoverTriedModelRefs: failedModelRefs,
|
|
debugMeta: {
|
|
...debugMeta,
|
|
failoverFromModelId: selectedModel.model.modelId,
|
|
failoverFromModelRef: selectedModel.model.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
throw caughtError;
|
|
}
|