fix(video): harden volcano model routing and request retries
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-18 22:28:08 +08:00
parent b1ac60a629
commit 46b3f4b081
3 changed files with 379 additions and 118 deletions

View File

@ -238,6 +238,103 @@ function startStreamKeepalive(writer: StreamWriter, label: string, intervalMs =
return () => clearInterval(timer);
}
type VideoModeType = "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
const normalized = modelId.toLowerCase();
if (normalized.includes("lite-t2v")) {
return ["text-to-video"];
}
if (normalized.includes("lite-i2v")) {
return ["first-frame", "first-last-frame", "reference-images"];
}
if (normalized.includes("pro-fast")) {
return ["text-to-video", "first-frame"];
}
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
return ["text-to-video", "first-frame", "first-last-frame"];
}
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
function getSupportedVideoModesForModel(model: any): VideoModeType[] {
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[])
.filter((mode): mode is VideoModeType =>
mode === "text-to-video" ||
mode === "first-frame" ||
mode === "first-last-frame" ||
mode === "reference-images"
);
if (capabilityModes.length > 0) {
return capabilityModes;
}
const modelId = String(model?.modelId || "");
return inferSupportedVideoModesByModelId(modelId);
}
function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
const normalized = modelId.toLowerCase();
const isProFast = normalized.includes("pro-fast");
const isLiteT2V = normalized.includes("lite-t2v");
const isLiteI2V = normalized.includes("lite-i2v");
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
const isV15Pro = normalized.includes("seedance-1-5-pro");
switch (mode) {
case "first-last-frame":
if (isV15Pro) return 150;
if (isPro) return 120;
if (isLiteI2V) return 90;
if (isProFast) return -1000;
if (isLiteT2V) return -1000;
return 10;
case "first-frame":
if (isV15Pro) return 140;
if (isProFast) return 130;
if (isPro) return 120;
if (isLiteI2V) return 100;
if (isLiteT2V) return 20;
return 10;
case "reference-images":
if (isV15Pro) return 150;
if (isLiteI2V) return 140;
if (isPro) return 110;
if (isProFast || isLiteT2V) return -1000;
return 10;
case "text-to-video":
default:
if (isV15Pro) return 140;
if (isLiteT2V) return 130;
if (isProFast) return 120;
if (isPro) return 100;
if (isLiteI2V) return 80;
return 10;
}
}
function selectBestVideoSubModelForMode(
subModels: Array<{ model: any }>,
mode: VideoModeType,
): { model: any } | null {
const compatible = subModels.filter(({ model }) => {
const supportedModes = getSupportedVideoModesForModel(model);
return supportedModes.includes(mode);
});
const candidates = compatible.length > 0 ? compatible : subModels;
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => {
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
return scoreB - scoreA;
})[0];
}
async function replayAssistantMessagesForDuplicateRequest({
chatId,
userMessageId,
@ -2118,41 +2215,18 @@ export async function POST(request: Request) {
console.log("[Chat API] Video params:", videoParams);
// 如果没有 videoParams 或没有 mode,使用默认模式
const mode = videoParams?.mode || "text-to-video";
const mode = (videoParams?.mode || "text-to-video") as VideoModeType;
console.log("[Chat API] Using mode:", mode);
// 根据模式选择对应的子模型
const modeToModelMap: Record<string, string> = {
"text-to-video": "lite-t2v", // 文生视频 → Lite T2V
"first-last-frame": "pro", // 首尾帧 → Pro
"first-frame": "pro-fast", // 智能多帧 → Pro Fast
"reference-images": "lite-i2v", // 主体参考 → Lite I2V
};
const targetSubModelKey = modeToModelMap[mode];
if (targetSubModelKey) {
// 查找匹配的子模型(通过 modelId 中包含的关键字)
const compatibleSubModels = subModels.filter(({ model }) => {
const supportedModes = ((model.capabilities as any)?.supportedModes ?? []) as string[];
return supportedModes.length === 0 || supportedModes.includes(mode);
});
const matchedSubModel = compatibleSubModels.find(({ model }) =>
model.modelId.toLowerCase().includes(targetSubModelKey)
const matchedSubModel = selectBestVideoSubModelForMode(subModels as any, mode);
if (matchedSubModel) {
console.log(
`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName} (${matchedSubModel.model.modelId})`
);
if (matchedSubModel) {
console.log(`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName}`);
targetModelId = matchedSubModel.model.id;
} else if (compatibleSubModels.length > 0) {
console.warn(
`[Chat API] 未找到匹配关键字子模型: ${targetSubModelKey},使用兼容子模型 ${compatibleSubModels[0].model.displayName}`
);
targetModelId = compatibleSubModels[0].model.id;
} else {
console.warn(`[Chat API] 未找到匹配的子模型: ${targetSubModelKey},将使用第一个可用子模型`);
// 如果没找到匹配的,使用第一个子模型作为后备
targetModelId = subModels[0].model.id;
}
targetModelId = matchedSubModel.model.id;
} else {
console.warn(`[Chat API] 未找到可用子模型,继续使用主模型`);
}
}
}
@ -3724,6 +3798,8 @@ export async function POST(request: Request) {
videoParams: effectiveVideoParams,
});
const videoMode = (effectiveVideoParams?.mode || "text-to-video") as VideoModeType;
// 🆕 重新执行子模型选择逻辑,确定要使用的模型 UUID
let videoModelId = finalModelId; // 默认使用选择的模型 ID
@ -3741,37 +3817,16 @@ export async function POST(request: Request) {
console.log(`[Chat API] Video: Found ${videoSubModels.length} sub-models for master model`);
if (videoSubModels.length > 0) {
const videoMode = effectiveVideoParams?.mode || "text-to-video";
const modeToModelMap: Record<string, string> = {
"text-to-video": "lite-t2v",
"first-last-frame": "pro",
"first-frame": "pro-fast",
"reference-images": "lite-i2v",
};
const targetKey = modeToModelMap[videoMode];
const compatibleModels = videoSubModels.filter(({ model }) => {
const supportedModes = ((model.capabilities as any)?.supportedModes ?? []) as string[];
return supportedModes.length === 0 || supportedModes.includes(videoMode);
});
const matchedModel = compatibleModels.find(({ model }) =>
model.modelId.toLowerCase().includes(targetKey)
);
const matchedModel = selectBestVideoSubModelForMode(videoSubModels as any, videoMode);
if (matchedModel) {
videoModelId = matchedModel.model.id;
console.log(
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} for mode ${videoMode}`
);
} else if (compatibleModels.length > 0) {
videoModelId = compatibleModels[0].model.id;
console.log(
`[Chat API] Video: Fallback to compatible sub-model ${compatibleModels[0].model.displayName} for mode ${videoMode}`
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} (${matchedModel.model.modelId}) for mode ${videoMode}`
);
} else {
videoModelId = videoSubModels[0].model.id;
console.log(
`[Chat API] Video: No compatible sub-model found for mode ${videoMode}, using ${videoSubModels[0].model.displayName}`
`[Chat API] Video: No compatible sub-model found for mode ${videoMode}, keep current model`
);
}
}
@ -3783,14 +3838,50 @@ export async function POST(request: Request) {
throw new Error("视频模型配置未找到");
}
// 若用户手动选了不兼容模式的子模型,自动回退到同父模型兼容子模型
const selectedSupportsMode = getSupportedVideoModesForModel(aiModelData as any).includes(videoMode);
if (!selectedSupportsMode) {
let fallbackModel: { model: any } | null = null;
if (aiModelData.parentModelId) {
const siblingSubModels = await getSubModelsByParentModelId(aiModelData.parentModelId);
fallbackModel = selectBestVideoSubModelForMode(siblingSubModels as any, videoMode);
} else if (selectedModelProvider?.type === "volcano-video") {
const enabledModels = await getEnabledAiModelsWithProvider();
const sameProviderModels = enabledModels
.filter(
({ model, provider }) =>
provider.type === "volcano-video" &&
model.isEnabled &&
model.id !== aiModelData.id,
)
.map(({ model }) => ({ model }));
fallbackModel = selectBestVideoSubModelForMode(sameProviderModels as any, videoMode);
}
if (fallbackModel) {
console.warn(
`[Chat API] Video: Model ${aiModelData.modelId} does not support ${videoMode}, fallback to ${fallbackModel.model.modelId}`
);
videoModelId = fallbackModel.model.id;
}
}
const finalVideoModelData = videoModelId === aiModelData.id
? aiModelData
: await getAiModelById({ id: videoModelId });
if (!finalVideoModelData) {
throw new Error("视频模型配置未找到");
}
// 如果是子模型,获取父模型的 Provider 配置
let providerModelData = aiModelData;
if (aiModelData.isSubModel && aiModelData.parentModelId) {
let providerModelData = finalVideoModelData;
if (finalVideoModelData.isSubModel && finalVideoModelData.parentModelId) {
console.log(
"[Chat API] This is a sub-model, getting parent model config:",
aiModelData.parentModelId
finalVideoModelData.parentModelId
);
const parentModel = await getAiModelById({ id: aiModelData.parentModelId });
const parentModel = await getAiModelById({ id: finalVideoModelData.parentModelId });
if (parentModel) {
providerModelData = parentModel;
}
@ -3802,13 +3893,13 @@ export async function POST(request: Request) {
throw new Error("Provider 配置未找到");
}
const actualVideoModelId = aiModelData.modelId;
const actualVideoModelId = finalVideoModelData.modelId;
// 构建视频生成选项
const videoOptions: any = {
model: actualVideoModelId,
prompt: prompt || "生成视频",
mode: effectiveVideoParams?.mode || "text-to-video",
mode: videoMode,
resolution: effectiveVideoParams?.resolution || "720p",
ratio: effectiveVideoParams?.ratio || "16:9",
duration: effectiveVideoParams?.duration || 5,

View File

@ -11,33 +11,138 @@ import type {
} from "@/types/ai";
import { uploadFile } from "@/lib/storage/service";
const RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
const RETRYABLE_FETCH_CODES = new Set([
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"ETIMEDOUT",
"ECONNRESET",
"ECONNREFUSED",
"EAI_AGAIN",
"ENOTFOUND",
]);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableFetchError(error: any): boolean {
const code = error?.code || error?.cause?.code;
if (typeof code === "string" && RETRYABLE_FETCH_CODES.has(code)) {
return true;
}
if (error?.name === "AbortError") {
return true;
}
const message = String(error?.message || "").toLowerCase();
return (
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("fetch failed")
);
}
async function fetchWithRetry(
url: string,
init: RequestInit,
options: {
label: string;
timeoutMs: number;
retries: number;
retryDelayMs?: number;
}
): Promise<Response> {
const { label, timeoutMs, retries, retryDelayMs = 1500 } = options;
let attempt = 0;
while (attempt <= retries) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timer);
if (
RETRYABLE_HTTP_STATUS.has(response.status) &&
attempt < retries
) {
const bodyText = await response.text().catch(() => "");
console.warn(
`[Volcano Video] ${label} transient status ${response.status}, retry ${attempt + 1}/${retries}`,
bodyText?.slice(0, 300)
);
await sleep(retryDelayMs * (attempt + 1));
attempt += 1;
continue;
}
return response;
} catch (error: any) {
clearTimeout(timer);
if (isRetryableFetchError(error) && attempt < retries) {
console.warn(
`[Volcano Video] ${label} request failed, retry ${attempt + 1}/${retries}`,
error?.cause?.code || error?.code || error?.message
);
await sleep(retryDelayMs * (attempt + 1));
attempt += 1;
continue;
}
throw error;
}
}
throw new Error(`[Volcano Video] ${label} request failed after retries`);
}
/**
* 将图片 URL 转换为 Base64(火山引擎 API 要求)
* 规范化图片地址为可公开访问 URL(优先直传 URL,避免大体积 base64)
*/
async function convertImageUrlToBase64(imageUrl: string): Promise<{
permanentUrl: string;
base64: string;
}> {
async function normalizeImageUrlForVolcano(imageUrl: string): Promise<string> {
try {
// 如果已经是 Base64,直接返回
// Base64 输入:上传到 S3,返回永久 URL
if (imageUrl.startsWith("data:image/")) {
const matches = imageUrl.match(/^data:image\/(\w+);base64,(.+)$/);
const matches = imageUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid base64 image format");
}
// 上传到 S3 获取永久 URL
const mimeType = matches[1];
const ext = mimeType.split("/")[1]?.toLowerCase() || "jpg";
const buffer = Buffer.from(matches[2], "base64");
const filename = `volcano-video-input-${Date.now()}.${matches[1]}`;
const filename = `volcano-video-input-${Date.now()}.${ext}`;
const { url: permanentUrl } = await uploadFile(filename, buffer as any);
return { permanentUrl, base64: matches[2] };
return permanentUrl;
}
// 下载图片
// 已经是公网 URL,直接使用
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
return imageUrl;
}
// 其他格式:下载后上传到 S3,返回永久 URL
console.log(`[Volcano Video] Downloading image: ${imageUrl}`);
const response = await fetch(imageUrl);
const response = await fetchWithRetry(
imageUrl,
{ method: "GET" },
{
label: "download-image",
timeoutMs: 30000,
retries: 2,
}
);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
@ -51,10 +156,7 @@ async function convertImageUrlToBase64(imageUrl: string): Promise<{
console.log(`[Volcano Video] Image uploaded to S3: ${permanentUrl}`);
// 转换为 Base64
const base64 = bufferNode.toString("base64");
return { permanentUrl, base64 };
return permanentUrl;
} catch (error) {
console.error("[Volcano Video] Image processing failed:", error);
throw new Error("图片处理失败,请重试");
@ -175,15 +277,13 @@ async function createVideoTask(
// 2. 处理图片输入(根据模式)
if (options.mode === "first-frame" && options.firstFrameImage) {
// 首帧模式
const { permanentUrl, base64 } = await convertImageUrlToBase64(
options.firstFrameImage
);
inputImageUrls.firstFrame = permanentUrl;
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
inputImageUrls.firstFrame = firstFrameUrl;
content.push({
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64}`,
url: firstFrameUrl,
},
role: "first_frame",
});
@ -193,24 +293,24 @@ async function createVideoTask(
options.lastFrameImage
) {
// 首尾帧模式
const firstFrame = await convertImageUrlToBase64(options.firstFrameImage);
const lastFrame = await convertImageUrlToBase64(options.lastFrameImage);
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
const lastFrameUrl = await normalizeImageUrlForVolcano(options.lastFrameImage);
inputImageUrls.firstFrame = firstFrame.permanentUrl;
inputImageUrls.lastFrame = lastFrame.permanentUrl;
inputImageUrls.firstFrame = firstFrameUrl;
inputImageUrls.lastFrame = lastFrameUrl;
content.push(
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${firstFrame.base64}`,
url: firstFrameUrl,
},
role: "first_frame",
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${lastFrame.base64}`,
url: lastFrameUrl,
},
role: "last_frame",
}
@ -220,13 +320,13 @@ async function createVideoTask(
inputImageUrls.references = [];
for (const imageUrl of options.referenceImages) {
const { permanentUrl, base64 } = await convertImageUrlToBase64(imageUrl);
inputImageUrls.references.push(permanentUrl);
const normalizedUrl = await normalizeImageUrlForVolcano(imageUrl);
inputImageUrls.references.push(normalizedUrl);
content.push({
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64}`,
url: normalizedUrl,
},
role: "reference_image",
});
@ -238,6 +338,15 @@ async function createVideoTask(
model: options.model,
content: content, // 直接传content数组,不使用messages包装
};
// 显式指定 task_type,避免服务端推断错误
const taskTypeMap: Record<string, string> = {
"text-to-video": "t2v",
"first-frame": "i2v",
"first-last-frame": "flf2v",
"reference-images": "i2v",
};
requestBody.task_type = taskTypeMap[options.mode] || "t2v";
// 可选参数:返回尾帧(用于连续生成视频)
if (options.returnLastFrame) {
@ -257,14 +366,22 @@ async function createVideoTask(
console.log("[Volcano Video] 🔍 Request method: POST");
console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2));
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
const response = await fetchWithRetry(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
},
body: JSON.stringify(requestBody),
});
{
label: "create-task",
timeoutMs: 90000,
retries: 2,
}
);
console.log("[Volcano Video] 🔍 Response status:", response.status);
console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries())));
@ -302,12 +419,20 @@ async function fetchTaskStatus(
): Promise<any> {
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
const response = await fetchWithRetry(
url,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
});
{
label: `task-status:${taskId}`,
timeoutMs: 45000,
retries: 2,
}
);
if (!response.ok) {
const errorText = await response.text();
@ -330,7 +455,20 @@ async function pollTaskStatus(
console.log("[Volcano Video] Polling task:", taskId);
for (let i = 0; i < maxRetries; i++) {
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
let status: any;
try {
status = await fetchTaskStatus(baseURL, apiKey, taskId);
} catch (error: any) {
if (isRetryableFetchError(error)) {
console.warn(
`[Volcano Video] Poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
error?.cause?.code || error?.code || error?.message
);
await sleep(pollInterval);
continue;
}
throw error;
}
console.log(
`[Volcano Video] Poll ${i + 1}/${maxRetries}, status: ${status.status}`
@ -371,7 +509,7 @@ async function pollTaskStatus(
}
// 继续等待
await new Promise((resolve) => setTimeout(resolve, pollInterval));
await sleep(pollInterval);
}
throw new Error("视频生成超时,请稍后重试");
@ -454,7 +592,20 @@ export async function* doGenerateStream(
const pollInterval = 10000;
for (let i = 0; i < maxRetries; i++) {
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
let status: any;
try {
status = await fetchTaskStatus(baseURL, apiKey, taskId);
} catch (error: any) {
if (isRetryableFetchError(error)) {
console.warn(
`[Volcano Video] Stream poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
error?.cause?.code || error?.code || error?.message
);
await sleep(pollInterval);
continue;
}
throw error;
}
if (status.status === "succeeded") {
// 火山引擎API返回格式:status.content.video_url
@ -504,7 +655,7 @@ export async function* doGenerateStream(
}
// 静默等待,不输出进度文字
await new Promise((resolve) => setTimeout(resolve, pollInterval));
await sleep(pollInterval);
}
throw new Error("视频生成超时,请稍后重试");
@ -514,4 +665,3 @@ export async function* doGenerateStream(
throw error;
}
}

View File

@ -16,6 +16,27 @@ import type {
VideoGenerationOptions,
} from "@/types/ai";
function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[] {
const normalized = modelId.toLowerCase();
if (normalized.includes("lite-t2v")) return ["text-to-video"];
if (normalized.includes("lite-i2v")) return ["first-frame", "first-last-frame", "reference-images"];
if (normalized.includes("pro-fast")) return ["text-to-video", "first-frame"];
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
return ["text-to-video", "first-frame", "first-last-frame"];
}
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
if (Array.isArray(modes) && modes.length > 0) {
return modes;
}
return inferSupportedModesFromModelId(modelId);
}
/**
* 🆕 智能选择最佳子模型
* 根据用户选择的模式和参数,从子模型列表中选择最合适的模型
@ -37,11 +58,11 @@ async function selectBestSubModel(
// 筛选支持指定模式的子模型
const compatibleModels = subModels.filter((m) => {
const caps = m.model.capabilities as VideoModelCapabilities;
const supportsMode = caps.supportedModes?.includes(mode);
const supportsMode = getSupportedModes(caps, m.model.modelId).includes(mode);
// 检查分辨率支持(如果用户指定了分辨率)
const supportsResolution = !params.resolution ||
caps.supportedResolutions?.includes(params.resolution);
const supportedResolutions = caps.supportedResolutions || ["480p", "720p", "1080p"];
const supportsResolution = !params.resolution || supportedResolutions.includes(params.resolution);
return supportsMode && supportsResolution;
});
@ -104,7 +125,7 @@ async function getCompatibleModel(
const compatibleModels = volcanoVideoModels.filter((m) => {
const caps = m.model.capabilities as VideoModelCapabilities;
return caps.supportedModes?.includes(mode);
return getSupportedModes(caps, m.model.modelId).includes(mode);
});
if (compatibleModels.length === 0) {
@ -357,4 +378,3 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
},
});
}