fix: improve image generation timeout failover

This commit is contained in:
134 2026-06-22 14:38:59 +08:00
parent 25df2d248b
commit b032774e8e
21 changed files with 343 additions and 60 deletions

View File

@ -109,8 +109,93 @@ export type ImageFallbackCandidate = {
provider: any;
configured: boolean;
sameProvider: boolean;
nameSimilarity: number;
sameName: boolean;
};
const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getModelRefKeys(item: { model?: any } | null | undefined) {
return [
String(item?.model?.id || "").trim(),
String(item?.model?.modelId || "").trim(),
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: { model?: any } | null | undefined,
candidate: { model?: any } | null | undefined
) {
const sourceNames = [
source?.model?.displayName,
source?.model?.modelId,
].filter(Boolean);
const candidateNames = [
candidate?.model?.displayName,
candidate?.model?.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function resolveCurrentImageModelEntry(
allModels: Array<{ model: any; provider: any }>,
refs: string[]
) {
const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean));
if (normalizedRefs.size === 0) return null;
return (
allModels.find((item) =>
getModelRefKeys(item).some((key) => normalizedRefs.has(key))
) || null
);
}
function messagePartHasImageInput(part: any) {
if (!part || typeof part !== "object") return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") {
@ -171,6 +256,10 @@ export function buildImageFallbackCandidates(
}
) {
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
const currentEntry = resolveCurrentImageModelEntry(allModels, [
currentModelId || "",
currentModelKey || "",
]);
const configuredRefs = configuredFallbackIds
.map((item) => String(item || "").trim())
.filter(Boolean);
@ -210,6 +299,7 @@ export function buildImageFallbackCandidates(
const candidates: ImageFallbackCandidate[] = [];
const seenModelIds = new Set<string>();
const candidateOrder = new Map<string, number>();
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
if (!item?.model) return;
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
@ -217,11 +307,15 @@ export function buildImageFallbackCandidates(
const modelId = String(item.model.id || "");
if (!modelId || seenModelIds.has(modelId)) return;
seenModelIds.add(modelId);
candidateOrder.set(modelId, candidateOrder.size);
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
candidates.push({
model: item.model,
provider: item.provider,
configured,
sameProvider,
nameSimilarity,
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
});
};
@ -235,7 +329,24 @@ export function buildImageFallbackCandidates(
.forEach((item) => pushCandidate(item, false));
}
return candidates;
if (currentEntry) {
allModels
.filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD)
.forEach((item) => pushCandidate(item, false));
}
return candidates.sort((left, right) => {
if (left.sameName !== right.sameName) return left.sameName ? -1 : 1;
if (left.configured !== right.configured) return left.configured ? -1 : 1;
if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return (
(candidateOrder.get(String(left.model.id || "")) ?? 0) -
(candidateOrder.get(String(right.model.id || "")) ?? 0)
);
});
}
export async function generateImageWithFailoverCandidate(params: {
@ -302,10 +413,15 @@ export function getImageCrossProviderCandidates(
allModels: any[],
options: {
currentModelId: string;
currentModelKey?: string;
currentProviderId?: string;
providerPriority: Record<string, number>;
}
) {
const currentEntry = resolveCurrentImageModelEntry(allModels, [
options.currentModelId,
options.currentModelKey || "",
]);
return allModels
.filter((m) => {
if (
@ -328,10 +444,37 @@ export function getImageCrossProviderCandidates(
}
return true;
})
.map((item, index) => {
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
return {
...item,
__imageFailoverIndex: index,
__imageFailoverNameSimilarity: nameSimilarity,
__imageFailoverSameName:
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
};
})
.sort((a, b) => {
if (a.__imageFailoverSameName !== b.__imageFailoverSameName) {
return a.__imageFailoverSameName ? -1 : 1;
}
if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) {
return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity;
}
const left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
if (sortOrderDiff !== 0) return sortOrderDiff;
return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0);
})
.map((item) => {
const {
__imageFailoverIndex,
__imageFailoverNameSimilarity,
__imageFailoverSameName,
...candidate
} = item;
return candidate;
});
}

View File

@ -598,7 +598,7 @@ function mergeVideoParamsWithAgentRecommendation(params: {
return Object.keys(merged).length > 0 ? (merged as ChatVideoParams) : undefined;
}
export const maxDuration = 600; // 10 分钟,用于支持图片编辑等耗时任务
export const maxDuration = 1200; // 20 分钟,支持单模型 10 分钟 + 故障迁移备用模型等待
let globalStreamContext: ResumableStreamContext | null = null;
const inFlightChatRequests = new Map<string, number>();
@ -6506,6 +6506,7 @@ export async function POST(request: Request) {
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentModelKey: actualModelId,
currentProviderId: currentModelConfig?.providerId,
providerPriority,
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
@ -7214,6 +7215,7 @@ export async function POST(request: Request) {
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId,
providerPriority,
});
@ -7640,6 +7642,7 @@ export async function POST(request: Request) {
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentModelKey: actualModelId,
currentProviderId: volcanoModelConfig?.providerId,
providerPriority,
});
@ -8409,7 +8412,7 @@ export async function POST(request: Request) {
try {
const tryGenerateWithOpenAICompatModel = async (modelToUse: any) => {
const imageRequestTimeoutMs = 180_000;
const imageRequestTimeoutMs = 600_000;
const generated = await generateText({
model: modelToUse,
system: systemPrompt({ selectedChatModel: finalModelId, requestHints }),
@ -8508,6 +8511,7 @@ export async function POST(request: Request) {
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentModelKey: actualModelId,
currentProviderId: currentModelConfig?.providerId,
providerPriority,
});
@ -9292,7 +9296,7 @@ export async function POST(request: Request) {
}
// 常规文本模型使用 streamText
const streamRequestTimeoutMs = resolvedModelSupportsImageGen ? 180_000 : 90_000;
const streamRequestTimeoutMs = resolvedModelSupportsImageGen ? 600_000 : 90_000;
const shouldDisableSmoothStream =
resolvedModelSupportsImageGen || isVolcanoVideoModel;

View File

@ -16,7 +16,7 @@ import {
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 600;
export const maxDuration = 1200;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;

View File

@ -21,7 +21,7 @@ import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
export const maxDuration = 1200;
const logger = createLogger("canvas-text-edit");
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;

View File

@ -19,7 +19,7 @@ import {
} from "@/lib/external-image-api/service";
export const runtime = "nodejs";
export const maxDuration = 360;
export const maxDuration = 1200;
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;

View File

@ -770,7 +770,7 @@ type DirectDetailReviewResult = {
};
const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const AGENT_IMAGE_TASK_TIMEOUT_MS = 20 * 60 * 1000;
const AGENT_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000;
const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4;
function sleep(ms: number) {

View File

@ -435,7 +435,7 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([
"输出设置",
]);
const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 20 * 60 * 1000;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000;
const CANVAS_GENERATION_NODE_GAP = 56;
const CANVAS_GENERATION_STACK_GAP = 28;
const CANVAS_GENERATION_COLLISION_GAP = 32;

View File

@ -100,6 +100,13 @@ const GENERATION_STATUS_TITLES = [
"正在完成本次生成",
"正在即将展示结果",
];
const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000;
const LONG_WAIT_STATUS_TITLES = [
"服务繁忙,排队处理中",
"高峰期生成较慢,请耐心等候",
"仍在保持连接,等待模型返回",
"结果返回后会自动展示",
];
const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => {
const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400];
@ -136,6 +143,21 @@ function getStatusTitleState(elapsedMs: number, initialTitle?: string) {
};
}
function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) {
const titles = initialTitle
? [initialTitle, ...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle)]
: LONG_WAIT_STATUS_TITLES;
const elapsedAfterThreshold = Math.max(0, elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS);
const duration = 7600;
const index = Math.floor(elapsedAfterThreshold / duration) % titles.length;
return {
index,
title: titles[index] || titles[0] || PHASE_TITLES.processing,
elapsedInTitle: elapsedAfterThreshold % duration,
};
}
function getTypewriterText(text: string, elapsedInTitle: number, index: number) {
const chars = Array.from(text);
if (chars.length === 0) return "";
@ -383,7 +405,12 @@ export function GenerationDotPlaceholder({
const resolvedProgress = clampProgress(
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
);
const statusTitleState = getStatusTitleState(elapsedMs, title);
const isLongWaitTitle =
(phase === "processing" || phase === "finalizing") &&
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
const statusTitleState = isLongWaitTitle
? getLongWaitTitleState(elapsedMs, title)
: getStatusTitleState(elapsedMs, title);
const rotatingTitle = getTypewriterText(
statusTitleState.title,
statusTitleState.elapsedInTitle,

View File

@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
import { ChevronDown, CircleDot } from "lucide-react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
@ -62,9 +62,13 @@ const LONG_PROCESSING_HINTS = [
"正在保存图片并同步到对话。",
"正在准备把结果显示到画布。",
];
const LONG_WAIT_SECONDS = 5 * 60;
const LONG_WAIT_HINT =
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT;
const index = Math.min(
LONG_PROCESSING_HINTS.length - 1,
Math.floor(Math.max(0, elapsedSeconds) / 15)
@ -95,19 +99,50 @@ export const ImageGenerationLoading = memo(
modelName,
providerType,
phase = "processing",
elapsedSeconds = 0,
elapsedSeconds,
queuePosition,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs,
}: ImageGenerationLoadingProps) => {
const shouldTrackElapsed =
phase === "queued" || phase === "processing" || phase === "finalizing";
const [internalElapsedSeconds, setInternalElapsedSeconds] = useState(0);
useEffect(() => {
if (typeof elapsedSeconds === "number") return;
if (!shouldTrackElapsed) {
setInternalElapsedSeconds(0);
return;
}
const startedAt = Date.now();
setInternalElapsedSeconds(0);
const timer = window.setInterval(() => {
setInternalElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => window.clearInterval(timer);
}, [elapsedSeconds, modelName, phase, providerType, shouldTrackElapsed]);
const count = Math.min(Math.max(imageCount, 1), maxImages);
const resolvedModelName =
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
const estimateRangeLabel = providerType === "grsai-gpt-image" ? "2~5分钟" : "60~120秒";
const resolvedElapsedSeconds =
typeof elapsedSeconds === "number" ? elapsedSeconds : internalElapsedSeconds;
const isLongWait =
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
(phase === "processing" || phase === "finalizing");
const estimateRangeLabel = isLongWait
? "单次最长约10分钟"
: providerType === "grsai-gpt-image"
? "2~5分钟"
: "60~120秒";
const elapsedLabel =
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`;
const phaseHint = getLongProcessingHint(phase, elapsedSeconds);
resolvedElapsedSeconds > 0
? `已等待 ${resolvedElapsedSeconds}s`
: `预计 ${estimateRangeLabel}`;
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
const queueLine =
phase === "queued" && Number.isFinite(queuePosition)
? Math.max(0, Number(queuePosition || 0)) > 0
@ -149,6 +184,7 @@ export const ImageGenerationLoading = memo(
}
compact={count > 1}
phase={phase}
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
/>
<div className="space-y-0.5 text-[#9aa3b2]">
@ -157,7 +193,7 @@ export const ImageGenerationLoading = memo(
</div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
{(phase !== "processing" || elapsedSeconds > 0) && (
{(phase !== "processing" || resolvedElapsedSeconds > 0) && (
<div className="text-[13px]">
{phaseHint} {elapsedLabel}
</div>

View File

@ -49,6 +49,7 @@ interface AlibabaImageResponse {
}
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
const ALIBABA_MIN_SIZE = 512;
const ALIBABA_MAX_SIZE = 2048;
const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2;
@ -451,24 +452,36 @@ async function sendAlibabaImageRequest(
console.log("[Alibaba Image] Model:", modelId);
console.log("[Alibaba Image] Parameters:", JSON.stringify(requestBody.parameters));
const response = await fetch(baseURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok || data.code) {
console.error("[Alibaba Image] API Error:", data);
throw new Error(data.message || `API request failed: ${response.status}`);
const ctx = createAbortSignal(ALIBABA_IMAGE_GENERATION_TIMEOUT_MS);
try {
const response = await fetch(baseURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal: ctx.signal,
});
const data = await response.json();
if (!response.ok || data.code) {
console.error("[Alibaba Image] API Error:", data);
throw new Error(data.message || `API request failed: ${response.status}`);
}
console.log("[Alibaba Image] Response received, request_id:", data.request_id);
return data;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("阿里图片生成请求超时(600秒),请稍后重试");
}
throw error;
} finally {
ctx.clear();
}
console.log("[Alibaba Image] Response received, request_id:", data.request_id);
return data;
}
/**

View File

@ -42,7 +42,7 @@ type BflSubmitResponse = {
detail?: string;
};
const DEFAULT_TIMEOUT_MS = 180_000;
const DEFAULT_TIMEOUT_MS = 600_000;
const TRAILING_SLASHES_REGEX = /\/+$/;
const TRAILING_V1_REGEX = /\/v1$/i;
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;

View File

@ -46,7 +46,7 @@ const DEFAULT_ASPECT_RATIO = "auto";
const DEFAULT_RESOLUTION = "1K";
const DEFAULT_OUTPUT_FORMAT = "png";
const DEFAULT_SAFETY_TOLERANCE = "4";
const DEFAULT_TIMEOUT_MS = 300000;
const DEFAULT_TIMEOUT_MS = 600000;
const FAL_GENERIC_MAX_EDGE = 4096;
const FAL_GENERIC_MAX_PIXELS = 4096 * 4096;
const GPT_IMAGE_2_MAX_EDGE = 3840;

View File

@ -640,8 +640,8 @@ function formatImageResponse(
const GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS = 30000;
const GRSAI_NANO_BANANA_POLL_TIMEOUT_MS = 30000;
const GRSAI_NANO_BANANA_POLL_INTERVAL_MS = 5000;
const GRSAI_GPT_IMAGE_TIMEOUT_MS = 12 * 60 * 1000;
const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 14 * 60 * 1000;
const GRSAI_GPT_IMAGE_TIMEOUT_MS = 10 * 60 * 1000;
const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 10 * 60 * 1000;
function isRecord(value: unknown): value is Record<string, any> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@ -1001,7 +1001,7 @@ async function submitAndPollGrsaiNanoBanana(params: {
webHook: "-1",
};
const imageSize = String(params.imageParams?.imageSize || "").toUpperCase();
const pollTimeoutMs = imageSize === "4K" ? 600000 : 300000;
const pollTimeoutMs = 600000;
logger.debug(`${params.apiLogPrefix} Submitting async Nano Banana task:`, {
model: body.model,
@ -1218,7 +1218,7 @@ async function sendGeminiRequest(
}
// 根据分辨率动态设置超时时间
const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟
const timeout = 600000; // 所有生图模型最长等待 10 分钟
logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
const controller = new AbortController();

View File

@ -531,8 +531,8 @@ async function sendHuankeGeminiRequest(
console.log("[Huanke Gemini] Request body (truncated):", JSON.stringify(requestBody).substring(0, 500) + "...");
// 根据分辨率动态设置超时时间
const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000;
// 所有生图模型最长等待 10 分钟
const timeout = 600000;
console.log(`[Huanke Gemini] Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
const controller = new AbortController();

View File

@ -40,7 +40,7 @@ interface UploadedImage {
const DEFAULT_BASE_URL = "https://api.krea.ai";
const IMAGE_POLL_INTERVAL_MS = 2000;
const VIDEO_POLL_INTERVAL_MS = 5000;
const IMAGE_TIMEOUT_MS = 5 * 60 * 1000;
const IMAGE_TIMEOUT_MS = 10 * 60 * 1000;
const VIDEO_TIMEOUT_MS = 20 * 60 * 1000;
const KREA_IMAGE_ASPECT_RATIOS = [
"21:9",

View File

@ -38,7 +38,7 @@ interface MidjourneyTaskResult {
buttons?: MidjourneyTaskButton[];
}
const DEFAULT_TIMEOUT_MS = 300000;
const DEFAULT_TIMEOUT_MS = 600000;
const POLL_INTERVAL_MS = 3500;
const DEFAULT_IMAGE_SIZE = "2K";

View File

@ -27,8 +27,8 @@ interface UploadedImage {
}
const DEFAULT_IMAGE_SIZE = "2K";
const DEFAULT_TIMEOUT_MS = 300000;
const LONG_RUNNING_COMFLY_TIMEOUT_MS = 590000;
const DEFAULT_TIMEOUT_MS = 600000;
const LONG_RUNNING_COMFLY_TIMEOUT_MS = 600000;
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16;
const COMFLY_GPT_IMAGE_STABLE_EDIT_EDGE = 1536;

View File

@ -39,7 +39,8 @@ interface UploadedImage {
const RUNWAY_VERSION = "2024-11-06";
const DEFAULT_BASE_URL = "https://api.dev.runwayml.com";
const TASK_POLL_INTERVAL_MS = 5000;
const TASK_TIMEOUT_MS = 15 * 60 * 1000;
const IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000;
const VIDEO_TASK_TIMEOUT_MS = 15 * 60 * 1000;
const IMAGE_RESOLUTION_MAP: Record<string, Record<string, string>> = {
"1K": {
@ -376,8 +377,13 @@ async function createTask(baseURL: string, apiKey: string, path: string, body: R
return taskId;
}
async function waitForTaskOutput(baseURL: string, apiKey: string, taskId: string) {
const deadline = Date.now() + TASK_TIMEOUT_MS;
async function waitForTaskOutput(
baseURL: string,
apiKey: string,
taskId: string,
timeoutMs: number = VIDEO_TASK_TIMEOUT_MS
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await fetch(joinEndpoint(baseURL, `/v1/tasks/${taskId}`), {
@ -552,7 +558,7 @@ export function createRunway({ apiKey, baseURL }: RunwayConfig) {
: {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const task = await waitForTaskOutput(baseURL, apiKey, taskId, IMAGE_TASK_TIMEOUT_MS);
const uploadedImages = await uploadRunwayImages(task?.output ?? task);
const text = formatImageMarkdown(uploadedImages, imageSize);
@ -624,7 +630,7 @@ export function createRunway({ apiKey, baseURL }: RunwayConfig) {
...(typeof audio === "boolean" ? { audio } : {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const task = await waitForTaskOutput(baseURL, apiKey, taskId, VIDEO_TASK_TIMEOUT_MS);
const videoUrl = await resolveRunwayVideoUrl(task?.output ?? task);
const text = formatVideoMarkdown(videoUrl);

View File

@ -24,7 +24,7 @@ interface UploadedImage {
}
const DEFAULT_BASE_URL = "https://api.stepfun.com";
const DEFAULT_TIMEOUT_MS = 300000;
const DEFAULT_TIMEOUT_MS = 600000;
const DEFAULT_CFG_SCALE = 1.0;
const DEFAULT_STEPS = 8;
const DEFAULT_TEXT_MODE = true;

View File

@ -122,7 +122,7 @@ async function executeDirectAgentEnhanceJob(params: {
messageId: params.messageId,
sourceImageUrl: params.imageUrl,
},
timeoutMs: 20 * 60 * 1000,
timeoutMs: 10 * 60 * 1000,
headers: cookieHeader
? {
cookie: cookieHeader,

View File

@ -50,6 +50,29 @@ function getErrorMessage(error: unknown) {
return normalizeText(error);
}
function isExplicitUserCancellation(normalized: string) {
return (
normalized.includes("user abort") ||
normalized.includes("user aborted") ||
normalized.includes("user cancelled") ||
normalized.includes("user canceled") ||
normalized.includes("client aborted") ||
normalized.includes("request cancelled by user") ||
normalized.includes("request canceled by user")
);
}
function isOperationalAbort(error: unknown, normalized: string) {
if (isExplicitUserCancellation(normalized)) return false;
if (error instanceof Error && error.name === "AbortError") return true;
return (
normalized.includes("this operation was aborted") ||
normalized.includes("the operation was aborted") ||
normalized.includes("operation was aborted") ||
normalized.includes("request aborted")
);
}
export function classifyAiModelHealthFailure(error: unknown): {
eventType: AiModelHealthEventType;
severity: AiModelHealthSeverity;
@ -68,6 +91,15 @@ export function classifyAiModelHealthFailure(error: unknown): {
};
}
if (isExplicitUserCancellation(normalized)) {
return {
eventType: "error",
severity: "warning",
counted: false,
reason: "user_cancelled",
};
}
if (
normalized.includes("生成结果未通过替换质检") ||
normalized.includes("output_moderation") ||
@ -76,10 +108,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("content policy") ||
normalized.includes("safety") ||
normalized.includes("generate image failed") ||
normalized.includes("could not generate images with the given prompts and images") ||
normalized.includes("user abort") ||
normalized.includes("aborted") ||
normalized.includes("cancelled")
normalized.includes("could not generate images with the given prompts and images")
) {
return {
eventType: "error",
@ -140,6 +169,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
}
if (
isOperationalAbort(error, normalized) ||
normalized.includes("timeout") ||
normalized.includes("超时") ||
normalized.includes("524") ||
@ -183,6 +213,10 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("download") ||
normalized.includes("no image url") ||
normalized.includes("no image") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||
normalized.includes("未找到图片数据") ||
normalized.includes("图片生成失败")
) {
return {
@ -203,12 +237,32 @@ export function classifyAiModelHealthFailure(error: unknown): {
export function shouldFailoverAiModelError(error: unknown) {
const classification = classifyAiModelHealthFailure(error);
if (!classification.counted) return false;
const normalized = getErrorMessage(error).toLowerCase();
if (classification.eventType === "timeout" || classification.eventType === "download_error") {
if (classification.reason === "user_cancelled" || classification.reason === "non_provider_failure") {
return false;
}
if (
classification.eventType === "timeout" ||
classification.reason === "transport_failure_no_provider_response"
) {
return true;
}
if (classification.eventType === "download_error") {
return (
normalized.includes("no image") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||
normalized.includes("未找到图片数据") ||
normalized.includes("图片生成失败")
);
}
if (!classification.counted) return false;
if (classification.eventType === "invalid_model" || classification.eventType === "quota") {
return true;
}