feat: add bltcy unified video provider
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-04-23 18:24:39 +08:00
parent 0808d1963b
commit d6d8bf021f
9 changed files with 1355 additions and 5 deletions

View File

@ -214,6 +214,10 @@ import {
BLTCY_IMAGE_OPENAI_BASE_URL,
BLTCY_IMAGE_PROVIDER_PRESET,
} from "@/lib/ai/bltcy-image-models";
import {
BLTCY_VIDEO_MODEL_PRESETS,
BLTCY_VIDEO_PROVIDER_PRESET,
} from "@/lib/ai/bltcy-video-models";
import {
CANVAS_SKILL_ICON_OPTIONS,
getCanvasSkillIconComponent,
@ -455,6 +459,7 @@ type AiProvider = {
| "custom"
| "local"
| "gemini"
| "bltcy-video"
| "grsai-gpt-image"
| "grsai-video"
| "huanke-gemini"
@ -1188,6 +1193,7 @@ const PROVIDER_TYPES = [
{ value: "ruxa-image", label: "RUXA生图" },
{ value: "fal-image", label: "FAL 生图模型" },
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
{ value: "grsai-video", label: "Grsai 视频模型" },
{ value: "midjourney", label: "Midjourney" },
@ -1219,6 +1225,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
"ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run",
"openai-image": "https://api.openai.com/v1",
"bltcy-video": "https://api.bltcy.ai",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
"grsai-video": "https://grsai.dakka.com.cn",
midjourney: "https://your-midjourney-api.example.com",
@ -3014,6 +3021,7 @@ export function AdminSettingsClient() {
| "custom"
| "local"
| "gemini"
| "bltcy-video"
| "grsai-gpt-image"
| "grsai-video"
| "huanke-gemini"
@ -4985,6 +4993,7 @@ export function AdminSettingsClient() {
const isNewProvider = !editingProvider;
const isVolcanoVideo = providerForm.type === "volcano-video";
const isKreaProvider = providerForm.type === "krea";
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
const isLemonProvider =
providerForm.type === "openai" &&
isLemonApiBaseUrl(
@ -5089,6 +5098,17 @@ export function AdminSettingsClient() {
}
}
if (isNewProvider && isBltcyVideoProvider && providerId) {
try {
await handleQuickAddBltcyVideoModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Add BLTCY Video Models] Error:", modelError);
toast.warning(
'柏拉图视频预设模型添加失败,请手动点击"快速添加视频预设"按钮'
);
}
}
if (isNewProvider && isLemonProvider && providerId) {
try {
await handleQuickAddLemonModels(providerId);
@ -5871,6 +5891,63 @@ export function AdminSettingsClient() {
}
};
const handleUseBltcyVideoProviderPreset = () => {
setProviderForm({
id: "",
name: BLTCY_VIDEO_PROVIDER_PRESET.name,
type: BLTCY_VIDEO_PROVIDER_PRESET.type,
baseUrl: BLTCY_VIDEO_PROVIDER_PRESET.baseUrl,
apiKey: "",
isEnabled: true,
});
setEditingProvider(null);
setShowProviderDialog(true);
};
const handleQuickAddBltcyVideoModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
`将为此服务商添加 ${BLTCY_VIDEO_MODEL_PRESETS.length} 个柏拉图统一视频模型预设。\n\n继续吗?`
)
) {
return;
}
try {
toast.info("正在添加柏拉图统一视频预设模型...");
const response = await fetch("/api/admin/bltcy-video-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "添加失败");
}
const { addedCount, errors } = data;
if (addedCount > 0) {
toast.success(`成功添加 ${addedCount} 个柏拉图视频预设模型`);
mutateModels();
}
if (errors && errors.length > 0) {
toast.warning(`${errors.length} 个模型添加失败(可能已存在)`);
}
} catch (error: any) {
console.error("[Quick Add BLTCY Video Models] Error:", error);
toast.error(error.message || "添加柏拉图视频预设模型失败");
}
};
const handleUseBltcyImageProviderPreset = () => {
setProviderForm({
id: "",
@ -8012,6 +8089,7 @@ export function AdminSettingsClient() {
{(() => {
const providerColors: Record<string, string> = {
gemini: "bg-purple-500",
"bltcy-video": "bg-cyan-500",
"volcano-vision": "bg-orange-500",
"volcano-video": "bg-red-500",
"doubao-video": "bg-red-400",
@ -12733,6 +12811,12 @@ export function AdminSettingsClient() {
<CardDescription>配置 AI 服务提供商</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleUseBltcyVideoProviderPreset}
>
<Video className="mr-2 h-4 w-4" /> 柏拉图视频
</Button>
<Button
variant="outline"
onClick={handleUseBltcyImageProviderPreset}
@ -12896,6 +12980,12 @@ export function AdminSettingsClient() {
OpenAPI 中的图片、图片增强和视频模型预设。
</p>
)}
{providerForm.type === "bltcy-video" && (
<p className="text-xs text-muted-foreground">
柏拉图统一视频使用 Bearer Token 认证,走
`/v2/videos/generations` 异步任务接口。保存后会自动添加当前支持的统一视频模型预设。
</p>
)}
{providerForm.type === "openai-image" &&
isBltcyImageBaseUrl(providerForm.baseUrl) && (
<p className="text-xs text-muted-foreground">
@ -12986,6 +13076,19 @@ export function AdminSettingsClient() {
快速添加 Krea 预设模型
</Button>
)}
{provider.type === "bltcy-video" && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() =>
handleQuickAddBltcyVideoModels(provider.id)
}
>
<Video className="h-4 w-4 mr-1" />
快速添加视频预设
</Button>
)}
{provider.type === "openai-image" &&
isBltcyImageBaseUrl(provider.baseUrl) && (
<Button

View File

@ -10478,7 +10478,7 @@ export async function POST(request: Request) {
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
if (
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea"].includes((languageModel as any).provider) &&
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
typeof (languageModel as any).doGenerate === "function"
) {
const currentVideoProvider = (languageModel as any).provider as
@ -10486,7 +10486,8 @@ export async function POST(request: Request) {
| "fal-video"
| "ruxa-video"
| "runway"
| "krea";
| "krea"
| "bltcy-video";
console.log(`[Chat API] Using ${currentVideoProvider} video generation`);
const videoGenerationStream = createUIMessageStream({

View File

@ -8,6 +8,7 @@ import {
deleteAiProvider,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
const providerTypes = [
"alibaba",
@ -15,6 +16,7 @@ const providerTypes = [
"custom",
"local",
"gemini",
"bltcy-video",
"grsai-gpt-image",
"grsai-video",
"huanke-gemini",
@ -39,7 +41,10 @@ const providerTypes = [
"volcano-multimodal",
] as const;
type ProviderType = (typeof providerTypes)[number];
type StoredProviderType = Exclude<ProviderType, "zhipu" | "volcano-multimodal">;
type StoredProviderType = Exclude<
ProviderType,
"zhipu" | "volcano-multimodal" | "bltcy-video"
>;
const createProviderSchema = z.object({
name: z.string().min(1).max(128),
@ -79,7 +84,9 @@ function normalizeProviderTypeForStorage(
type?: ProviderType
): StoredProviderType | undefined {
if (!type) return undefined;
if (type === "zhipu" || type === "volcano-multimodal") return "custom";
if (type === "zhipu" || type === "volcano-multimodal" || type === "bltcy-video") {
return "custom";
}
return type;
}
@ -112,6 +119,13 @@ function isVolcanoMultimodalBaseUrl(baseUrl: string) {
}
function normalizeProviderTypeForResponse(provider: Record<string, any>) {
if (
provider.type === "custom" &&
typeof provider.baseUrl === "string" &&
isBltcyVideoBaseUrl(provider.baseUrl)
) {
return { ...provider, type: "bltcy-video" };
}
if (provider.type === "custom" && typeof provider.baseUrl === "string" && isZhipuBaseUrl(provider.baseUrl)) {
return { ...provider, type: "zhipu" };
}
@ -202,6 +216,8 @@ export async function POST(request: Request) {
baseUrl:
data.type === "zhipu"
? data.baseUrl || ZHIPU_BASE_URL
: data.type === "bltcy-video"
? data.baseUrl || BLTCY_VIDEO_BASE_URL
: data.type === "volcano-multimodal"
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
: data.type === "doubao-video"

View File

@ -0,0 +1,139 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
BLTCY_VIDEO_MODEL_PRESETS,
isBltcyVideoBaseUrl,
} from "@/lib/ai/bltcy-video-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BLTCY_VIDEO_MODEL_PRESETS,
counts: {
video: BLTCY_VIDEO_MODEL_PRESETS.length,
total: BLTCY_VIDEO_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY Video Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BLTCY video model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (
!provider ||
provider.type !== "custom" ||
!isBltcyVideoBaseUrl(provider.baseUrl)
) {
return NextResponse.json(
{ error: "Provider must be a 柏拉图统一视频 provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? BLTCY_VIDEO_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: BLTCY_VIDEO_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
errors.push({
modelId: preset.modelId,
error: "Model already exists",
});
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[BLTCY Video Models API] Failed to add ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
addedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: BLTCY_VIDEO_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[BLTCY Video Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add BLTCY video models" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,550 @@
import {
recommendedVideoDurationOptions,
recommendedVideoDurationReferenceCosts,
recommendedVideoResolutionBaseCosts,
} from "@/lib/ai/video-model-config";
export type BltcyVideoModelPreset = {
modelId: string;
displayName: string;
description: string;
family: string;
supportsVision: boolean;
supportsImageGen: boolean;
capabilities?: Record<string, any>;
defaultParams: Record<string, any>;
imageGenConfig?: Record<string, any>;
sortOrder: number;
};
type BltcyVideoMode =
| "text-to-video"
| "first-frame"
| "first-last-frame"
| "reference-images";
type BltcyVideoResolution = "480p" | "720p" | "1080p";
type ModelDefinition = {
sortOrder: number;
modelId: string;
displayName: string;
family: string;
providerName: string;
summary: string;
supportedModes: BltcyVideoMode[];
supportedRatios?: string[];
supportedResolutions?: BltcyVideoResolution[];
durationOptions?: number[];
priceNote?: string;
};
const TRAILING_SLASHES_REGEX = /\/+$/;
const TRAILING_V2_REGEX = /\/v2$/i;
export const BLTCY_VIDEO_BASE_URL = "https://api.bltcy.ai";
export const BLTCY_VIDEO_PRICE_PAGE_URL =
"https://api.bltcy.ai/models?category=%E8%A7%86%E9%A2%91";
export const BLTCY_VIDEO_UNIFIED_DOC_URL =
"https://gpt-best.apifox.cn/doc-7324259";
export const BLTCY_VIDEO_PRICE_CHECKED_AT = "2026-04-23";
export const BLTCY_VIDEO_PROVIDER_PRESET = {
name: "柏拉图统一视频",
type: "bltcy-video" as const,
baseUrl: BLTCY_VIDEO_BASE_URL,
};
const STANDARD_VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
const SEEDANCE_VIDEO_RATIOS = ["16:9", "9:16", "1:1"];
const STANDARD_VIDEO_RESOLUTIONS: BltcyVideoResolution[] = [
"480p",
"720p",
"1080p",
];
const HD_VIDEO_RESOLUTIONS: BltcyVideoResolution[] = ["720p", "1080p"];
function createModeConstraints(
modes: BltcyVideoMode[],
ratios: string[],
resolutions: BltcyVideoResolution[],
durations: number[]
) {
return modes.reduce<Record<string, any>>((acc, mode) => {
acc[mode] = {
ratioOptions: ratios,
durationOptions: durations,
resolutionOptions: resolutions,
defaults: {
mode,
ratio: ratios.includes("16:9") ? "16:9" : ratios[0],
duration: durations.includes(5) ? 5 : durations[0],
resolution: resolutions.includes("720p") ? "720p" : resolutions[0],
},
};
return acc;
}, {});
}
function createPreset(definition: ModelDefinition): BltcyVideoModelPreset {
const supportedRatios =
definition.supportedRatios && definition.supportedRatios.length > 0
? definition.supportedRatios
: STANDARD_VIDEO_RATIOS;
const supportedResolutions =
definition.supportedResolutions && definition.supportedResolutions.length > 0
? definition.supportedResolutions
: STANDARD_VIDEO_RESOLUTIONS;
const durationOptions =
definition.durationOptions && definition.durationOptions.length > 0
? definition.durationOptions
: [...recommendedVideoDurationOptions];
const supportsVision = definition.supportedModes.some((mode) => mode !== "text-to-video");
return {
modelId: definition.modelId,
displayName: definition.displayName,
description: `${definition.displayName},柏拉图统一视频接口模型(${definition.providerName} / ${definition.family})。${definition.summary}${
definition.priceNote ? ` ${definition.priceNote}` : ""
},实际计费以柏拉图价格页为准。`,
family: definition.family,
supportsVision,
supportsImageGen: false,
capabilities: {
provider: "bltcy",
family: definition.family,
apiFormat: "bltcy-unified-video",
supportedModes: definition.supportedModes,
supportedRatios,
supportedResolutions,
durationRange: {
min: Math.min(...durationOptions),
max: Math.max(...durationOptions),
},
durationOptions,
constraints: createModeConstraints(
definition.supportedModes,
supportedRatios,
supportedResolutions,
durationOptions
),
maxReferenceImages: definition.supportedModes.includes("reference-images")
? 8
: definition.supportedModes.includes("first-last-frame")
? 2
: definition.supportedModes.includes("first-frame")
? 1
: 0,
supportReturnLastFrame: definition.supportedModes.includes("first-last-frame"),
supportSeed: false,
endpoints: {
submit: "/v2/videos/generations",
task: "/v2/videos/generations/{task_id}",
},
docs: BLTCY_VIDEO_UNIFIED_DOC_URL,
pricePage: BLTCY_VIDEO_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_VIDEO_PRICE_CHECKED_AT,
},
defaultParams: {
provider: "bltcy",
family: definition.family,
kind: "video",
apiFormat: "bltcy-unified-video",
submitEndpoint: "/v2/videos/generations",
taskEndpoint: "/v2/videos/generations/{task_id}",
docs: BLTCY_VIDEO_UNIFIED_DOC_URL,
pricePage: BLTCY_VIDEO_PRICE_PAGE_URL,
priceCheckedAt: BLTCY_VIDEO_PRICE_CHECKED_AT,
supportedModes: definition.supportedModes,
supportsVision,
providerName: definition.providerName,
},
imageGenConfig: {
creditCostPerVideo: recommendedVideoResolutionBaseCosts["480p"],
videoDurationOptions: durationOptions,
creditCostPerVideoByDuration: recommendedVideoDurationReferenceCosts,
creditCostPerVideoByResolution: recommendedVideoResolutionBaseCosts,
enableLoadingPlaceholder: true,
loadingColor: "cyan",
},
sortOrder: definition.sortOrder,
};
}
export function normalizeBltcyVideoBaseUrl(value?: string | null) {
const normalized = String(value || "")
.trim()
.replace(TRAILING_SLASHES_REGEX, "")
.replace(TRAILING_V2_REGEX, "");
return normalized || BLTCY_VIDEO_BASE_URL;
}
export function isBltcyVideoBaseUrl(value?: string | null) {
const normalized = normalizeBltcyVideoBaseUrl(value);
return normalized === BLTCY_VIDEO_BASE_URL;
}
const MODEL_DEFINITIONS: ModelDefinition[] = [
{
sortOrder: 10,
modelId: "veo3.1",
displayName: "Veo 3.1",
family: "veo",
providerName: "Google",
summary: "支持文生视频、图生视频和多参考图输入的通用 Veo 3.1 模型。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: STANDARD_VIDEO_RATIOS,
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [4, 5, 6, 8],
priceNote: "柏拉图当前页为按次计费模型。",
},
{
sortOrder: 20,
modelId: "veo3.1-fast",
displayName: "Veo 3.1 Fast",
family: "veo",
providerName: "Google",
summary: "偏速度优先的 Veo 3.1 Fast,适合快速出样。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: STANDARD_VIDEO_RATIOS,
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [4, 5, 6, 8],
priceNote: "柏拉图当前页为按次计费模型。",
},
{
sortOrder: 30,
modelId: "veo3.1-pro",
displayName: "Veo 3.1 Pro",
family: "veo",
providerName: "Google",
summary: "质量优先的 Veo 3.1 Pro。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: STANDARD_VIDEO_RATIOS,
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [4, 5, 6, 8],
priceNote: "柏拉图当前页为按次计费模型。",
},
{
sortOrder: 40,
modelId: "veo3.1-pro-4k",
displayName: "Veo 3.1 Pro 4K",
family: "veo",
providerName: "Google",
summary: "4K 版本 Veo 3.1 Pro,当前项目侧仍沿用统一分辨率控件。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: STANDARD_VIDEO_RATIOS,
supportedResolutions: ["1080p"],
durationOptions: [4, 5, 6, 8],
priceNote: "柏拉图当前页为按次计费模型。",
},
{
sortOrder: 50,
modelId: "veo3.1-components",
displayName: "Veo 3.1 Components",
family: "veo",
providerName: "Google",
summary: "组件化 Veo 3.1 版本,适合多参考图驱动。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: STANDARD_VIDEO_RATIOS,
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [4, 5, 6, 8],
priceNote: "柏拉图当前页为按次计费模型。",
},
{
sortOrder: 100,
modelId: "doubao-seedance-1-0-lite-t2v-250428",
displayName: "Seedance 1.0 Lite T2V",
family: "seedance",
providerName: "火山豆包",
summary: "轻量文生视频模型。",
supportedModes: ["text-to-video"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按秒计费。",
},
{
sortOrder: 110,
modelId: "doubao-seedance-1-0-lite-i2v-250428",
displayName: "Seedance 1.0 Lite I2V",
family: "seedance",
providerName: "火山豆包",
summary: "轻量图生视频模型。",
supportedModes: ["first-frame"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按秒计费。",
},
{
sortOrder: 120,
modelId: "doubao-seedance-1-0-pro-250528",
displayName: "Seedance 1.0 Pro",
family: "seedance",
providerName: "火山豆包",
summary: "Seedance Pro 通用视频模型。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按秒计费。",
},
{
sortOrder: 130,
modelId: "doubao-seedance-1-0-pro-fast-251015",
displayName: "Seedance 1.0 Pro Fast",
family: "seedance",
providerName: "火山豆包",
summary: "速度优先的 Seedance Pro Fast。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按秒计费。",
},
{
sortOrder: 140,
modelId: "doubao-seedance-1-5-pro-251215",
displayName: "Seedance 1.5 Pro",
family: "seedance",
providerName: "火山豆包",
summary: "更高质量的 Seedance 1.5 Pro。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按秒计费。",
},
{
sortOrder: 150,
modelId: "doubao-seedance-2-0-260128",
displayName: "Seedance 2.0",
family: "seedance",
providerName: "火山豆包",
summary: "Seedance 2.0 通用视频模型。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按秒计费。",
},
{
sortOrder: 160,
modelId: "doubao-seedance-2-0-fast-260128",
displayName: "Seedance 2.0 Fast",
family: "seedance",
providerName: "火山豆包",
summary: "速度优先的 Seedance 2.0 Fast。",
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedRatios: SEEDANCE_VIDEO_RATIOS,
supportedResolutions: STANDARD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按秒计费。",
},
{
sortOrder: 200,
modelId: "wanx2.1-t2v-plus",
displayName: "WanX 2.1 T2V Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 文生视频模型。",
supportedModes: ["text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 210,
modelId: "wanx2.1-t2v-turbo",
displayName: "WanX 2.1 T2V Turbo",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 文生视频极速版。",
supportedModes: ["text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 220,
modelId: "wanx2.1-i2v-plus",
displayName: "WanX 2.1 I2V Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 图生视频模型。",
supportedModes: ["first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 230,
modelId: "wanx2.1-i2v-turbo",
displayName: "WanX 2.1 I2V Turbo",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 图生视频极速版。",
supportedModes: ["first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 240,
modelId: "wanx2.1-kf2v-plus",
displayName: "WanX 2.1 KF2V Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 关键帧视频模型,适合多图/关键帧驱动。",
supportedModes: ["first-last-frame", "reference-images", "first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 250,
modelId: "wanx2.1-vace-plus",
displayName: "WanX 2.1 VACE Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.1 视频编辑类模型,可结合参考图/视频使用。",
supportedModes: ["reference-images", "first-frame", "text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 260,
modelId: "wan2.2-t2v-plus",
displayName: "Wan 2.2 T2V Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 文生视频模型。",
supportedModes: ["text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 270,
modelId: "wan2.2-i2v-plus",
displayName: "Wan 2.2 I2V Plus",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 图生视频模型。",
supportedModes: ["first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 280,
modelId: "wan2.2-i2v-flash",
displayName: "Wan 2.2 I2V Flash",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 图生视频极速版。",
supportedModes: ["first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 290,
modelId: "wan2.2-kf2v-flash",
displayName: "Wan 2.2 KF2V Flash",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 关键帧视频极速版。",
supportedModes: ["first-last-frame", "reference-images", "first-frame"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 300,
modelId: "wan2.2-s2v",
displayName: "Wan 2.2 S2V",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 视频生成模型。",
supportedModes: ["text-to-video", "first-frame", "reference-images"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 310,
modelId: "wan2.2-animate-mix",
displayName: "Wan 2.2 Animate Mix",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 动画类视频模型。",
supportedModes: ["reference-images", "first-frame", "text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 320,
modelId: "wan2.2-animate-move",
displayName: "Wan 2.2 Animate Move",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.2 动画运动类视频模型。",
supportedModes: ["reference-images", "first-frame", "text-to-video"],
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 330,
modelId: "wan2.5-t2v-preview",
displayName: "Wan 2.5 T2V Preview",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.5 文生视频预览版。",
supportedModes: ["text-to-video"],
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 340,
modelId: "wan2.5-i2v-preview",
displayName: "Wan 2.5 I2V Preview",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.5 图生视频预览版。",
supportedModes: ["first-frame"],
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10],
priceNote: "柏拉图当前页为阶梯/按次计费。",
},
{
sortOrder: 350,
modelId: "wan2.6-t2v",
displayName: "Wan 2.6 T2V",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.6 文生视频模型。",
supportedModes: ["text-to-video"],
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10, 15],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 360,
modelId: "wan2.6-i2v",
displayName: "Wan 2.6 I2V",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.6 图生视频模型。",
supportedModes: ["first-frame"],
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10, 15],
priceNote: "柏拉图当前页为按次计费。",
},
{
sortOrder: 370,
modelId: "wan2.6-r2v",
displayName: "Wan 2.6 R2V",
family: "wan",
providerName: "Aliyun(Qwen)",
summary: "万相 2.6 参考驱动视频模型。",
supportedModes: ["reference-images", "first-frame"],
supportedResolutions: HD_VIDEO_RESOLUTIONS,
durationOptions: [5, 10, 15],
priceNote: "柏拉图当前页为按次计费。",
},
];
export const BLTCY_VIDEO_MODEL_PRESETS: BltcyVideoModelPreset[] =
MODEL_DEFINITIONS.map(createPreset);

View File

@ -28,11 +28,13 @@ import { createRuxaVideo } from "./providers/ruxa-video";
import { createRunway } from "./providers/runway";
import { createKrea } from "./providers/krea";
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
import { createBltcyVideo } from "./providers/bltcy-video";
import { getProviderDefaultBaseUrl } from "./provider-defaults";
import {
createMoonshotKimiFetch,
isMoonshotOfficialBaseUrl,
} from "./detail-agent-kimi-profile";
import { isBltcyVideoBaseUrl } from "./bltcy-video-models";
// 缓存已创建的 provider 实例
const providerCache = new Map<string, any>();
@ -287,6 +289,14 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
case "custom":
default: {
if (isBltcyVideoBaseUrl(resolvedBaseUrl)) {
const bltcyVideoProvider = createBltcyVideo({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
console.log(`[Provider] Created BLTCY unified video provider: ${resolvedBaseUrl}`);
return bltcyVideoProvider as any;
}
if (isVolcanoMultimodalBaseUrl(resolvedBaseUrl)) {
const volcanoMultimodalProvider = createVolcanoMultimodal({
baseURL: resolvedBaseUrl,

View File

@ -1,6 +1,7 @@
import { inferKreaModelKind } from "@/lib/ai/krea-models";
const STATIC_VIDEO_PROVIDER_TYPES = new Set([
"bltcy-video",
"doubao-video",
"volcano-video",
"qianwen-video",

View File

@ -177,7 +177,7 @@ const INTENT_MODEL_MAP: Record<
// 视频生成
video_gen: {
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea"],
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "custom"],
description: "视频生成",
},

View File

@ -0,0 +1,530 @@
import "server-only";
import { uploadFile } from "@/lib/storage/service";
import { normalizeBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
interface BltcyVideoConfig {
apiKey: string;
baseURL: string;
}
interface BltcyVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
resolution?: string;
}
const POLL_INTERVAL_MS = 3000;
const POLL_MAX_ATTEMPTS = 240;
const DEFAULT_RATIO = "16:9";
const DEFAULT_DURATION = 5;
function normalizeText(value?: string | null) {
return String(value || "")
.replace(/\u00a0/g, " ")
.trim()
.normalize("NFKC");
}
function normalizeApiKey(value: string) {
const normalized = normalizeText(value)
.replace(/^bearer\s+/i, "")
.replace(/^key\s+/i, "")
.replace(/^(?:bltcy[_-]?api[_-]?key|api[_-]?key|token)\s*[::]\s*/i, "");
return normalized.replace(/[^\x20-\x7E]/g, "");
}
function joinApiPath(baseURL: string, path: string) {
const cleanBase = normalizeBltcyVideoBaseUrl(baseURL).replace(/\/+$/, "");
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
}
function normalizeRatio(value?: string) {
const normalized = normalizeText(value);
if (
normalized === "21:9" ||
normalized === "16:9" ||
normalized === "4:3" ||
normalized === "1:1" ||
normalized === "3:4" ||
normalized === "9:16"
) {
return normalized;
}
return DEFAULT_RATIO;
}
function normalizeDuration(value?: number) {
if (!Number.isFinite(value)) return DEFAULT_DURATION;
return Math.max(3, Math.min(20, Math.round(Number(value))));
}
function normalizeResolution(value?: string) {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "480p") return "480P";
if (normalized === "720p") return "720P";
if (normalized === "1080p") return "1080P";
return undefined;
}
function detectExtensionFromContentType(contentType?: string | null) {
const normalized = String(contentType || "").toLowerCase();
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("png")) return "png";
if (normalized.includes("mp4")) return "mp4";
if (normalized.includes("quicktime")) return "mov";
if (normalized.includes("webm")) return "webm";
return "bin";
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
function extractDataUriParts(dataUri: string) {
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
throw new Error("不支持的 Data URI 格式");
}
return {
mimeType: match[1],
buffer: Buffer.from(match[2], "base64"),
};
}
async function uploadDataUriAsset(dataUri: string, prefix: "bltcy-input" | "bltcy-video") {
const { mimeType, buffer } = extractDataUriParts(dataUri);
const extension = detectExtensionFromContentType(mimeType);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(
generateFilename(prefix, extension === "bin" ? (prefix === "bltcy-video" ? "mp4" : "png") : extension),
arrayBuffer
);
return uploaded.url;
}
function isLikelyVideoUrl(url: string, mediaType?: string | null) {
const normalizedMediaType = String(mediaType || "").toLowerCase();
if (normalizedMediaType.startsWith("video/")) return true;
return /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url) || /^data:video\//i.test(url);
}
async function normalizeAssetUrl(url?: string | null, kind?: "image" | "video") {
const normalized = normalizeText(url);
if (!normalized) return "";
if (/^https?:\/\//i.test(normalized)) return normalized;
if (/^data:image\//i.test(normalized)) {
return uploadDataUriAsset(normalized, "bltcy-input");
}
if (/^data:video\//i.test(normalized)) {
return uploadDataUriAsset(normalized, "bltcy-video");
}
if (kind === "video") {
throw new Error("柏拉图统一视频的参考视频需要可访问的 URL");
}
return normalized;
}
function extractPromptAndAssets(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("未找到用户输入内容");
}
let prompt = "";
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushAsset = (value?: string, mediaType?: string | null) => {
if (!value) return;
const normalized = String(value || "").trim();
if (!normalized) return;
if (isLikelyVideoUrl(normalized, mediaType)) {
if (!videoUrls.includes(normalized)) {
videoUrls.push(normalized);
}
return;
}
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
const readPart = (part: any) => {
if (!part) return;
if (part.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
return;
}
if (part.type === "image_url" && part.image_url?.url) {
pushAsset(part.image_url.url, "image/jpeg");
return;
}
if (part.type === "file") {
pushAsset(part.url || part.data, part.mediaType || part.mimeType);
return;
}
if (part.type === "image") {
pushAsset(part.image || part.url || part.data, part.mediaType || "image/jpeg");
return;
}
if (part.type === "video") {
pushAsset(part.video || part.url || part.data, part.mediaType || "video/mp4");
}
};
if (typeof lastUserMessage.content === "string") {
prompt = normalizeText(lastUserMessage.content);
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
readPart(part);
}
}
return {
prompt: normalizeText(prompt) || "生成视频",
imageUrls,
videoUrls,
};
}
function extractTaskId(payload: any): string | undefined {
const candidates = [
payload?.task_id,
payload?.taskId,
payload?.id,
payload?.data?.task_id,
payload?.data?.taskId,
payload?.data?.id,
];
return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
}
function extractStatus(payload: any): string | undefined {
const raw =
payload?.status ||
payload?.task_status ||
payload?.taskStatus ||
payload?.state ||
payload?.data?.status ||
payload?.data?.task_status ||
payload?.data?.taskStatus ||
payload?.data?.state;
if (typeof raw !== "string") return undefined;
return raw.trim().toLowerCase();
}
function extractErrorMessage(payload: any): string {
return (
payload?.fail_reason ||
payload?.message ||
payload?.error ||
payload?.data?.fail_reason ||
payload?.data?.message ||
payload?.data?.error ||
"柏拉图视频任务执行失败"
);
}
function collectHttpUrls(payload: unknown): string[] {
const urls = new Set<string>();
const visit = (value: unknown) => {
if (typeof value === "string") {
const normalized = value.trim();
if (/^https?:\/\//i.test(normalized)) {
urls.add(normalized);
}
return;
}
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (value && typeof value === "object") {
Object.values(value as Record<string, unknown>).forEach(visit);
}
};
visit(payload);
return Array.from(urls);
}
async function readJsonOrText(response: Response) {
const rawText = await response.text();
if (!rawText) return null;
try {
return JSON.parse(rawText);
} catch {
return rawText;
}
}
async function downloadRemoteAsset(url: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000);
try {
const response = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`下载柏拉图视频输出失败: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
contentType: response.headers.get("content-type"),
};
} finally {
clearTimeout(timeoutId);
}
}
async function uploadVideoToStorage(remoteVideoUrl: string) {
try {
const { buffer, contentType } = await downloadRemoteAsset(remoteVideoUrl);
const extension = detectExtensionFromContentType(contentType || "video/mp4");
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(
generateFilename("bltcy-video", extension === "bin" ? "mp4" : extension),
arrayBuffer
);
return uploaded.url;
} catch (error) {
console.warn("[BLTCY Video] Failed to archive video output, using temporary URL:", error);
return remoteVideoUrl;
}
}
function formatVideoMarkdown(videoUrl: string) {
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
}
async function waitForVideoUrl(baseURL: string, apiKey: string, taskId: string) {
const endpoint = joinApiPath(baseURL, `/v2/videos/generations/${encodeURIComponent(taskId)}`);
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
cache: "no-store",
});
const payload = await readJsonOrText(response);
if (!response.ok) {
throw new Error(
`${extractErrorMessage(payload)} (HTTP ${response.status})`
);
}
const status = extractStatus(payload);
const urls = collectHttpUrls(payload);
const remoteVideoUrl =
urls.find((url) => /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url) || /video/i.test(url)) ||
urls[0];
if (remoteVideoUrl && (!status || ["success", "succeeded", "completed", "done", "finished"].includes(status))) {
return uploadVideoToStorage(remoteVideoUrl);
}
if (status && ["failed", "error", "cancelled", "canceled"].includes(status)) {
throw new Error(extractErrorMessage(payload));
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error("柏拉图视频任务超时,请稍后重试");
}
function buildSubmitBody(params: {
modelId: string;
prompt: string;
imageUrls: string[];
videoUrls: string[];
videoParams?: BltcyVideoParams;
}) {
const { modelId, prompt, imageUrls, videoUrls, videoParams } = params;
const mode = videoParams?.mode || (imageUrls[0] || videoUrls[0] ? "first-frame" : "text-to-video");
const images =
mode === "first-frame"
? imageUrls.slice(0, 1)
: mode === "first-last-frame"
? imageUrls.slice(0, 2)
: mode === "reference-images"
? imageUrls.slice(0, 8)
: [];
const videos = videoUrls.slice(0, 2);
if (mode === "first-frame" && images.length < 1 && videos.length < 1) {
throw new Error("柏拉图图生视频至少需要 1 张参考图或 1 段参考视频");
}
if (mode === "first-last-frame" && images.length < 2) {
throw new Error("柏拉图首尾帧视频至少需要 2 张图片");
}
if (mode === "reference-images" && images.length < 1 && videos.length < 1) {
throw new Error("柏拉图多参考视频至少需要 1 张图片或 1 段视频");
}
const body: Record<string, any> = {
model: normalizeText(modelId),
prompt: normalizeText(prompt) || "生成视频",
duration: normalizeDuration(videoParams?.duration),
aspect_ratio: normalizeRatio(videoParams?.ratio),
watermark: false,
};
const resolution = normalizeResolution(videoParams?.resolution);
if (resolution) {
body.resolution = resolution;
}
if (images.length > 0) {
body.images = images;
}
if (videos.length > 0) {
body.videos = videos;
}
return body;
}
export function createBltcyVideo({ apiKey, baseURL }: BltcyVideoConfig) {
return {
provider: "bltcy-video",
chat: (modelId: string) => ({
provider: "bltcy-video",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
videoParams?: BltcyVideoParams;
overrideModelId?: string;
}) => {
try {
const credentials = normalizeApiKey(apiKey);
if (!credentials) {
throw new Error("柏拉图 API Key 未配置或格式无效");
}
const activeModelId = normalizeText(options.overrideModelId || modelId);
const { prompt, imageUrls, videoUrls } = extractPromptAndAssets(options.messages);
const normalizedImageUrls = (
await Promise.all(imageUrls.map((url) => normalizeAssetUrl(url, "image")))
).filter(Boolean);
const normalizedVideoUrls = (
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url, "video")))
).filter(Boolean);
const requestBody = buildSubmitBody({
modelId: activeModelId,
prompt,
imageUrls: normalizedImageUrls,
videoUrls: normalizedVideoUrls,
videoParams: options.videoParams,
});
console.log("[BLTCY Video] Submitting task:", {
model: activeModelId,
baseURL: normalizeBltcyVideoBaseUrl(baseURL),
hasImages: Array.isArray(requestBody.images) ? requestBody.images.length : 0,
hasVideos: Array.isArray(requestBody.videos) ? requestBody.videos.length : 0,
duration: requestBody.duration,
aspectRatio: requestBody.aspect_ratio,
resolution: requestBody.resolution || null,
});
const submitResponse = await fetch(joinApiPath(baseURL, "/v2/videos/generations"), {
method: "POST",
headers: {
Authorization: `Bearer ${credentials}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(requestBody),
});
const submitPayload = await readJsonOrText(submitResponse);
if (!submitResponse.ok) {
throw new Error(
`${extractErrorMessage(submitPayload)} (HTTP ${submitResponse.status})`
);
}
const taskId = extractTaskId(submitPayload);
if (!taskId) {
const directVideoUrl = collectHttpUrls(submitPayload).find((url) =>
/\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url)
);
if (directVideoUrl) {
const archivedUrl = await uploadVideoToStorage(directVideoUrl);
const text = formatVideoMarkdown(archivedUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
}
throw new Error("柏拉图创建任务成功,但未返回任务 ID");
}
const archivedVideoUrl = await waitForVideoUrl(baseURL, credentials, taskId);
const text = formatVideoMarkdown(archivedVideoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[BLTCY Video] Generation failed:", error);
return {
text:
error instanceof Error
? `视频生成失败: ${error.message}`
: "柏拉图视频生成失败,请稍后重试",
finishReason: "error" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
videoParams?: BltcyVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}