+
{isProjectsOnlyView ? "所有项目" : "最近项目"}
diff --git a/lib/ai/grsai-gpt-image-sizes.ts b/lib/ai/grsai-gpt-image-sizes.ts
index a7820d10b..444fd5ffe 100644
--- a/lib/ai/grsai-gpt-image-sizes.ts
+++ b/lib/ai/grsai-gpt-image-sizes.ts
@@ -7,33 +7,36 @@ type GrsaiGptImageSizeOption = {
};
const GRSAI_GPT_IMAGE_SIZE_OPTIONS: GrsaiGptImageSizeOption[] = [
- { bucket: "1K", width: 1024, height: 1024 },
- { bucket: "1K", width: 1774, height: 887 },
- { bucket: "1K", width: 887, height: 1774 },
- { bucket: "1K", width: 1536, height: 1024 },
- { bucket: "1K", width: 1024, height: 1536 },
+ { bucket: "1K", width: 1280, height: 1280 },
+ { bucket: "1K", width: 848, height: 1280 },
+ { bucket: "1K", width: 1280, height: 848 },
+ { bucket: "1K", width: 960, height: 1280 },
+ { bucket: "1K", width: 1280, height: 960 },
+ { bucket: "1K", width: 1024, height: 1280 },
+ { bucket: "1K", width: 1280, height: 1024 },
+ { bucket: "1K", width: 720, height: 1280 },
+ { bucket: "1K", width: 1280, height: 720 },
+ { bucket: "1K", width: 1280, height: 544 },
{ bucket: "2K", width: 2048, height: 2048 },
- { bucket: "2K", width: 2048, height: 1152 },
- { bucket: "2K", width: 1152, height: 2048 },
- { bucket: "2K", width: 2048, height: 1360 },
{ bucket: "2K", width: 1360, height: 2048 },
- { bucket: "2K", width: 2048, height: 880 },
- { bucket: "2K", width: 880, height: 2048 },
- { bucket: "2K", width: 2048, height: 688 },
- { bucket: "2K", width: 688, height: 2048 },
- { bucket: "2K", width: 2048, height: 1024 },
- { bucket: "2K", width: 1024, height: 2048 },
+ { bucket: "2K", width: 2048, height: 1360 },
+ { bucket: "2K", width: 1536, height: 2048 },
+ { bucket: "2K", width: 2048, height: 1536 },
+ { bucket: "2K", width: 1632, height: 2048 },
+ { bucket: "2K", width: 2048, height: 1632 },
+ { bucket: "2K", width: 1152, height: 2048 },
+ { bucket: "2K", width: 2048, height: 1152 },
+ { bucket: "2K", width: 2048, height: 864 },
{ bucket: "4K", width: 2880, height: 2880 },
- { bucket: "4K", width: 3840, height: 2160 },
+ { bucket: "4K", width: 2336, height: 3520 },
+ { bucket: "4K", width: 3520, height: 2336 },
+ { bucket: "4K", width: 2480, height: 3312 },
+ { bucket: "4K", width: 3312, height: 2480 },
+ { bucket: "4K", width: 2560, height: 3216 },
+ { bucket: "4K", width: 3216, height: 2560 },
{ bucket: "4K", width: 2160, height: 3840 },
- { bucket: "4K", width: 3504, height: 2336 },
- { bucket: "4K", width: 2336, height: 3504 },
- { bucket: "4K", width: 3840, height: 1648 },
- { bucket: "4K", width: 1648, height: 3840 },
- { bucket: "4K", width: 3840, height: 1280 },
- { bucket: "4K", width: 1280, height: 3840 },
- { bucket: "4K", width: 3840, height: 1920 },
- { bucket: "4K", width: 1920, height: 3840 },
+ { bucket: "4K", width: 3840, height: 2160 },
+ { bucket: "4K", width: 3840, height: 1632 },
];
const GRSAI_GPT_IMAGE_SIZE_VALUES = new Set(
diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts
index 003ca12de..a3d89154f 100644
--- a/lib/ai/providers/alibaba-image.ts
+++ b/lib/ai/providers/alibaba-image.ts
@@ -50,6 +50,9 @@ interface AlibabaImageResponse {
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
const ALIBABA_MIN_SIZE = 512;
const ALIBABA_MAX_SIZE = 2048;
+const ALIBABA_MAX_INPUT_SIDE = 4096;
+const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024;
+const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 1024 * 1024;
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
@@ -57,6 +60,68 @@ function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
return "2K";
}
+function getAlibabaMaxInputBytes(modelId: string) {
+ const normalized = modelId.trim().toLowerCase();
+ if (normalized.includes("qwen-image")) {
+ return ALIBABA_QWEN_MAX_INPUT_BYTES;
+ }
+ return ALIBABA_DEFAULT_MAX_INPUT_BYTES;
+}
+
+function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
+ return buffer.buffer.slice(
+ buffer.byteOffset,
+ buffer.byteOffset + buffer.byteLength
+ ) as ArrayBuffer;
+}
+
+function createAbortSignal(timeoutMs: number) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ return {
+ signal: controller.signal,
+ clear: () => clearTimeout(timeoutId),
+ };
+}
+
+async function fetchImageBufferWithRetry(
+ imageUrl: string,
+ timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
+ attempts = 2
+): Promise<{ buffer: Buffer; contentType: string | null }> {
+ let lastError: unknown = null;
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ const ctx = createAbortSignal(timeoutMs);
+ try {
+ const response = await fetch(imageUrl, {
+ cache: "no-store",
+ redirect: "follow",
+ signal: ctx.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to download image: ${response.status}`);
+ }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (!buffer.length) {
+ throw new Error("Downloaded image is empty");
+ }
+ return {
+ buffer,
+ contentType: response.headers.get("content-type"),
+ };
+ } catch (error) {
+ lastError = error;
+ if (attempt < attempts) {
+ await new Promise((resolve) => setTimeout(resolve, attempt * 500));
+ }
+ } finally {
+ ctx.clear();
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
+}
+
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
const safeAspect = Number.isFinite(aspectValue) && aspectValue > 0 ? aspectValue : 1;
const totalPixels = baseSize * baseSize;
@@ -97,16 +162,8 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise {
if (!/^https?:\/\//i.test(imageUrl)) return null;
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), ALIBABA_IMAGE_FETCH_TIMEOUT_MS);
try {
- const response = await fetch(imageUrl, { signal: controller.signal });
- if (!response.ok) {
- console.warn("[Alibaba Image] Failed to fetch reference image for auto size:", response.status);
- return null;
- }
-
- const buffer = Buffer.from(await response.arrayBuffer());
+ const { buffer } = await fetchImageBufferWithRetry(imageUrl);
if (!buffer.length) return null;
const metadata = await sharp(buffer).metadata();
@@ -115,8 +172,6 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise {
if (!/^https?:\/\//i.test(imageUrl)) return null;
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), ALIBABA_IMAGE_FETCH_TIMEOUT_MS);
try {
- const response = await fetch(imageUrl, { signal: controller.signal });
- if (!response.ok) {
- console.warn("[Alibaba Image] Failed to fetch reference image dimensions:", response.status);
- return null;
- }
-
- const buffer = Buffer.from(await response.arrayBuffer());
+ const { buffer } = await fetchImageBufferWithRetry(imageUrl);
if (!buffer.length) return null;
const metadata = await sharp(buffer).metadata();
@@ -148,8 +195,6 @@ async function inferImageDimensionsFromImageUrl(
} catch (error) {
console.warn("[Alibaba Image] Failed to infer image dimensions from reference image:", error);
return null;
- } finally {
- clearTimeout(timeoutId);
}
}
@@ -219,6 +264,124 @@ function generateFilename(prefix: string, extension: string): string {
return `${prefix}-${timestamp}-${random}.${extension}`;
}
+async function uploadPreparedAlibabaInputImage(
+ buffer: Buffer,
+ extension: "png" | "jpg"
+): Promise {
+ const filename = generateFilename("alibaba-input", extension);
+ const result = await uploadFile(filename, bufferToArrayBuffer(buffer));
+ console.log(`[Alibaba Image] Prepared input image uploaded to: ${result.url}`);
+ return result.url;
+}
+
+async function prepareAlibabaInputImageUrl(
+ imageUrl: string,
+ modelId: string
+): Promise {
+ const normalizedUrl = String(imageUrl || "").trim();
+ if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
+
+ const maxInputBytes = getAlibabaMaxInputBytes(modelId);
+ const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
+ let metadata: sharp.Metadata;
+ try {
+ metadata = await sharp(buffer, { failOn: "none" }).metadata();
+ } catch (error) {
+ throw new Error(
+ `输入图片无法识别,请转换为 JPG/PNG 后重试: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+
+ const format = String(metadata.format || "").toLowerCase();
+ const type = String(contentType || "").toLowerCase();
+ const cleanUrl = normalizedUrl.split("?")[0]?.toLowerCase() || "";
+ const isGif = format === "gif" || type.includes("gif") || cleanUrl.endsWith(".gif");
+ const isTooLarge = buffer.length > maxInputBytes;
+ const isTooWide = Number(metadata.width || 0) > ALIBABA_MAX_INPUT_SIDE;
+ const isTooTall = Number(metadata.height || 0) > ALIBABA_MAX_INPUT_SIDE;
+
+ if (!isGif && !isTooLarge && !isTooWide && !isTooTall) {
+ return normalizedUrl;
+ }
+
+ console.warn("[Alibaba Image] Normalizing input image before provider request:", {
+ bytes: buffer.length,
+ contentType,
+ format,
+ height: metadata.height,
+ isGif,
+ isTooLarge,
+ maxInputBytes,
+ modelId,
+ sourceHost: (() => {
+ try {
+ return new URL(normalizedUrl).host;
+ } catch {
+ return "";
+ }
+ })(),
+ width: metadata.width,
+ });
+
+ const basePipeline = sharp(buffer, {
+ animated: false,
+ failOn: "none",
+ })
+ .rotate()
+ .resize(ALIBABA_MAX_INPUT_SIDE, ALIBABA_MAX_INPUT_SIDE, {
+ fit: "inside",
+ withoutEnlargement: true,
+ });
+
+ let prepared = await basePipeline.png({ compressionLevel: 8 }).toBuffer();
+ let extension: "png" | "jpg" = "png";
+
+ if (prepared.length > maxInputBytes) {
+ prepared = await sharp(buffer, { animated: false, failOn: "none" })
+ .rotate()
+ .resize(ALIBABA_MAX_INPUT_SIDE, ALIBABA_MAX_INPUT_SIDE, {
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ .jpeg({ quality: 88, mozjpeg: true })
+ .toBuffer();
+ extension = "jpg";
+ }
+
+ if (prepared.length > maxInputBytes) {
+ prepared = await sharp(buffer, { animated: false, failOn: "none" })
+ .rotate()
+ .resize(3072, 3072, {
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ .jpeg({ quality: 82, mozjpeg: true })
+ .toBuffer();
+ extension = "jpg";
+ }
+
+ if (prepared.length > maxInputBytes) {
+ prepared = await sharp(buffer, { animated: false, failOn: "none" })
+ .rotate()
+ .resize(2048, 2048, {
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ .jpeg({ quality: 76, mozjpeg: true })
+ .toBuffer();
+ extension = "jpg";
+ }
+
+ if (prepared.length > maxInputBytes) {
+ const limitMb = Math.floor(maxInputBytes / 1024 / 1024);
+ throw new Error(`输入图片压缩后仍超过 ${limitMb}MB,请先压缩图片后重试`);
+ }
+
+ return uploadPreparedAlibabaInputImage(prepared, extension);
+}
+
/**
* 下载图片并上传到 S3
*/
@@ -229,12 +392,7 @@ async function downloadAndUploadToS3(
try {
console.log("[Alibaba Image] Downloading image from:", imageUrl.substring(0, 100) + "...");
- const response = await fetch(imageUrl);
- if (!response.ok) {
- throw new Error(`Failed to download image: ${response.status}`);
- }
-
- const buffer = Buffer.from(await response.arrayBuffer());
+ const { buffer } = await fetchImageBufferWithRetry(imageUrl, 30000, 3);
console.log(`[Alibaba Image] Downloaded image, size: ${buffer.length} bytes`);
// 创建缩略图
@@ -242,7 +400,7 @@ async function downloadAndUploadToS3(
// 上传原图(Buffer 转 ArrayBuffer)
const originalFilename = generateFilename(`${prefix}-orig`, "png");
- const originalResult = await uploadFile(originalFilename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
+ const originalResult = await uploadFile(originalFilename, bufferToArrayBuffer(buffer));
console.log(`[Alibaba Image] Uploaded original to: ${originalResult.url}`);
// 上传缩略图
@@ -271,7 +429,7 @@ async function uploadBase64ToS3(base64Data: string): Promise {
const buffer = Buffer.from(base64Clean, "base64");
const filename = generateFilename("alibaba-ref", "png");
- const result = await uploadFile(filename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
+ const result = await uploadFile(filename, bufferToArrayBuffer(buffer));
console.log(`[Alibaba Image] Uploaded reference image to: ${result.url}`);
return result.url;
} catch (error) {
@@ -513,8 +671,13 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
throw new Error("No prompt found in message");
}
+ const preparedImageUrls: string[] = [];
+ for (const imageUrl of Array.from(new Set(imageUrls)).slice(0, 3)) {
+ preparedImageUrls.push(await prepareAlibabaInputImageUrl(imageUrl, activeModelId));
+ }
+
// 验证:qwen-image-edit 系列模型必须有输入图片
- if (imageUrls.length === 0) {
+ if (preparedImageUrls.length === 0) {
const modelName = options.modelDisplayName || modelId;
throw new Error(`「${modelName}」是图片编辑模型,需要至少上传一张图片才能进行编辑。如需纯文生图,请使用其他生图模型。`);
}
@@ -574,7 +737,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
if (aspectRatio === "auto") {
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
- const referenceImageUrl = imageUrls[0];
+ const referenceImageUrl = preparedImageUrls[0];
const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl);
if (inferredDimensions) {
params.size = calculateAlibabaSizeFromDimensions(
@@ -613,10 +776,10 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
}
// 根据图片数量确定编辑模式
- const editMode = imageUrls.length === 1 ? "单图编辑" : `多图融合 (${imageUrls.length}张)`;
+ const editMode = preparedImageUrls.length === 1 ? "单图编辑" : `多图融合 (${preparedImageUrls.length}张)`;
console.log("[Alibaba Image] 编辑模式:", editMode);
console.log("[Alibaba Image] Prompt:", prompt.substring(0, 100) + (prompt.length > 100 ? "..." : ""));
- console.log("[Alibaba Image] Input images:", imageUrls.length);
+ console.log("[Alibaba Image] Input images:", preparedImageUrls.length);
console.log("[Alibaba Image] Final params:", params);
// 发送请求
@@ -625,7 +788,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
apiKey,
activeModelId,
prompt,
- imageUrls,
+ preparedImageUrls,
params
);
diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts
index 2bf11349b..bee405a58 100644
--- a/lib/ai/providers/fal-image.ts
+++ b/lib/ai/providers/fal-image.ts
@@ -42,7 +42,7 @@ const DEFAULT_ASPECT_RATIO = "auto";
const DEFAULT_RESOLUTION = "1K";
const DEFAULT_OUTPUT_FORMAT = "png";
const DEFAULT_SAFETY_TOLERANCE = "4";
-const DEFAULT_TIMEOUT_MS = 180000;
+const DEFAULT_TIMEOUT_MS = 300000;
const FAL_NANO_BANANA_MODEL_IDS = new Set([
"fal-ai/nano-banana",
"fal-ai/nano-banana-2",
@@ -70,7 +70,10 @@ const FAL_ASPECT_RATIO_VALUES = new Set([
"3:4",
"2:3",
"9:16",
- "9:21",
+ "4:1",
+ "1:4",
+ "8:1",
+ "1:8",
]);
const FAL_IMAGE_SIZE_PRESETS = new Set([
"square_hd",
@@ -588,6 +591,34 @@ function summarizeFalError(error: any) {
};
}
+function stringifyFalErrorDetail(value: unknown) {
+ if (!value) return "";
+ if (typeof value === "string") return value;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+function createDetailedFalError(error: unknown) {
+ const summary = summarizeFalError(error);
+ const detail = stringifyFalErrorDetail(summary.body);
+ const message = [
+ "FAL request failed",
+ summary.status ? `status=${summary.status}` : "",
+ summary.message ? `message=${summary.message}` : "",
+ detail ? `body=${detail}` : "",
+ ]
+ .filter(Boolean)
+ .join("; ")
+ .slice(0, 1800);
+ const detailedError = new Error(message);
+ detailedError.name = "FalImageProviderError";
+ (detailedError as Error & { cause?: unknown }).cause = error;
+ return detailedError;
+}
+
function createFalClientFromApiKey(apiKey: string) {
return import("@fal-ai/client").then(({ createFalClient }) =>
createFalClient({
@@ -663,7 +694,9 @@ function buildFalRequestPayload(params: {
};
if (isFalNanoBananaModelId(falEndpointId)) {
- input.aspect_ratio = aspectRatio;
+ input.aspect_ratio = FAL_ASPECT_RATIO_VALUES.has(aspectRatio)
+ ? aspectRatio
+ : DEFAULT_ASPECT_RATIO;
input.resolution = resolution;
input.limit_generations = true;
} else if (isFalFluxKontextDevModelId(falEndpointId) && imageUrls.length > 0) {
@@ -698,6 +731,10 @@ function buildFalRequestPayload(params: {
if (typeof params.imageParams?.enableWebSearch === "boolean") {
input.enable_web_search = params.imageParams.enableWebSearch;
}
+
+ if (typeof params.imageParams?.syncMode === "boolean") {
+ input.sync_mode = params.imageParams.syncMode;
+ }
}
return {
@@ -862,7 +899,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
});
} catch (error) {
console.error("[Fal Image] Request failed:", summarizeFalError(error));
- throw error;
+ throw createDetailedFalError(error);
}
const text = await normalizeFalResultToMarkdown({
diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts
index 426b79ce9..58030389f 100644
--- a/lib/ai/providers/gemini-image.ts
+++ b/lib/ai/providers/gemini-image.ts
@@ -798,39 +798,19 @@ async function sendGeminiRequest(
? await inferImageAspectRatioFromUrl(processedUrls[0])
: null;
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;
- // 兼容不同网关:优先使用 OpenAI 原生 size;对容易兼容失败的比例(如 1:1)不再附带 aspect 变体参数。
+ // Grsai GPT-image-2-vip 只接受固定像素 size;继续传比例或档位字段会触发
+ // “does not support 1:1/3:4 parameter, please use pixel value input”。
requestBody.size = resolvedSize;
- requestBody.imageSize = imageParams?.imageSize;
- requestBody.image_size = imageParams?.imageSize;
-
- if (shouldPassAspectRatio) {
- requestBody.aspectRatio = normalizedRequestedAspectRatio;
- requestBody.aspect_ratio = normalizedRequestedAspectRatio;
- requestBody.aspectRatioRaw = requestedAspectRatio;
- }
requestBody.shutProgress = false;
if (processedUrls.length > 0) {
requestBody.image = processedUrls[0];
- requestBody.imageUrls = [processedUrls[0]];
+ requestBody.imageUrls = processedUrls;
}
logger.debug(
- `${apiLogPrefix} GPT image payload mapping: requestedAspectRatio=${requestedAspectRatio}, normalizedRequestedAspectRatio=${normalizedRequestedAspectRatio || "n/a"}, resolvedAspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"}, resolvedSize=${resolvedSize}`
+ `${apiLogPrefix} GPT image payload mapping: requestedAspectRatio=${requestedAspectRatio}, resolvedAspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"}, resolvedSize=${resolvedSize}`
);
if (inferredAspectRatio) {
logger.debug(`${apiLogPrefix} Inferred reference aspect ratio: ${inferredAspectRatio}`);
diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts
index 73bb58feb..28a3a7a7a 100644
--- a/lib/ai/runtime/model-health.ts
+++ b/lib/ai/runtime/model-health.ts
@@ -90,7 +90,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("model not exist") ||
normalized.includes("model_not_found") ||
normalized.includes("model not found") ||
- normalized.includes("unknown model")
+ normalized.includes("unknown model") ||
+ normalized.includes("model name") && normalized.includes("not supported")
) {
return {
eventType: "invalid_model",
@@ -102,7 +103,11 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("insufficient balance") ||
+ normalized.includes("credits insufficient") ||
normalized.includes("quota") ||
+ normalized.includes("余额") ||
+ normalized.includes("额度") ||
+ normalized.includes("预扣费额度") ||
normalized.includes("exceeded_current_quota") ||
normalized.includes("pre_consume_token_quota_failed") ||
normalized.includes("token quota is not enough")
@@ -117,6 +122,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("timeout") ||
+ normalized.includes("超时") ||
+ normalized.includes("terminated") ||
normalized.includes("timed out") ||
normalized.includes("body timeout") ||
normalized.includes("headers timeout")
@@ -140,7 +147,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("no available channel") ||
normalized.includes("no supported channel") ||
normalized.includes("service unavailable") ||
- normalized.includes("fetch failed")
+ normalized.includes("fetch failed") ||
+ normalized.includes("econnreset")
) {
return {
eventType: "unavailable",
diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts
index d77c59cf3..e05ec6726 100644
--- a/lib/canvas/generate-image.ts
+++ b/lib/canvas/generate-image.ts
@@ -466,7 +466,10 @@ export async function generateCanvasImage(
).slice(0, 12);
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
- const effectiveReferenceImages = normalizedReferenceImages;
+ const effectiveReferenceImages =
+ reachableReferenceImages.length > 0
+ ? reachableReferenceImages
+ : normalizedReferenceImages;
if (unreachableReferenceImages.length > 0) {
console.warn("[Canvas Generate Image] Local reachability check failed for some reference images:", {
count: unreachableReferenceImages.length,
@@ -857,6 +860,9 @@ export async function generateCanvasImage(
imageSize,
imageCount,
requestedReferenceImageCount: normalizedReferenceImages.length,
+ reachableReferenceImageCount: reachableReferenceImages.length,
+ effectiveReferenceImageCount: effectiveReferenceImages.length,
+ droppedReferenceImageCount: unreachableReferenceImages.length,
...debugMeta,
},
}).catch((healthError) => {
diff --git a/lib/db/migrations/0086_external_image_log_model_delete_set_null.sql b/lib/db/migrations/0086_external_image_log_model_delete_set_null.sql
new file mode 100644
index 000000000..fce3da302
--- /dev/null
+++ b/lib/db/migrations/0086_external_image_log_model_delete_set_null.sql
@@ -0,0 +1,13 @@
+DO $$
+BEGIN
+ ALTER TABLE "ExternalImageApiRequestLog"
+ DROP CONSTRAINT IF EXISTS "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk";
+
+ ALTER TABLE "ExternalImageApiRequestLog"
+ ADD CONSTRAINT "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk"
+ FOREIGN KEY ("modelDbId")
+ REFERENCES "public"."AiModel"("id")
+ ON DELETE SET NULL
+ ON UPDATE NO ACTION;
+END
+$$;
diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json
index 22ee22139..236089b9d 100644
--- a/lib/db/migrations/meta/_journal.json
+++ b/lib/db/migrations/meta/_journal.json
@@ -505,6 +505,13 @@
"when": 1780075200000,
"tag": "0085_add_system_setting_snapshots",
"breakpoints": true
+ },
+ {
+ "idx": 72,
+ "version": "7",
+ "when": 1781174400000,
+ "tag": "0086_external_image_log_model_delete_set_null",
+ "breakpoints": true
}
]
}
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index ddd37cf44..c2e03fcd5 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -74,6 +74,7 @@ import {
type MediaAsset,
mediaAssetRelation,
type MediaAssetRelation,
+ externalImageApiRequestLog,
type AgentPreset,
type AgentPresetCategory,
type AgentPresetExecutionSnapshot,
@@ -4354,8 +4355,54 @@ export async function updateAiModel({
export async function deleteAiModel({ id }: { id: string }) {
try {
- return await db.delete(aiModel).where(eq(aiModel.id, id)).returning();
+ return await db.transaction(async (tx) => {
+ const [modelToDelete] = await tx
+ .select({
+ id: aiModel.id,
+ modelId: aiModel.modelId,
+ })
+ .from(aiModel)
+ .where(eq(aiModel.id, id))
+ .limit(1);
+
+ if (!modelToDelete) {
+ return [];
+ }
+
+ await tx
+ .update(externalImageApiRequestLog)
+ .set({ modelDbId: null })
+ .where(eq(externalImageApiRequestLog.modelDbId, id));
+
+ await tx
+ .update(aiModel)
+ .set({ parentModelId: null })
+ .where(eq(aiModel.parentModelId, id));
+
+ await tx
+ .update(aiModel)
+ .set({ fallbackModelId1: null })
+ .where(
+ inArray(aiModel.fallbackModelId1, [
+ modelToDelete.id,
+ modelToDelete.modelId,
+ ])
+ );
+
+ await tx
+ .update(aiModel)
+ .set({ fallbackModelId2: null })
+ .where(
+ inArray(aiModel.fallbackModelId2, [
+ modelToDelete.id,
+ modelToDelete.modelId,
+ ])
+ );
+
+ return await tx.delete(aiModel).where(eq(aiModel.id, id)).returning();
+ });
} catch (_error) {
+ console.error("deleteAiModel error:", _error);
throw new ChatSDKError(
"bad_request:database",
"Failed to delete AI model"
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 406520003..2acecf1d8 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -600,7 +600,9 @@ export const externalImageApiRequestLog = pgTable("ExternalImageApiRequestLog",
apiKeyId: varchar("apiKeyId", { length: 64 }),
apiKeyName: varchar("apiKeyName", { length: 128 }),
ownerUserId: uuid("ownerUserId").references(() => user.id),
- modelDbId: uuid("modelDbId").references(() => aiModel.id),
+ modelDbId: uuid("modelDbId").references(() => aiModel.id, {
+ onDelete: "set null",
+ }),
modelRuntimeId: varchar("modelRuntimeId", { length: 128 }),
modelDisplayName: varchar("modelDisplayName", { length: 128 }),
prompt: text("prompt"),