feat(ai): add VIP98K provider foundation on 48 baseline

This commit is contained in:
134 2026-08-08 16:40:01 +08:00 committed by root
parent 30b7ae168f
commit 725a93331a
28 changed files with 1944 additions and 19 deletions

View File

@ -91,6 +91,7 @@ export type AdminAiProvider = {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"

View File

@ -636,6 +636,7 @@ type AiProvider = {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -665,6 +666,9 @@ type ImageGenConfig = {
supportsSeed?: boolean; // 是否支持随机种子
supportedAspectRatios?: string[]; // 支持的图片比例
supportedImageSizes?: string[]; // 支持的分辨率/K 数
maxReferenceImages?: number; // 最大参考图数量
maxVideoReferences?: number; // 最大参考视频数量
supportsQuality?: boolean; // 是否支持质量档位
creditCostPerImage?: number; // 每张图片积分消耗
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
@ -674,6 +678,7 @@ type ImageGenConfig = {
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
vip98k?: Record<string, unknown>; // VIP98K 目录元数据
};
function createDefaultImageGenConfig(): ImageGenConfig {
@ -1598,6 +1603,7 @@ const PROVIDER_TYPES = [
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
{ value: "vip98k", label: "VIP98K 混合模型服务" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@ -1635,6 +1641,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
bananaimage: "https://bananaimage.aigenmedia.art",
visionary: "https://api.visionary.beer",
atlascloud: "https://api.atlascloud.ai",
vip98k: "https://api.vip98k.com",
"stepfun-image": "https://api.stepfun.com",
"bltcy-video": "https://api.bltcy.ai",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
@ -4117,6 +4124,7 @@ export function AdminSettingsClient() {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -6363,6 +6371,7 @@ export function AdminSettingsClient() {
const isBananaImageProvider = providerForm.type === "bananaimage";
const isVisionaryProvider = providerForm.type === "visionary";
const isAtlasCloudProvider = providerForm.type === "atlascloud";
const isVip98kProvider = providerForm.type === "vip98k";
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
const isLemonProvider =
providerForm.type === "openai" &&
@ -6538,6 +6547,17 @@ export function AdminSettingsClient() {
}
}
if (isNewProvider && isVip98kProvider && providerId) {
try {
await handleQuickAddVip98kModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Sync VIP98K Models] Error:", modelError);
toast.warning(
'VIP98K 模型同步失败,请手动点击"同步 VIP98K 全部模型"按钮'
);
}
}
if (isNewProvider && isBltcyVideoProvider && providerId) {
try {
await handleQuickAddBltcyVideoModels(providerId, {
@ -7626,6 +7646,45 @@ export function AdminSettingsClient() {
}
};
const handleQuickAddVip98kModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
"将从 VIP98K 模型目录同步全部模型,并按图片、视频、3D 世界、文本和工具分类。图片模型会配置比例、1K/2K/4K 和多参考图能力。\n\n继续吗?"
)
) {
return;
}
try {
toast.info("正在读取 VIP98K 模型目录...");
const response = await fetch("/api/admin/vip98k-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 totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
`已同步 ${totalSynced} 个 VIP98K 模型(目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
toast.warning(`${data.errors.length} 个 VIP98K 模型同步失败`);
}
} catch (error: any) {
console.error("[Quick Add VIP98K Models] Error:", error);
toast.error(error.message || "同步 VIP98K 模型失败");
throw error;
}
};
const handleUseFalImageProviderPreset = () => {
setProviderForm({
id: "",
@ -15944,6 +16003,13 @@ export function AdminSettingsClient() {
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
</p>
)}
{providerForm.type === "vip98k" && (
<p className="text-muted-foreground text-xs">
VIP98K 使用 Bearer API Key 认证,Base URL 只填写
https://api.vip98k.com。保存后会自动同步图片、视频、3D、
多模态和工具模型。
</p>
)}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
<p className="text-muted-foreground text-xs">
@ -16101,6 +16167,17 @@ export function AdminSettingsClient() {
同步 Atlas Cloud 模型
</Button>
)}
{provider.type === "vip98k" && (
<Button
className="mt-2"
onClick={() => handleQuickAddVip98kModels(provider.id)}
size="sm"
variant="secondary"
>
<Sparkles className="mr-1 h-4 w-4" />
同步 VIP98K 全部模型
</Button>
)}
{provider.type === "bltcy-video" && (
<Button
className="mt-2"

View File

@ -18,6 +18,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"runway",
"krea",
@ -249,6 +250,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":

View File

@ -527,6 +527,7 @@ export async function convertMessagesWithImages(
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||

View File

@ -191,6 +191,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||

View File

@ -9222,6 +9222,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
"grsai-gpt-image": 8,
runway: 9,
@ -9616,6 +9617,7 @@ export async function POST(request: Request) {
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -9844,6 +9846,8 @@ export async function POST(request: Request) {
? "visionary"
: (languageModel as any).provider === "atlascloud"
? "atlascloud"
: (languageModel as any).provider === "vip98k"
? "vip98k"
: (languageModel as any).provider === "stepfun-image"
? "stepfun-image"
: (languageModel as any).provider === "midjourney"
@ -9950,6 +9954,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -9999,6 +10004,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -10388,6 +10395,7 @@ export async function POST(request: Request) {
bananaimage: 6,
visionary: 6,
atlascloud: 6,
vip98k: 6,
"stepfun-image": 6,
runway: 7,
krea: 8,
@ -10438,6 +10446,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -10651,7 +10661,7 @@ export async function POST(request: Request) {
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
if (
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
typeof (languageModel as any).doGenerate === "function"
) {
const currentVideoProvider = (languageModel as any).provider as
@ -10660,7 +10670,8 @@ export async function POST(request: Request) {
| "ruxa-video"
| "runway"
| "krea"
| "bltcy-video";
| "bltcy-video"
| "vip98k";
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
const videoGenerationStream = createUIMessageStream({
@ -11316,6 +11327,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -11368,6 +11380,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"

View File

@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
supportsQuality: z.boolean().optional(),
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
maxReferenceImages: z.number().min(0).max(64).optional(),
maxVideoReferences: z.number().min(0).max(16).optional(),
ruxaTaskInputMode: z
.enum(["size-image", "resolution-based"])
.optional(),
@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();

View File

@ -9,6 +9,7 @@ import {
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const providerTypes = [
@ -35,6 +36,7 @@ const providerTypes = [
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"fal-video",
@ -79,6 +81,7 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const VISIONARY_BASE_URL = "https://api.visionary.beer";
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
@ -249,6 +252,8 @@ export async function POST(request: Request) {
? data.baseUrl || VISIONARY_BASE_URL
: data.type === "atlascloud"
? data.baseUrl || ATLASCLOUD_BASE_URL
: data.type === "vip98k"
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
: data.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney"
@ -311,6 +316,12 @@ export async function PUT(request: Request) {
...data,
apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type),
baseUrl:
data.baseUrl !== undefined
? data.baseUrl
: data.type === "vip98k"
? VIP98K_PROVIDER_BASE_URL
: undefined,
updatedBy: session.user.id,
});

View File

@ -0,0 +1,185 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
fetchVip98kModelPresets,
type Vip98kModelPreset,
} from "@/lib/ai/vip98k-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { aiModel, type AiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 300;
function summarizeByType(presets: Vip98kModelPreset[]) {
return presets.reduce<Record<string, number>>((acc, preset) => {
const type = String(preset.type || "model");
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const providerId = String(searchParams.get("providerId") || "").trim();
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required to fetch VIP98K catalog" },
{ status: 400 },
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "vip98k") {
return NextResponse.json(
{ error: "Provider must be a VIP98K provider" },
{ status: 400 },
);
}
const presets = await fetchVip98kModelPresets({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
return NextResponse.json({
presets,
counts: {
total: presets.length,
byType: summarizeByType(presets),
},
});
} catch (error) {
console.error("[VIP98K Models API] GET error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch VIP98K models",
},
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const requestedModelIds = Array.isArray(body?.modelIds)
? new Set(
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 !== "vip98k") {
return NextResponse.json(
{ error: "Provider must be a VIP98K provider" },
{ status: 400 },
);
}
const catalogPresets = await fetchVip98kModelPresets({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const presets = requestedModelIds
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalogPresets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: Array<{ modelId: string; error: string }> = [];
for (const preset of presets) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId),
),
});
const values = {
displayName: preset.displayName,
avatarUrl: null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: preset.isHidden,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
updatedAt: new Date(),
};
if (existing) {
const [updated] = await db
.update(aiModel)
.set(values)
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId),
),
)
.returning();
if (updated) updatedModels.push(updated);
} else {
const [created] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
...values,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
}
} catch (error) {
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
errors.push({
modelId: preset.modelId,
error: error instanceof Error ? error.message : "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: catalogPresets.length,
byType: summarizeByType(catalogPresets),
},
});
} catch (error) {
console.error("[VIP98K Models API] POST error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to sync VIP98K models",
},
{ status: 500 },
);
}
}

View File

@ -14,6 +14,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
@ -57,6 +58,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -127,6 +129,8 @@ function createTextEditProvider(
return createVisionary({ baseURL, apiKey });
case "atlascloud":
return createAtlasCloud({ baseURL, apiKey });
case "vip98k":
return createVip98k({ baseURL, apiKey });
case "stepfun-image":
return createStepfunImage({ baseURL, apiKey });
case "midjourney":
@ -493,10 +497,17 @@ async function generateTextEditImageWithFailover(params: {
imageCount: 1,
},
};
if (selected.provider.type === "atlascloud") {
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
}
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
const modelInstance =
selected.provider.type === "vip98k"
? (provider as any).chat(runtimeModelId, {
capabilities: selected.model.capabilities || null,
imageGenConfig: selected.model.imageGenConfig || null,
})
: provider.chat(runtimeModelId);
const result = await modelInstance.doGenerate(generationOptions);
const resultText = String(result?.text || "").trim();
const finishReason = String(result?.finishReason || "").toLowerCase();

View File

@ -28,6 +28,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import {
canProcessSceneFusionProviderType,
@ -1565,6 +1566,18 @@ async function processSceneWithProvider(
imageGenConfig,
);
case "vip98k":
return processWithVip98k(
productImageUrl,
sceneImageUrl,
prompt,
provider,
runtimeModelId || modelId,
aspectRatio,
imageSize,
imageGenConfig,
);
case "gemini":
case "gemini-image":
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
@ -1933,6 +1946,45 @@ async function processWithAtlasCloud(
return { thumbnail, original, prompt };
}
async function processWithVip98k(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string,
imageGenConfig?: Record<string, any> | null
): Promise<{ thumbnail: string; original: string; prompt: string }> {
logger.info("provider_processing", { providerType: "vip98k", modelId });
const vip98kProvider = createVip98k({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
],
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
imageGenConfig,
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
/**
* 使用既梦/火山模型处理
*/

View File

@ -29,6 +29,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
bananaimage: "BananaImage 图片",
visionary: "Visionary 图片",
atlascloud: "Atlas Cloud 图片",
vip98k: "VIP98K 图片",
"stepfun-image": "StepFun 图片",
midjourney: "Midjourney",
"volcano-vision": "即梦 4.5",

View File

@ -177,6 +177,7 @@ export function ImageParamsBar({
providerType === "bananaimage" ||
providerType === "visionary" ||
providerType === "atlascloud" ||
providerType === "vip98k" ||
providerType === "stepfun-image" ||
providerType === "midjourney"
) {

View File

@ -27,6 +27,7 @@ import { createOpenAIImage } from "./providers/openai-image";
import { createBananaImage } from "./providers/bananaimage";
import { createVisionary } from "./providers/visionary";
import { createAtlasCloud } from "./providers/atlascloud";
import { createVip98k } from "./providers/vip98k";
import { createStepfunImage } from "./providers/stepfun-image";
import { createMidjourney } from "./providers/midjourney";
import { createFalVideo } from "./providers/fal-video";
@ -324,6 +325,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return atlasCloudProvider as any;
}
case "vip98k": {
const vip98kProvider = createVip98k({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
logProviderCreated("vip98k", resolvedBaseUrl);
return vip98kProvider as any;
}
case "stepfun-image": {
const stepfunImageProvider = createStepfunImage({
baseURL: resolvedBaseUrl,
@ -501,6 +511,12 @@ export async function getDynamicModel(
} else if (provider.type === "atlascloud") {
baseModel = providerInstance.chat(resolvedModelId, model.imageGenConfig || null);
logChatModelResult("atlascloud", baseModel);
} else if (provider.type === "vip98k") {
baseModel = providerInstance.chat(resolvedModelId, {
capabilities: model.capabilities || null,
imageGenConfig: model.imageGenConfig || null,
});
logChatModelResult("vip98k", baseModel);
} else if (provider.type === "stepfun-image") {
baseModel = providerInstance.chat(resolvedModelId);
logChatModelResult("stepfun-image", baseModel);

View File

@ -76,6 +76,8 @@ const THREE_D_MODE_KEYS = new Set([
"mesh-generation",
"mesh_generation",
"cad",
"world-generation",
"world_generation",
]);
const THREE_D_KIND_KEYS = new Set([
@ -86,6 +88,11 @@ const THREE_D_KIND_KEYS = new Set([
"mesh-generation",
"mesh_generation",
"cad",
"world",
"world-generation",
"world_generation",
"3d-world",
"3d_world",
]);
const TEXT_TO_IMAGE_MODE_KEYS = new Set([

View File

@ -9,6 +9,7 @@ import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
import {
isImageGenerationModelDescriptor,
modelSupportsTextToImageDescriptor,
is3DModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { isKnownReferenceImageModel } from "@/lib/ai/reference-image-capability";
@ -51,6 +52,7 @@ const CHAT_PROVIDER_TYPES = [
"gemini",
"huanke-gemini",
"volcano-multimodal",
"vip98k",
] as const;
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
@ -91,6 +93,25 @@ function isConversationalCandidate(
const providerType = String(item.provider.type || "");
if (!item.model.isEnabled || item.model.isSubModel) return false;
if (isVideoProviderType(providerType, item.model)) return false;
if (
is3DModelDescriptor({
providerType,
modelId: item.model.modelId,
capabilities: item.model.capabilities,
})
) {
return false;
}
if (
isImageGenerationModelDescriptor({
providerType,
modelId: item.model.modelId,
capabilities: item.model.capabilities,
supportsImageGen: item.model.supportsImageGen,
})
) {
return false;
}
const requireVision = Boolean(options?.requireVision);
const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes(
@ -175,7 +196,7 @@ const INTENT_MODEL_MAP: Record<
> = {
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
image_edit: {
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
requireVision: true,
requireImageGen: true,
description: "图片编辑",
@ -190,20 +211,20 @@ const INTENT_MODEL_MAP: Record<
// 图片生成:优先灵魂4.5
image_gen: {
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
requireImageGen: true,
description: "图片生成",
},
// 视频生成
video_gen: {
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "custom"],
providerTypes: ["doubao-video", "volcano-video", "qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "vip98k", "custom"],
description: "视频生成",
},
// 普通对话:优先千问3
chat: {
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal"],
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal", "vip98k"],
description: "对话",
},
};
@ -218,25 +239,25 @@ const SUB_INTENT_OVERRIDES: Record<
> = {
// 抠图:alibaba-image 效果最好
remove_background: {
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini"],
reason: "抠图首选灵魂4.5",
},
// 换背景:alibaba-image 支持场景生成
replace_background: {
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini"],
reason: "换背景首选灵魂4.5",
},
// 图片增强
enhance: {
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
reason: "增强首选灵魂4.5",
},
// 风格迁移:gemini 可能更有创意
style_transfer: {
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea"],
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "runway", "krea"],
reason: "风格迁移优选Gemini",
},
};

View File

@ -20,6 +20,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
bananaimage: "https://bananaimage.aigenmedia.art",
visionary: "https://api.visionary.beer",
atlascloud: "https://api.atlascloud.ai",
vip98k: "https://api.vip98k.com",
"stepfun-image": "https://api.stepfun.com",
"bfl-image": "https://api.bfl.ai",
midjourney: "https://your-midjourney-api.example.com",

674
lib/ai/providers/vip98k.ts Normal file
View File

@ -0,0 +1,674 @@
import "server-only";
import { createOpenAI } from "@ai-sdk/openai";
import sharp from "sharp";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
interface Vip98kConfig {
apiKey: string;
baseURL?: string | null;
}
interface Vip98kTaskParams {
apiKey: string;
baseURL?: string | null;
modelId: string;
kind: string;
prompt: string;
imageUrls: string[];
videoUrls: string[];
videoParams?: Record<string, unknown>;
imageParams?: Record<string, unknown>;
modelConfig: Vip98kModelRuntimeConfig;
}
type Vip98kModelRuntimeConfig = {
capabilities?: Record<string, unknown> | null;
imageGenConfig?: Record<string, unknown> | null;
};
type UploadedImage = {
thumbnail: string;
original: string;
};
const DOWNLOAD_TIMEOUT_MS = 120_000;
const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600;
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function normalizeOpenAIBaseUrl(baseURL?: string | null) {
const normalized = normalizeVip98kBaseUrl(baseURL || VIP98K_BASE_URL);
return /\/v1$/i.test(normalized) ? normalized : `${normalized}/v1`;
}
function parseRuntimeConfig(value?: unknown): Vip98kModelRuntimeConfig {
const record = toRecord(value);
if (!record) return {};
if ("capabilities" in record || "imageGenConfig" in record) {
return {
capabilities: toRecord(record.capabilities),
imageGenConfig: toRecord(record.imageGenConfig),
};
}
return {
capabilities: null,
imageGenConfig: record,
};
}
function getStringField(source: unknown, key: string) {
const record = toRecord(source);
return String(record?.[key] || "").trim().toLowerCase();
}
function getVip98kMetadata(config: Vip98kModelRuntimeConfig) {
const capabilitiesVip98k = toRecord(toRecord(config.capabilities)?.vip98k);
const imageConfigVip98k = toRecord(toRecord(config.imageGenConfig)?.vip98k);
return capabilitiesVip98k || imageConfigVip98k || {};
}
function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) {
const capabilities = config.capabilities;
const metadata = getVip98kMetadata(config);
const configuredKind =
getStringField(capabilities, "kind") ||
getStringField(capabilities, "modelType") ||
getStringField(capabilities, "mediaType") ||
getStringField(metadata, "modelType");
if (configuredKind.includes("world")) return "world";
if (configuredKind.includes("video")) return "video";
if (configuredKind.includes("image-tool")) return "image-tool";
if (configuredKind.includes("image")) return "image";
if (configuredKind.includes("audio")) return "audio";
if (configuredKind.includes("document")) return "document-tool";
if (configuredKind.includes("chat") || configuredKind.includes("model")) return "chat";
const normalizedModelId = modelId.trim().toLowerCase();
if (normalizedModelId === "world-generation") return "world";
if (/^veo[-_.]/.test(normalizedModelId) || normalizedModelId.startsWith("veo-")) {
return "video";
}
if (
normalizedModelId.startsWith("gpt-image") ||
normalizedModelId.startsWith("nano-banana")
) {
return "image";
}
return "chat";
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function parseJsonResponse(response: Response) {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function extractErrorMessage(payload: unknown, fallback = "VIP98K request failed") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
const record = toRecord(payload);
const data = toRecord(record?.data);
const error = toRecord(record?.error);
const value =
data?.message ||
data?.errorMessage ||
data?.error ||
error?.message ||
record?.message ||
record?.error ||
fallback;
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function unwrapData(payload: unknown) {
const record = toRecord(payload);
return record?.data && typeof record.data === "object" ? record.data : payload;
}
function extractTaskId(payload: unknown) {
const data = unwrapData(payload);
const record = toRecord(data);
return String(
record?.taskId ||
record?.task_id ||
record?.id ||
record?.requestId ||
record?.request_id ||
"",
).trim();
}
function extractResultPath(payload: unknown) {
const data = unwrapData(payload);
const record = toRecord(data);
return String(record?.resultPath || record?.result_path || "").trim();
}
function extractStatus(payload: unknown) {
const data = unwrapData(payload);
const record = toRecord(data);
return String(record?.status || record?.state || record?.taskStatus || "").trim().toLowerCase();
}
function extractDone(payload: unknown) {
const data = unwrapData(payload);
const record = toRecord(data);
if (typeof record?.done === "boolean") return record.done;
if (typeof record?.isDone === "boolean") return record.isDone;
if (typeof record?.finished === "boolean") return record.finished;
return false;
}
function collectHttpUrls(value: unknown, urls = new Set<string>()) {
if (!value) return urls;
if (typeof value === "string") {
const rawUrlMatches = value.match(/https?:\/\/[^\s"'<>()[\]{}]+/g) || [];
for (const match of rawUrlMatches) {
urls.add(match.replace(/[),.;]+$/g, ""));
}
if (/^data:image\/[a-zA-Z0-9.+-]+;base64,/i.test(value)) urls.add(value);
return urls;
}
if (Array.isArray(value)) {
value.forEach((item) => collectHttpUrls(item, urls));
return urls;
}
if (typeof value !== "object") return urls;
Object.values(value as Record<string, unknown>).forEach((nested) =>
collectHttpUrls(nested, urls),
);
return urls;
}
function collectOutputUrls(payload: unknown) {
const data = unwrapData(payload);
const candidateRecord = toRecord(data);
const preferred = [
candidateRecord?.result,
candidateRecord?.results,
candidateRecord?.output,
candidateRecord?.outputs,
candidateRecord?.url,
candidateRecord?.urls,
candidateRecord?.imageUrl,
candidateRecord?.image_url,
candidateRecord?.videoUrl,
candidateRecord?.video_url,
candidateRecord?.assetUrl,
candidateRecord?.asset_url,
candidateRecord?.files,
candidateRecord?.assets,
data,
];
const urls = new Set<string>();
preferred.forEach((candidate) => collectHttpUrls(candidate, urls));
return Array.from(urls);
}
function parseDataUrl(value: string) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) throw new Error("VIP98K returned invalid Base64 media");
return {
mimeType: match[1] || "image/png",
buffer: Buffer.from(match[2] || "", "base64"),
};
}
function detectExtension(mimeType: string) {
if (/jpe?g/i.test(mimeType)) return "jpg";
if (/webp/i.test(mimeType)) return "webp";
if (/gif/i.test(mimeType)) return "gif";
if (/mp4/i.test(mimeType)) return "mp4";
if (/webm/i.test(mimeType)) return "webm";
if (/quicktime/i.test(mimeType)) return "mov";
return "png";
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
async function uploadBuffer(buffer: Buffer, mimeType: string) {
const extension = detectExtension(mimeType);
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer()
.catch(() => buffer);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [original, thumbnail] = await Promise.all([
uploadFile(`vip98k-orig-${suffix}.${extension}`, toArrayBuffer(buffer)),
uploadFile(`vip98k-thumb-${suffix}.jpg`, toArrayBuffer(thumbnailBuffer)),
]);
return { original: original.url, thumbnail: thumbnail.url };
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "vip98k",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k/1.0" },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`VIP98K result image download failed (HTTP ${response.status})`);
}
return uploadBuffer(
Buffer.from(await response.arrayBuffer()),
response.headers.get("content-type") || "image/png",
);
}
async function ensurePublicMediaUrl(mediaUrl: string) {
const normalized = String(mediaUrl || "").trim();
if (/^https?:\/\//i.test(normalized)) return normalized;
if (!normalized.startsWith("data:")) return normalized;
const parsed = parseDataUrl(normalized);
const extension = detectExtension(parsed.mimeType);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const uploaded = await uploadFile(
`vip98k-ref-${suffix}.${extension}`,
toArrayBuffer(parsed.buffer),
);
return uploaded.url;
}
function extractPromptAndMedia(messages: Array<{ role: string; content?: any; parts?: any[] }>) {
const message = messages.filter((item) => item.role === "user").pop();
if (!message) throw new Error("No user message found");
const texts: string[] = [];
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushMedia = (value: unknown, mediaType?: unknown) => {
const url = String(
typeof value === "string"
? value
: (value as any)?.url ||
(value as any)?.imageUrl ||
(value as any)?.fileUrl ||
(value as any)?.src ||
"",
).trim();
if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
const normalizedMediaType = String(mediaType || "").toLowerCase();
const isVideo =
normalizedMediaType.startsWith("video/") ||
url.startsWith("data:video/") ||
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
const target = isVideo ? videoUrls : imageUrls;
if (!target.includes(url)) target.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") return pushText(part);
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
if (part.type === "file") {
pushMedia(
part.url || part.file || part.data,
part.mediaType || part.contentType || part.mimeType,
);
}
if (part.inlineData?.data) {
pushMedia(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
part.inlineData.mimeType,
);
}
};
if (typeof message.content === "string") pushText(message.content);
else if (Array.isArray(message.content)) message.content.forEach(visitPart);
if (Array.isArray(message.parts)) message.parts.forEach(visitPart);
const prompt = texts.join("\n").trim() || "Generate media";
return { prompt, imageUrls, videoUrls };
}
function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string, fallback: number) {
const metadata = getVip98kMetadata(config);
const value = Number(metadata[key]);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.max(value, 1), 3600);
}
async function submitTask(params: {
apiKey: string;
baseURL?: string | null;
body: Record<string, unknown>;
}) {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const response = await fetch(`${baseURL}/api/external/task/generate`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(120_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
}
return {
payload,
taskId: extractTaskId(payload),
resultPath: extractResultPath(payload),
outputs: collectOutputUrls(payload),
};
}
async function waitForTask(params: {
apiKey: string;
baseURL?: string | null;
taskId: string;
resultPath?: string;
modelConfig: Vip98kModelRuntimeConfig;
}) {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const intervalMs =
getConfiguredPollSeconds(
params.modelConfig,
"pollIntervalSeconds",
DEFAULT_POLL_INTERVAL_SECONDS,
) * 1000;
const timeoutMs =
getConfiguredPollSeconds(params.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS) *
1000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await delay(intervalMs);
const resultUrl = params.resultPath
? params.resultPath.startsWith("http")
? params.resultPath
: `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}`
: `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`;
const response = await fetch(resultUrl, {
cache: "no-store",
headers: { Authorization: `Bearer ${params.apiKey}` },
signal: AbortSignal.timeout(60_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
}
const status = extractStatus(payload);
const done = extractDone(payload);
const outputs = collectOutputUrls(payload);
if (
["succeeded", "success", "completed", "done", "finished"].includes(status) ||
(done && outputs.length > 0)
) {
return { payload, outputs };
}
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
throw new Error(extractErrorMessage(payload, "VIP98K task failed"));
}
}
throw new Error("VIP98K task timed out; it may still be running");
}
function normalizeRatio(value: unknown) {
const normalized = String(value || "").trim();
return normalized && normalized !== "auto" ? normalized : undefined;
}
function normalizeDuration(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return undefined;
return Math.round(numeric);
}
function buildTaskBody(params: Vip98kTaskParams) {
const videoParams = params.videoParams || {};
const imageParams = params.imageParams || {};
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
const body: Record<string, unknown> = {
model: params.modelId,
displayName: promptTitle || params.modelId,
prompt: params.prompt,
};
const aspectRatio =
normalizeRatio(videoParams.ratio) ||
normalizeRatio(videoParams.aspectRatio) ||
normalizeRatio(imageParams.aspectRatio);
const duration = normalizeDuration(videoParams.duration);
const resolution = String(videoParams.resolution || imageParams.imageSize || "").trim();
const negativePrompt = String(videoParams.negativePrompt || imageParams.negativePrompt || "").trim();
if (aspectRatio) {
body.ratio = aspectRatio;
body.aspectRatio = aspectRatio;
body.aspect_ratio = aspectRatio;
}
if (duration) body.duration = duration;
if (resolution) {
body.resolution = resolution;
body.imageSize = resolution;
body.image_size = resolution;
}
if (negativePrompt) {
body.negativePrompt = negativePrompt;
body.negative_prompt = negativePrompt;
}
if (typeof videoParams.generateAudio === "boolean") {
body.generateAudio = videoParams.generateAudio;
body.generate_audio = videoParams.generateAudio;
}
if (params.imageUrls.length > 0) {
body.images = params.imageUrls;
body.imageUrls = params.imageUrls;
body.referenceImages = params.imageUrls;
body.reference_images = params.imageUrls;
body.image = params.imageUrls[0];
body.imageUrl = params.imageUrls[0];
body.image_url = params.imageUrls[0];
body.firstFrameImage = params.imageUrls[0];
body.first_frame_image = params.imageUrls[0];
body.firstFrameImageUrl = params.imageUrls[0];
body.first_frame_image_url = params.imageUrls[0];
if (params.imageUrls[1]) {
body.lastFrameImage = params.imageUrls[1];
body.last_frame_image = params.imageUrls[1];
body.endImageUrl = params.imageUrls[1];
body.end_image_url = params.imageUrls[1];
}
}
if (params.videoUrls.length > 0) {
body.videos = params.videoUrls;
body.videoUrls = params.videoUrls;
body.referenceVideos = params.videoUrls;
}
if (params.kind === "world") {
body.mode = "world-generation";
} else if (params.kind === "video") {
body.mode = params.imageUrls.length > 0 ? "image-to-video" : "text-to-video";
}
return body;
}
function formatImageMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.join("\n\n");
}
function formatVideoMarkdown(videoUrl: string) {
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
}
function formatWorldMarkdown(urls: string[]) {
if (urls.length === 0) return "3D 世界生成成功,但未返回可访问的结果地址";
return urls.map((url, index) => `[查看 3D 世界 ${index + 1}](${url})`).join("\n\n");
}
async function runTask(params: Vip98kTaskParams) {
const body = buildTaskBody(params);
const submitted = await submitTask({
apiKey: params.apiKey,
baseURL: params.baseURL,
body,
});
const outputUrls = submitted.outputs.length
? submitted.outputs
: await waitForTask({
apiKey: params.apiKey,
baseURL: params.baseURL,
taskId: submitted.taskId,
resultPath: submitted.resultPath,
modelConfig: params.modelConfig,
}).then((result) => result.outputs);
if (params.kind === "video") {
const videoUrl =
outputUrls.find((url) => /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url)) ||
outputUrls[0];
if (!videoUrl) throw new Error("VIP98K task completed but returned no video URL");
return formatVideoMarkdown(videoUrl);
}
if (params.kind === "image" || params.kind === "image-tool") {
if (outputUrls.length === 0) {
throw new Error("VIP98K task completed but returned no image URL");
}
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
return formatImageMarkdown(archived, imageSize);
}
if (params.kind === "world") {
return formatWorldMarkdown(outputUrls);
}
return outputUrls.length ? outputUrls.join("\n") : "VIP98K task completed";
}
export function createVip98k({ apiKey, baseURL }: Vip98kConfig) {
const rootBaseURL = normalizeVip98kBaseUrl(baseURL);
const openAIBaseURL = normalizeOpenAIBaseUrl(rootBaseURL);
const openAITextProvider = createOpenAI({
apiKey,
baseURL: openAIBaseURL,
});
const openAIImageProvider = createOpenAIImage({
apiKey,
baseURL: openAIBaseURL,
});
return {
provider: "vip98k",
chat: (modelId: string, defaultModelConfig?: unknown): any => {
const modelConfig = parseRuntimeConfig(defaultModelConfig);
const kind = inferKind(modelId, modelConfig);
if (kind === "image") {
return openAIImageProvider.chat(modelId) as any;
}
if (kind === "chat") {
return openAITextProvider.chat(modelId) as any;
}
return {
provider: "vip98k",
doGenerate: async (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: Record<string, unknown>;
videoParams?: Record<string, unknown>;
imageGenConfig?: unknown;
overrideModelId?: string;
}) => {
const runtimeConfig =
options.imageGenConfig !== undefined
? parseRuntimeConfig({
capabilities: modelConfig.capabilities,
imageGenConfig: options.imageGenConfig,
})
: modelConfig;
const activeModelId = String(options.overrideModelId || modelId || "").trim();
if (!activeModelId) throw new Error("VIP98K model ID is not configured");
const activeKind = inferKind(activeModelId, runtimeConfig);
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(options.messages);
const [publicImageUrls, publicVideoUrls] = await Promise.all([
Promise.all(imageUrls.map(ensurePublicMediaUrl)),
Promise.all(videoUrls.map(ensurePublicMediaUrl)),
]);
const text = await runTask({
apiKey,
baseURL: rootBaseURL,
modelId: activeModelId,
kind: activeKind,
prompt,
imageUrls: publicImageUrls,
videoUrls: publicVideoUrls,
videoParams: options.videoParams,
imageParams: options.imageParams,
modelConfig: runtimeConfig,
});
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
},
doStream: async function* (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: Record<string, unknown>;
videoParams?: Record<string, unknown>;
imageGenConfig?: unknown;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield { type: "text-delta", textDelta: result.text };
},
};
},
};
}

View File

@ -7,6 +7,8 @@ import { tool } from "ai";
import { z } from "zod";
import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
import {
getEnabledAiModelsWithProvider,
getSubModelsByParentModelId
@ -55,6 +57,16 @@ function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[]
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
function extractVideoUrlFromGeneratedText(value: unknown) {
const text = String(value || "");
const srcMatch = text.match(/<video[^>]+src=["'](https?:\/\/[^"']+)["']/i);
if (srcMatch?.[1]) return srcMatch[1];
const markdownMatch = text.match(/\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
const directMatch = text.match(/https?:\/\/[^\s<>"']+/i);
return directMatch?.[0]?.replace(/[),.;]+$/, "") || "";
}
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
if (Array.isArray(modes) && modes.length > 0) {
@ -120,11 +132,24 @@ function isAtlasCloudVideoModel(item: any) {
);
}
function isVip98kVideoModel(item: any) {
return (
item?.provider?.type === "vip98k" &&
isVideoModelDescriptor({
providerType: item.provider.type,
modelId: item.model?.modelId,
capabilities: item.model?.capabilities,
supportsImageGen: item.model?.supportsImageGen,
})
);
}
function isDirectExecutableVideoModel(item: any) {
return (
isVolcanoVideoModel(item) ||
isAgnesVideoModel(item) ||
isAtlasCloudVideoModel(item)
isAtlasCloudVideoModel(item) ||
isVip98kVideoModel(item)
);
}
@ -540,6 +565,103 @@ async function executeAgnesVideoGeneration(params: {
);
}
async function resolvePreferredVip98kVideoModel(
preferredModelRef: string | undefined,
mode: VideoGenerationMode,
params: Partial<VideoGenerationOptions>,
) {
const normalizedRef = String(preferredModelRef || "").trim();
if (!normalizedRef) return null;
const models = await getEnabledAiModelsWithProvider();
const matched = models.find(
(item) =>
isVip98kVideoModel(item) &&
item.model.isEnabled &&
!item.model.isSubModel &&
(item.model.id === normalizedRef ||
item.model.modelId === normalizedRef ||
item.model.displayName === normalizedRef),
);
if (!matched) return null;
const capabilities = withConfiguredVideoCapabilities(
matched.model.capabilities as VideoModelCapabilities | undefined,
(matched.model as any).imageGenConfig,
);
const supportedModes = getSupportedModes(capabilities, matched.model.modelId);
if (!supportedModes.includes(mode)) {
throw new Error(`模型 ${matched.model.displayName} 不支持 ${mode} 模式`);
}
const resolvedCapabilities = {
...capabilities,
supportedModes,
} as VideoModelCapabilities;
validateParamsAgainstCapabilities(params, resolvedCapabilities);
return { model: matched, capabilities: resolvedCapabilities };
}
async function executeVip98kVideoGeneration(params: {
request: ExecuteDirectVideoGenerationParams;
prompt: string;
selected: { model: any; capabilities: VideoModelCapabilities };
generationParams: Partial<VideoGenerationOptions>;
}): Promise<ExecuteDirectVideoGenerationResult> {
const selected = params.selected.model;
const languageModel = await getLanguageModelById(selected.model.id);
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
throw new Error("所选 VIP98K 视频模型初始化失败");
}
await params.request.onProgress?.(
`正在调用 ${selected.model.displayName || selected.model.modelId} 生成视频...`,
);
const imageParts = (params.request.images || []).map((url) => ({
type: "image_url" as const,
image_url: { url },
}));
const result = await (languageModel as any).doGenerate({
messages: [
{
role: "user",
content: [{ type: "text", text: params.prompt }, ...imageParts],
},
],
overrideModelId: selected.model.modelId,
videoParams: {
mode: params.request.mode,
resolution: params.generationParams.resolution || "720p",
ratio: params.generationParams.ratio || "16:9",
duration: params.generationParams.duration || 8,
watermark: params.generationParams.watermark,
cameraFixed: params.generationParams.cameraFixed,
seed: params.generationParams.seed,
returnLastFrame: params.generationParams.returnLastFrame,
},
});
if (result?.finishReason === "error") {
throw new Error(String(result?.text || "VIP98K 视频生成失败"));
}
const videoUrl = extractVideoUrlFromGeneratedText(result?.text);
if (!videoUrl) throw new Error("VIP98K 视频模型未返回可用视频地址");
return {
success: true,
videoUrl,
metadata: {
provider: "vip98k",
temporaryVideoUrl: videoUrl,
mode: params.request.mode,
resolution: params.generationParams.resolution,
ratio: params.generationParams.ratio,
duration: params.generationParams.duration,
},
modelDisplayName: String(selected.model.displayName || selected.model.modelId),
modelId: String(selected.model.modelId || selected.model.id),
modelRef: String(selected.model.id || selected.model.modelId),
};
}
export async function executeDirectVideoGeneration(
params: ExecuteDirectVideoGenerationParams,
): Promise<ExecuteDirectVideoGenerationResult> {
@ -589,6 +711,20 @@ export async function executeDirectVideoGeneration(
referenceImages: params.images,
} satisfies Partial<VideoGenerationOptions>;
const preferredVip98kModel = await resolvePreferredVip98kVideoModel(
params.preferredModelRef,
params.mode,
generationParams,
);
if (preferredVip98kModel) {
return executeVip98kVideoGeneration({
request: params,
prompt,
selected: preferredVip98kModel,
generationParams,
});
}
let selectedModelConfig: { model: any; capabilities: VideoModelCapabilities };
try {
selectedModelConfig = await getCompatibleModel(

626
lib/ai/vip98k-models.ts Normal file
View File

@ -0,0 +1,626 @@
import "server-only";
export const VIP98K_BASE_URL = "https://api.vip98k.com";
export type Vip98kCatalogModelType =
| "image"
| "video"
| "image_tool"
| "document_tool"
| "model"
| "audio"
| "world"
| string;
export interface Vip98kCatalogModel {
modelId?: string;
id?: string;
name?: string;
displayName?: string;
display_name?: string;
description?: string;
type?: Vip98kCatalogModelType;
modelType?: Vip98kCatalogModelType;
model_type?: Vip98kCatalogModelType;
category?: Vip98kCatalogModelType;
capabilities?: unknown;
inputModes?: unknown;
input_modes?: unknown;
outputModes?: unknown;
output_modes?: unknown;
supportedResolutions?: unknown;
supported_resolutions?: unknown;
resolutions?: unknown;
durations?: unknown;
durationOptions?: unknown;
duration_options?: unknown;
defaultDuration?: unknown;
default_duration?: unknown;
supportsAsync?: unknown;
supports_async?: unknown;
pollIntervalSeconds?: unknown;
poll_interval_seconds?: unknown;
maxPollSeconds?: unknown;
max_poll_seconds?: unknown;
resultTTLSeconds?: unknown;
result_ttl_seconds?: unknown;
billingMode?: string;
billing_mode?: string;
pricing?: unknown;
price?: unknown;
status?: string;
[key: string]: unknown;
}
export interface Vip98kModelPreset {
modelId: string;
displayName: string;
description: string;
type: Vip98kCatalogModelType;
supportsVision: boolean;
supportsImageGen: boolean;
isHidden: boolean;
capabilities: Record<string, unknown>;
defaultParams: Record<string, unknown>;
imageGenConfig: Record<string, unknown> | null;
sortOrder: number;
}
const DEFAULT_ASPECT_RATIOS = [
"auto",
"1:1",
"4:3",
"3:4",
"16:9",
"9:16",
"3:2",
"2:3",
"4:5",
"5:4",
] as const;
const DEFAULT_VIDEO_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"] as const;
const DEFAULT_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
const TYPE_SORT_ORDER: Record<string, number> = {
image: 100,
video: 200,
world: 300,
model: 400,
image_tool: 800,
audio: 900,
document_tool: 950,
};
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function parseStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean),
),
);
}
function parseNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0),
),
).sort((a, b) => a - b);
}
function parseNumber(value: unknown): number | undefined {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : undefined;
}
function normalizeCatalogType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
return String(
model.type || model.modelType || model.model_type || model.category || "model",
)
.trim()
.toLowerCase();
}
function normalizeImageSize(value: string): string | null {
const normalized = value.trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
const kMatch = normalized.match(/^(\d+(?:\.\d+)?)\s*K$/);
if (kMatch?.[1]) return `${Number(kMatch[1]).toString()}K`;
return null;
}
function normalizeVideoResolution(value: string): string | null {
const normalized = value.trim().toLowerCase();
if (normalized === "720p" || normalized === "1080p" || normalized === "4k") {
return normalized;
}
if (normalized === "720" || normalized === "720P".toLowerCase()) return "720p";
if (normalized === "1080" || normalized === "1080P".toLowerCase()) return "1080p";
if (normalized === "2160p" || normalized === "2160" || normalized === "4K".toLowerCase()) {
return "4k";
}
return normalized || null;
}
function getSupportedResolutions(model: Vip98kCatalogModel) {
return parseStringArray(
model.supportedResolutions || model.supported_resolutions || model.resolutions,
);
}
function getSupportedImageSizes(model: Vip98kCatalogModel) {
const sizes = getSupportedResolutions(model)
.map(normalizeImageSize)
.filter((value): value is string => Boolean(value));
return sizes.length ? sizes : [...DEFAULT_IMAGE_SIZES];
}
function getSupportedVideoResolutions(model: Vip98kCatalogModel) {
const resolutions = getSupportedResolutions(model)
.map(normalizeVideoResolution)
.filter((value): value is string => Boolean(value));
return resolutions.length ? resolutions : ["720p"];
}
function getDurations(model: Vip98kCatalogModel) {
return parseNumberArray(
model.durations || model.durationOptions || model.duration_options,
);
}
function getCapabilities(model: Vip98kCatalogModel) {
return parseStringArray(model.capabilities).map((item) =>
item.trim().toLowerCase().replace(/_/g, "-"),
);
}
function getInputModes(model: Vip98kCatalogModel) {
return parseStringArray(model.inputModes || model.input_modes).map((item) =>
item.trim().toLowerCase(),
);
}
function getOutputModes(model: Vip98kCatalogModel) {
return parseStringArray(model.outputModes || model.output_modes).map((item) =>
item.trim().toLowerCase(),
);
}
function getDisplayName(model: Vip98kCatalogModel, modelId: string) {
return String(
model.displayName || model.display_name || model.name || modelId,
).trim();
}
function getModelId(model: Vip98kCatalogModel) {
return String(model.modelId || model.id || model.name || "").trim();
}
function getEffectivePrice(model: Vip98kCatalogModel) {
const pricing = toRecord(model.pricing);
return parseNumber(
model.price ||
pricing?.effectivePrice ||
pricing?.effective_price ||
pricing?.price ||
pricing?.requestPrice ||
pricing?.request_price,
);
}
function getBillingMode(model: Vip98kCatalogModel) {
return String(model.billingMode || model.billing_mode || "per_request").trim();
}
function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
return {
modelType: type,
taskApi: "external-task",
supportsAsync: Boolean(model.supportsAsync ?? model.supports_async ?? true),
pollIntervalSeconds: parseNumber(
model.pollIntervalSeconds || model.poll_interval_seconds,
) || 8,
maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || 600,
resultTTLSeconds: parseNumber(
model.resultTTLSeconds || model.result_ttl_seconds,
),
billingMode: getBillingMode(model),
effectivePrice: getEffectivePrice(model),
pricing: model.pricing || null,
status: model.status || null,
};
}
function createImagePreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const supportedImageSizes = getSupportedImageSizes(model);
const supportsReferenceImages =
capabilities.includes("image-to-image") ||
inputModes.includes("url") ||
inputModes.includes("file");
const supportedModes = [
...(capabilities.includes("text-to-image") || capabilities.length === 0
? ["text-to-image"]
: []),
...(supportsReferenceImages ? ["image-to-image", "reference-images"] : []),
];
const maxReferenceImages = supportsReferenceImages ? 8 : 0;
const defaultImageSize =
supportedImageSizes.find((size) => size === "2K") ||
supportedImageSizes[0] ||
"1K";
return {
modelId,
displayName,
description: model.description || `VIP98K image model (${modelId})`,
type: "image",
supportsVision: supportsReferenceImages,
supportsImageGen: true,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "image",
mediaType: "image",
modelType: "image",
outputType: "image",
supportedModes,
inputModes,
outputModes,
supportedImageSizes,
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
supportsMultiImageReference: supportsReferenceImages,
maxReferenceImages,
vip98k: getVip98kMetadata(model, "image"),
},
defaultParams: {
provider: "vip98k",
kind: "image",
endpoint: modelId,
apiFormat: "openai-images-compatible",
taskApi: "openai-images",
supportedModes,
imageSize: defaultImageSize,
aspectRatio: "auto",
imageCount: 1,
},
imageGenConfig: {
maxImages: 1,
maxReferenceImages,
supportsSize: true,
supports1K: supportedImageSizes.includes("1K"),
supports2K: supportedImageSizes.includes("2K"),
supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes,
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostMultiplierBySize: {
"1K": 1,
"2K": 1,
"4K": 1,
},
enableLoadingPlaceholder: true,
loadingColor: "yellow",
vip98k: getVip98kMetadata(model, "image"),
},
sortOrder: TYPE_SORT_ORDER.image + index,
};
}
function createVideoPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const supportedResolutions = getSupportedVideoResolutions(model);
const durations = getDurations(model);
const defaultDuration =
parseNumber(model.defaultDuration || model.default_duration) ||
durations[durations.length - 1] ||
8;
const supportsReferenceImages =
capabilities.includes("image-to-video") ||
inputModes.includes("url") ||
inputModes.includes("file");
const supportedModes = [
...(capabilities.includes("text-to-video") || capabilities.length === 0
? ["text-to-video"]
: []),
...(supportsReferenceImages ? ["image-to-video", "first-frame"] : []),
];
return {
modelId,
displayName,
description: model.description || `VIP98K video model (${modelId})`,
type: "video",
supportsVision: supportsReferenceImages,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "video",
mediaType: "video",
modelType: "video",
outputType: "video",
supportedModes,
inputModes,
outputModes,
supportedResolutions,
supportedRatios: [...DEFAULT_VIDEO_RATIOS],
durationOptions: durations,
durationRange: durations.length
? { min: durations[0], max: durations[durations.length - 1] }
: { min: defaultDuration, max: defaultDuration },
defaultDuration,
supportWatermark: false,
supportCameraFixed: false,
maxReferenceImages: supportsReferenceImages ? 4 : 0,
vip98k: getVip98kMetadata(model, "video"),
},
defaultParams: {
provider: "vip98k",
kind: "video",
endpoint: modelId,
taskApi: "external-task",
supportedModes,
duration: defaultDuration,
resolution: supportedResolutions[0] || "720p",
ratio: "16:9",
},
imageGenConfig: {
creditCostPerVideo: 0,
videoDurationOptions: durations,
creditCostPerVideoByDuration: Object.fromEntries(
durations.map((duration) => [String(duration), 0]),
),
creditCostPerVideoByResolution: Object.fromEntries(
supportedResolutions.map((resolution) => [resolution, 0]),
),
enableLoadingPlaceholder: true,
loadingColor: "blue",
vip98k: getVip98kMetadata(model, "video"),
},
sortOrder: TYPE_SORT_ORDER.video + index,
};
}
function createWorldPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
return {
modelId,
displayName,
description: model.description || `VIP98K 3D world model (${modelId})`,
type: "world",
supportsVision: false,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "world",
mediaType: "world",
modelType: "world-generation",
outputType: "3d",
supportedModes: ["world-generation", "text-to-3d"],
vip98k: getVip98kMetadata(model, "world"),
},
defaultParams: {
provider: "vip98k",
kind: "world",
endpoint: modelId,
taskApi: "external-task",
},
imageGenConfig: null,
sortOrder: TYPE_SORT_ORDER.world + index,
};
}
function createChatPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
return {
modelId,
displayName,
description: model.description || `VIP98K text model (${modelId})`,
type: "model",
supportsVision: false,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "chat",
mediaType: "text",
modelType: "model",
outputType: "text",
apiFormat: "openai-compatible",
vip98k: getVip98kMetadata(model, "model"),
},
defaultParams: {
provider: "vip98k",
kind: "chat",
endpoint: modelId,
apiFormat: "openai-compatible",
},
imageGenConfig: null,
sortOrder: TYPE_SORT_ORDER.model + index,
};
}
function createToolPreset(
model: Vip98kCatalogModel,
type: string,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const isImageTool = type === "image_tool";
return {
modelId,
displayName,
description: model.description || `VIP98K ${type} model (${modelId})`,
type,
supportsVision: isImageTool,
supportsImageGen: false,
isHidden: true,
capabilities: {
provider: "vip98k",
kind: type.replace(/_/g, "-"),
mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document",
modelType: type,
outputType: outputModes[0] || (isImageTool ? "image" : "text"),
inputModes,
outputModes,
supportedModes: getCapabilities(model),
vip98k: getVip98kMetadata(model, type),
},
defaultParams: {
provider: "vip98k",
kind: type.replace(/_/g, "-"),
endpoint: modelId,
taskApi: type === "audio" ? "openai-compatible" : "external-task",
},
imageGenConfig: null,
sortOrder: (TYPE_SORT_ORDER[type] || 990) + index,
};
}
export function buildVip98kModelPresets(
models: Vip98kCatalogModel[],
): Vip98kModelPreset[] {
return models
.map((model, index) => {
const modelId = getModelId(model);
if (!modelId) return null;
const type = normalizeCatalogType(model);
if (type === "image") return createImagePreset(model, index);
if (type === "video") return createVideoPreset(model, index);
if (type === "world") return createWorldPreset(model, index);
if (type === "model") return createChatPreset(model, index);
return createToolPreset(model, type, index);
})
.filter((item): item is Vip98kModelPreset => Boolean(item))
.sort((a, b) => a.sortOrder - b.sortOrder || a.displayName.localeCompare(b.displayName));
}
export function normalizeVip98kBaseUrl(baseURL?: string | null) {
const normalized = String(baseURL || VIP98K_BASE_URL).trim().replace(/\/+$/, "");
if (!normalized) return VIP98K_BASE_URL;
if (/\/v1$/i.test(normalized)) return normalized.replace(/\/v1$/i, "");
if (/\/api\/external\/task$/i.test(normalized)) {
return normalized.replace(/\/api\/external\/task$/i, "");
}
return normalized;
}
function extractCatalogModels(payload: unknown): Vip98kCatalogModel[] {
const record = toRecord(payload);
const data = record?.data;
const dataRecord = toRecord(data);
const candidates = [
dataRecord?.models,
dataRecord?.items,
dataRecord?.list,
data,
record?.models,
record?.items,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate as Vip98kCatalogModel[];
}
return [];
}
async function parseJsonResponse(response: Response) {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function extractErrorMessage(payload: unknown, fallback = "VIP98K model catalog request failed") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
const record = toRecord(payload);
const data = toRecord(record?.data);
const error = toRecord(record?.error);
const value =
data?.message ||
data?.error ||
error?.message ||
record?.message ||
record?.error ||
fallback;
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
export async function fetchVip98kCatalog(params: {
baseURL?: string | null;
apiKey: string;
}): Promise<Vip98kCatalogModel[]> {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const response = await fetch(`${baseURL}/api/external/task/models`, {
cache: "no-store",
headers: {
Authorization: `Bearer ${params.apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(60_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
}
const models = extractCatalogModels(payload);
if (models.length === 0) {
throw new Error("VIP98K model catalog is empty or has an unsupported response shape");
}
return models;
}
export async function fetchVip98kModelPresets(params: {
baseURL?: string | null;
apiKey: string;
}) {
const models = await fetchVip98kCatalog(params);
return buildVip98kModelPresets(models);
}

View File

@ -13,6 +13,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
@ -300,6 +301,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -4155,6 +4157,7 @@ function getProviderBatchLimit(providerType: string) {
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
@ -5341,6 +5344,29 @@ export async function generateCanvasImage(
});
return result.text || "";
};
} else if (provider.type === "vip98k") {
const vip98kProvider = createVip98k({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = vip98kProvider.chat(runtimeModelId, {
capabilities: selectedModel.model.capabilities || null,
imageGenConfig: selectedModel.model.imageGenConfig || null,
});
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
imageGenConfig: selectedModel.model.imageGenConfig || null,
});
return result.text || "";
};
} else if (provider.type === "stepfun-image") {
const stepfunProvider = createStepfunImage({
apiKey: provider.apiKey,
@ -5417,7 +5443,7 @@ export async function generateCanvasImage(
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/StepFun/Midjourney/Runway/Krea 图片模型`
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/VIP98K/StepFun/Midjourney/Runway/Krea 图片模型`
);
}

View File

@ -36,6 +36,7 @@ const providerBaseUrlDefaults = [
{ type: "bananaimage", baseUrl: "https://bananaimage.aigenmedia.art" },
{ type: "visionary", baseUrl: "https://api.visionary.beer" },
{ type: "atlascloud", baseUrl: "https://api.atlascloud.ai" },
{ type: "vip98k", baseUrl: "https://api.vip98k.com" },
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
{ type: "fal-video", baseUrl: "https://fal.run" },
@ -153,7 +154,7 @@ async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'atlascloud', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'atlascloud', 'vip98k', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
));
`);
await db.execute(sql`

View File

@ -0,0 +1,41 @@
ALTER TABLE "AiProvider"
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
ALTER TABLE "AiProvider"
ADD CONSTRAINT "AiProvider_type_check"
CHECK (
"type" IN (
'alibaba',
'openai',
'custom',
'local',
'gemini',
'grsai-gpt-image',
'grsai-video',
'huanke-gemini',
'qianwen-video',
'doubao-video',
'ruxa-video',
'volcano-vision',
'volcano-video',
'alibaba-image',
'bfl-image',
'ruxa-image',
'fal-image',
'snapgen-image',
'openai-image',
'bananaimage',
'visionary',
'atlascloud',
'vip98k',
'stepfun-image',
'midjourney',
'fal-video',
'zhipu',
'tripo-3d',
'worldlabs-3d',
'kittycad-cad',
'runway',
'krea'
)
);

View File

@ -582,6 +582,13 @@
"when": 1786060800000,
"tag": "0096_add_user_access_keys",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1786176000000,
"tag": "0097_add_vip98k_provider_type",
"breakpoints": true
}
]
}

View File

@ -3786,7 +3786,7 @@ export async function createAiProvider({
updatedBy,
}: {
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "vip98k" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@ -3824,7 +3824,7 @@ export async function updateAiProvider({
}: {
id: string;
name?: string;
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "vip98k" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;
@ -3854,7 +3854,10 @@ export async function updateAiProvider({
export async function deleteAiProvider({ id }: { id: string }) {
try {
return await db.delete(aiProvider).where(eq(aiProvider.id, id)).returning();
return await db.transaction(async (tx) => {
await tx.delete(aiModel).where(eq(aiModel.providerId, id));
return tx.delete(aiProvider).where(eq(aiProvider.id, id)).returning();
});
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",

View File

@ -309,7 +309,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL

View File

@ -353,6 +353,7 @@ function supportsImageEditForReferenceCount(
providerType === "bananaimage" ||
providerType === "visionary" ||
providerType === "atlascloud" ||
providerType === "vip98k" ||
providerType === "grsai-gpt-image" ||
providerType === "ruxa-image" ||
providerType === "snapgen-image";

View File

@ -80,6 +80,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "gemini":
case "volcano-vision":
return true;
@ -137,6 +138,7 @@ function getSupportedAspectRatiosByProvider(
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "gemini":
default:
return [...ALL_ASPECT_RATIO_VALUES];