fix: improve image generation timeout failover
This commit is contained in:
parent
25df2d248b
commit
b032774e8e
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
2
app/api/external/image/generate/route.ts
vendored
2
app/api/external/image/generate/route.ts
vendored
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user