diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts
index 0368973af..389517568 100644
--- a/app/(admin)/settings/_lib/types.ts
+++ b/app/(admin)/settings/_lib/types.ts
@@ -248,7 +248,7 @@ export type EnhanceProKeyRow = {
};
export type RmbgConfig = {
- provider: "rmbg" | "photoroom" | "aoscdn";
+ provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string;
apiKey: string;
photoroomApiKey: string;
@@ -260,6 +260,7 @@ export type RmbgConfig = {
};
export type EnhanceConfig = {
+ provider: "vip98k" | "legacy";
apiUrl: string;
apiKey: string;
type: string;
@@ -272,6 +273,7 @@ export type EnhanceConfig = {
};
export type ExtendConfig = {
+ provider: "vip98k" | "dashscope";
apiKey: string;
xScale: string;
yScale: string;
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 3a6cddf55..514c4ceb1 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -2568,7 +2568,7 @@ export function AdminSettingsClient() {
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
- provider: "rmbg" as "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
+ provider: "vip98k" as "vip98k" | "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
apiUrl: "",
apiKey: "",
// PhotoRoom 配置
@@ -2583,6 +2583,7 @@ export function AdminSettingsClient() {
// 变清晰API配置状态
const [enhanceConfig, setEnhanceConfig] = useState({
+ provider: "vip98k" as "vip98k" | "legacy",
apiUrl: "",
apiKey: "",
type: "clean", // "clean" | "face"
@@ -2609,6 +2610,7 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
+ provider: "vip98k" as "vip98k" | "dashscope",
apiKey: "",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
@@ -2773,7 +2775,7 @@ export function AdminSettingsClient() {
useEffect(() => {
if (rmbgConfigData?.setting?.value) {
const config = rmbgConfigData.setting.value as {
- provider?: "rmbg" | "photoroom" | "aoscdn";
+ provider?: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
@@ -2784,7 +2786,7 @@ export function AdminSettingsClient() {
aosFormat?: "png" | "jpg";
};
setRmbgConfig({
- provider: config.provider || "rmbg",
+ provider: config.provider || "vip98k",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
photoroomApiKey: config.photoroomApiKey || "",
@@ -2810,6 +2812,7 @@ export function AdminSettingsClient() {
useEffect(() => {
if (enhanceConfigData?.setting?.value) {
const config = enhanceConfigData.setting.value as {
+ provider?: "vip98k" | "legacy";
apiUrl?: string;
apiKey?: string;
type?: string;
@@ -2821,6 +2824,7 @@ export function AdminSettingsClient() {
backupModel?: string;
};
setEnhanceConfig({
+ provider: config.provider === "vip98k" ? "vip98k" : "legacy",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
type: config.type || "clean",
@@ -2918,11 +2922,13 @@ export function AdminSettingsClient() {
useEffect(() => {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
+ provider?: "vip98k" | "dashscope";
apiKey?: string;
xScale?: number;
yScale?: number;
};
setExtendConfig({
+ provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
apiKey: config.apiKey || "",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
@@ -5108,7 +5114,9 @@ export function AdminSettingsClient() {
const handleSaveEnhanceConfig = async () => {
try {
// 如果启用了备用API,验证备用API配置
- if (enhanceConfig.useBackupApi) {
+ if (enhanceConfig.provider === "vip98k") {
+ // VIP98K credentials are read from AiProvider and never duplicated here.
+ } else if (enhanceConfig.useBackupApi) {
if (!enhanceConfig.backupApiUrl.trim()) {
toast.error("请输入备用API地址");
return;
@@ -5135,6 +5143,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
key: "enhance_api_config",
value: {
+ provider: enhanceConfig.provider,
apiUrl: enhanceConfig.apiUrl.trim(),
apiKey: enhanceConfig.apiKey.trim(),
type: enhanceConfig.type,
@@ -5483,7 +5492,7 @@ export function AdminSettingsClient() {
// --- 图像拓展API配置逻辑 ---
const handleSaveExtendConfig = async () => {
try {
- if (!extendConfig.apiKey.trim()) {
+ if (extendConfig.provider !== "vip98k" && !extendConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
@@ -5507,6 +5516,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
key: "extend_api_config",
value: {
+ provider: extendConfig.provider,
apiKey: extendConfig.apiKey.trim(),
xScale,
yScale,
diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts
index cb9af5f55..e85c9ca25 100644
--- a/app/api/canvas/3d/tasks/[taskId]/route.ts
+++ b/app/api/canvas/3d/tasks/[taskId]/route.ts
@@ -17,6 +17,7 @@ import {
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import type { TripoTaskData } from "@/lib/tripo/types";
+import { getConfiguredVip98kTaskStatus } from "@/lib/ai/vip98k-runtime";
export const runtime = "nodejs";
export const maxDuration = 120;
@@ -319,6 +320,111 @@ export async function GET(request: Request, { params }: RouteParams) {
const { searchParams } = new URL(request.url);
const preferredProviderId = searchParams.get("providerId");
+ if (normalizedTaskId.startsWith("vip98k:")) {
+ const [, encodedProviderId, ...remoteTaskParts] = normalizedTaskId.split(":");
+ const remoteTaskId = remoteTaskParts.join(":");
+ if (!encodedProviderId || !remoteTaskId) {
+ return NextResponse.json(
+ { success: false, error: "VIP98K 3D taskId 格式无效" },
+ { status: 400 },
+ );
+ }
+ const result = await getConfiguredVip98kTaskStatus({
+ taskId: remoteTaskId,
+ preferredProviderId: encodedProviderId,
+ });
+ const modelUrl =
+ result.outputUrls.find((url) =>
+ /\.(?:glb|gltf|fbx|obj|stl|3mf|usdz|ply|zip)(?:[?#].*)?$/i.test(url),
+ ) || result.outputUrls[0] || null;
+ const previewImageUrl =
+ result.outputUrls.find((url) =>
+ /\.(?:png|jpe?g|webp|gif)(?:[?#].*)?$/i.test(url),
+ ) || null;
+ const stableModelUrl = await archiveTripoModelIfNeeded({
+ taskId: remoteTaskId,
+ cacheKey: `vip98k:${remoteTaskId}:model`,
+ sourceUrl: modelUrl,
+ filenamePrefix: `vip98k-model-${remoteTaskId}`,
+ fallbackExtension: "glb",
+ });
+ const stablePreviewImageUrl = await archiveTripoModelIfNeeded({
+ taskId: remoteTaskId,
+ cacheKey: `vip98k:${remoteTaskId}:preview`,
+ sourceUrl: previewImageUrl,
+ filenamePrefix: `vip98k-preview-${remoteTaskId}`,
+ fallbackExtension: "jpg",
+ });
+ const errorMessage = result.failed ? "VIP98K 3D 任务执行失败" : null;
+ const refundedPoints = result.failed
+ ? await refundModel3DPointsForFailedTask({
+ userId: session.user.id,
+ taskId: normalizedTaskId,
+ errorMessage: errorMessage || "VIP98K 3D task failed",
+ })
+ : 0;
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId: normalizedTaskId,
+ type: "model",
+ status: result.failed ? "failed" : result.done ? "success" : result.status || "running",
+ progress: result.done ? 100 : null,
+ output: result.payload,
+ modelUrl: stableModelUrl,
+ previewImageUrl: stablePreviewImageUrl,
+ assetPayload: {
+ providerId: result.providerId,
+ providerName: result.providerName,
+ storageArchivedAt: stableModelUrl || stablePreviewImageUrl ? new Date().toISOString() : null,
+ },
+ errorCode: result.failed ? "VIP98K_TASK_FAILED" : null,
+ errorMessage,
+ refundedPoints,
+ },
+ });
+ }
+
+ if (normalizedTaskId.startsWith("vip98k-direct:")) {
+ const [, encodedProviderId, encodedUrls] = normalizedTaskId.split(":");
+ if (!encodedProviderId || !encodedUrls) {
+ return NextResponse.json(
+ { success: false, error: "VIP98K 3D direct taskId 格式无效" },
+ { status: 400 },
+ );
+ }
+ const outputUrls = JSON.parse(
+ Buffer.from(encodedUrls, "base64url").toString("utf8"),
+ ) as string[];
+ const modelUrl =
+ outputUrls.find((url) =>
+ /\.(?:glb|gltf|fbx|obj|stl|3mf|usdz|ply|zip)(?:[?#].*)?$/i.test(url),
+ ) || outputUrls[0] || null;
+ const previewImageUrl =
+ outputUrls.find((url) => /\.(?:png|jpe?g|webp|gif)(?:[?#].*)?$/i.test(url)) ||
+ null;
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId: normalizedTaskId,
+ type: "model",
+ status: "success",
+ progress: 100,
+ output: { urls: outputUrls },
+ modelUrl,
+ previewImageUrl,
+ assetPayload: {
+ providerId: encodedProviderId,
+ providerName: "VIP98K",
+ storageArchivedAt: null,
+ },
+ errorCode: null,
+ errorMessage: null,
+ refundedPoints: 0,
+ },
+ });
+ }
+
const config = preferredProviderId
? await getTripoProviderConfig(preferredProviderId)
: await resolveTripoProviderConfigForTaskId(normalizedTaskId);
diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts
index ec3479a19..40ba8db01 100644
--- a/app/api/canvas/3d/tasks/route.ts
+++ b/app/api/canvas/3d/tasks/route.ts
@@ -14,6 +14,10 @@ import {
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
+import {
+ getVip98kRuntimeProvider,
+ submitConfiguredVip98kTask,
+} from "@/lib/ai/vip98k-runtime";
export const runtime = "nodejs";
export const maxDuration = 60;
@@ -116,6 +120,13 @@ function assignMapped(
}
}
+const VIP98K_TEXT_TO_3D_MODEL = "tencent/hunyuan3d-pro/text-to-3d";
+const VIP98K_IMAGE_TO_3D_MODEL = "tencent/hunyuan3d-pro/image-to-3d";
+
+function encodeVip98kDirectResult(outputUrls: string[]) {
+ return Buffer.from(JSON.stringify(outputUrls), "utf8").toString("base64url");
+}
+
export async function POST(request: Request) {
try {
const session = await auth();
@@ -415,6 +426,73 @@ export async function POST(request: Request) {
}
const preferredProviderId = readOptionalTrimmedText(body.providerId);
+ let vip98kProvider: Awaited
> | null = null;
+ try {
+ vip98kProvider = await getVip98kRuntimeProvider(preferredProviderId);
+ } catch {
+ vip98kProvider = null;
+ }
+
+ if (vip98kProvider) {
+ if (taskType !== "text_to_model" && taskType !== "image_to_model") {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "VIP98K 当前仅支持文生 3D 和图生 3D;细化、动画和格式转换暂不支持",
+ },
+ { status: 400 },
+ );
+ }
+
+ const inputFile = body.file && typeof body.file === "object" ? body.file : null;
+ const imageUrl = String(body.imageUrl || inputFile?.url || "").trim();
+ if (taskType === "image_to_model" && !imageUrl) {
+ return NextResponse.json(
+ { success: false, error: "VIP98K 图生 3D 缺少可访问的图片地址" },
+ { status: 400 },
+ );
+ }
+ const vipModelId =
+ taskType === "image_to_model"
+ ? VIP98K_IMAGE_TO_3D_MODEL
+ : VIP98K_TEXT_TO_3D_MODEL;
+ const submission = await submitConfiguredVip98kTask({
+ preferredProviderId: vip98kProvider.id,
+ modelId: vipModelId,
+ kind: "model-3d",
+ prompt: String(body.prompt || "Generate a production-ready 3D model"),
+ imageUrls: imageUrl ? [imageUrl] : [],
+ });
+ if (!submission.taskId && submission.outputUrls.length === 0) {
+ throw new Error("VIP98K 3D 任务未返回 taskId 或模型地址");
+ }
+ const taskId = submission.taskId
+ ? `vip98k:${vip98kProvider.id}:${submission.taskId}`
+ : `vip98k-direct:${vip98kProvider.id}:${encodeVip98kDirectResult(submission.outputUrls)}`;
+
+ if (pointsCharge.chargeable) {
+ await pointsCharge.consume({
+ note: `3D模型任务消耗积分(${billableTaskLabel} / ${taskId})`,
+ }).catch((error) => {
+ logger.error("VIP98K points consume failed", error, {
+ taskId,
+ userId: session.user.id,
+ });
+ });
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId,
+ status: submission.outputUrls.length > 0 ? "success" : "queued",
+ providerId: vip98kProvider.id,
+ providerName: vip98kProvider.name,
+ raw: submission.payload,
+ },
+ });
+ }
+
const dependencyTaskId =
taskType === "refine_model"
? String(body.draftModelTaskId || "").trim()
diff --git a/app/api/canvas/3d/upload-image/route.ts b/app/api/canvas/3d/upload-image/route.ts
index 42bdd6f68..1a65ad438 100644
--- a/app/api/canvas/3d/upload-image/route.ts
+++ b/app/api/canvas/3d/upload-image/route.ts
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getTripoProviderConfig } from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
+import { getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
+import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 60;
@@ -51,7 +53,26 @@ export async function POST(request: Request) {
);
}
- const config = await getTripoProviderConfig(providerId);
+ let config: Awaited> | null = null;
+ try {
+ config = await getTripoProviderConfig(providerId);
+ } catch {
+ const vip98k = await getVip98kRuntimeProvider(providerId);
+ const extension = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg";
+ const uploaded = await uploadFile(
+ `vip98k-3d-input-${Date.now()}-${crypto.randomUUID()}.${extension}`,
+ await file.arrayBuffer(),
+ );
+ return NextResponse.json({
+ success: true,
+ data: {
+ imageToken: null,
+ file: { type: extension, url: uploaded.url },
+ providerId: vip98k.id,
+ providerName: vip98k.name,
+ },
+ });
+ }
const uploadPaths = ["/upload/sts", "/upload"];
let lastError: unknown = null;
diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts
index 4a3375b8a..1faf23c9a 100644
--- a/app/api/canvas/detail-plan/route.ts
+++ b/app/api/canvas/detail-plan/route.ts
@@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
- ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
+ ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&
diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts
index 66c5738dc..45ddde0af 100644
--- a/app/api/canvas/detail-review/route.ts
+++ b/app/api/canvas/detail-review/route.ts
@@ -204,7 +204,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
- ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
+ ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&
diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts
index 104cf60fe..2f998684b 100644
--- a/app/api/enhance/route.ts
+++ b/app/api/enhance/route.ts
@@ -9,9 +9,11 @@ import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
+import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
// 变清晰API配置类型
type EnhanceApiConfig = {
+ provider?: "vip98k" | "aoscdn";
apiUrl?: string;
apiKey?: string;
type?: string; // "clean" | "face"
@@ -317,6 +319,102 @@ async function processWithRealESRGANBase64(
};
}
+async function processWithVip98kEnhance(params: {
+ userId: string;
+ imageBase64: string;
+ prompt: string;
+ chatId: string | null;
+ messageId: string | null;
+ sourceImageUrl: string | null;
+ scaleFactor: number;
+}) {
+ const startedAt = Date.now();
+ const result = await executeConfiguredVip98kTask({
+ modelId: "enhance",
+ kind: "image-tool",
+ prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
+ imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
+ imageParams: { scaleFactor: params.scaleFactor },
+ });
+ const outputUrl = result.outputUrls[0];
+ if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
+
+ const response = await fetch(outputUrl, {
+ cache: "no-store",
+ signal: AbortSignal.timeout(120_000),
+ });
+ if (!response.ok) {
+ throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
+ }
+ const outputBuffer = Buffer.from(await response.arrayBuffer());
+ const outputBase64 = outputBuffer.toString("base64");
+ const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
+ const [savedImage] = await db
+ .insert(generatedImage)
+ .values({
+ userId: params.userId,
+ chatId: params.chatId,
+ messageId: params.messageId,
+ thumbnailUrl: uploaded.thumbnail,
+ originalUrl: uploaded.original,
+ imageSize: `${params.scaleFactor}x放大`,
+ prompt: params.prompt,
+ modelId: "enhance",
+ })
+ .returning();
+
+ try {
+ const sourceAssets = params.sourceImageUrl
+ ? await getMediaAssetsByUserAndUrls({
+ userId: params.userId,
+ urls: [params.sourceImageUrl],
+ })
+ : [];
+ const media = await upsertMediaAsset({
+ userId: params.userId,
+ chatId: params.chatId,
+ mediaType: "image",
+ sourceType: "edited",
+ status: "ready",
+ originalUrl: uploaded.original,
+ previewUrl: uploaded.original,
+ thumbnailUrl: uploaded.thumbnail,
+ displayName: params.prompt.slice(0, 80),
+ prompt: params.prompt,
+ modelId: "enhance",
+ sourceMessageId: params.messageId,
+ sourceTable: "GeneratedImage",
+ sourceRowId: savedImage?.id || null,
+ primaryParentAssetId: sourceAssets[0]?.id ?? null,
+ metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
+ });
+ if (media) {
+ for (const sourceAsset of sourceAssets) {
+ await createMediaAssetRelation({
+ userId: params.userId,
+ chatId: params.chatId,
+ fromAssetId: sourceAsset.id,
+ toAssetId: media.id,
+ relationType: "edited_from",
+ sourceMessageId: params.messageId,
+ metadata: { prompt: params.prompt, provider: "vip98k" },
+ });
+ }
+ }
+ } catch (error) {
+ console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
+ }
+
+ return {
+ success: true,
+ image_base64: outputBase64,
+ thumbnail_url: uploaded.thumbnail,
+ original_url: uploaded.original,
+ processing_time: (Date.now() - startedAt) / 1000,
+ api_type: "vip98k",
+ };
+}
+
/**
* 轮询获取任务结果(根据官方示例)
*/
@@ -563,6 +661,12 @@ export async function GET(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
+ if (config.provider === "vip98k") {
+ return NextResponse.json(
+ { error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
+ { status: 400 }
+ );
+ }
if (config.useBackupApi) {
return NextResponse.json(
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
@@ -651,12 +755,15 @@ export async function POST(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
+ const useVip98k = config.provider === "vip98k";
// 检查是否使用备用API
- const useBackupApi = config.useBackupApi === true;
+ const useBackupApi = !useVip98k && config.useBackupApi === true;
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
- if (useBackupApi) {
+ if (useVip98k) {
+ // VIP98K 密钥只从 AiProvider 读取。
+ } else if (useBackupApi) {
// 验证备用API配置
if (!config.backupApiUrl || !config.backupApiKey) {
return NextResponse.json(
@@ -757,6 +864,31 @@ export async function POST(request: NextRequest) {
);
}
+ if (useVip98k) {
+ try {
+ const scaleFactor = requestedScaleFactor || 4;
+ const payload = await processWithVip98kEnhance({
+ userId,
+ imageBase64,
+ prompt: prompt || "图片变清晰处理",
+ chatId,
+ messageId,
+ sourceImageUrl,
+ scaleFactor,
+ });
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("[Enhance-VIP98K] Processing failed", error);
+ return NextResponse.json(
+ {
+ error: "VIP98K 变清晰处理失败",
+ detail: error instanceof Error ? error.message : "未知错误",
+ },
+ { status: 500 }
+ );
+ }
+ }
+
// ========== 备用API处理路径 (Real-ESRGAN) ==========
if (useBackupApi) {
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts
index bc722c0b7..87e2cbaad 100644
--- a/lib/ai/auto-agent-graph.ts
+++ b/lib/ai/auto-agent-graph.ts
@@ -1222,7 +1222,7 @@ type EnabledModelWithProvider = Awaited<
>[number];
function isAgentGenerateTextProviderType(providerType: string): boolean {
- return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
+ return ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(providerType);
}
function isLikelyVisionCapableModel(item: EnabledModelWithProvider) {
diff --git a/lib/ai/extend/service.ts b/lib/ai/extend/service.ts
index bfa94370f..b7025e252 100644
--- a/lib/ai/extend/service.ts
+++ b/lib/ai/extend/service.ts
@@ -4,11 +4,13 @@ import {
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
+import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
type ExtendConfig = {
+ provider?: "vip98k" | "dashscope";
apiKey?: string;
xScale?: number;
yScale?: number;
@@ -123,10 +125,28 @@ async function uploadImageToS3ForAPI(imageBase64: string): Promise {
async function extendImage(
imageUrl: string,
- apiKey: string,
+ config: ExtendConfig,
xScale: number,
yScale: number,
): Promise {
+ if (config.provider === "vip98k") {
+ const result = await executeConfiguredVip98kTask({
+ modelId: "image-expansion",
+ kind: "image-tool",
+ prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
+ imageUrls: [imageUrl],
+ imageParams: { xScale, yScale },
+ });
+ const outputUrl = result.outputUrls[0];
+ if (!outputUrl) {
+ throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
+ }
+ return outputUrl;
+ }
+
+ if (!config.apiKey) {
+ throw new ExtendServiceError("图像拓展API密钥未配置", 400);
+ }
const requestBody = {
model: "image-out-painting",
input: {
@@ -147,7 +167,7 @@ async function extendImage(
method: "POST",
headers: {
"Content-Type": "application/json",
- Authorization: `Bearer ${apiKey}`,
+ Authorization: `Bearer ${config.apiKey}`,
"X-DashScope-Async": "enable",
},
body: JSON.stringify(requestBody),
@@ -170,7 +190,7 @@ async function extendImage(
for (let attempt = 0; attempt < 60; attempt += 1) {
const statusResponse = await fetch(taskStatusUrl, {
headers: {
- Authorization: `Bearer ${apiKey}`,
+ Authorization: `Bearer ${config.apiKey}`,
},
});
const result = await statusResponse.json();
@@ -207,7 +227,7 @@ async function getValidatedExtendConfig(): Promise {
}
const config = extendConfig.value as ExtendConfig;
- if (!config.apiKey) {
+ if (config.provider !== "vip98k" && !config.apiKey) {
throw new ExtendServiceError("图像拓展API密钥未配置", 400);
}
return config;
@@ -243,7 +263,7 @@ export async function executeExtendJob(
}
const startTime = Date.now();
- const extendedImageUrl = await extendImage(inputImageUrl, config.apiKey!, xScale, yScale);
+ const extendedImageUrl = await extendImage(inputImageUrl, config, xScale, yScale);
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new ExtendServiceError(
@@ -277,7 +297,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original,
prompt,
- modelId: "extend-1.0",
+ modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
imageSize: "2K",
})
.returning();
@@ -301,7 +321,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80),
prompt,
- modelId: "extend-1.0",
+ modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: generatedImageId,
diff --git a/lib/ai/prompt-optimizer.ts b/lib/ai/prompt-optimizer.ts
index 10b01aca0..52668afb3 100644
--- a/lib/ai/prompt-optimizer.ts
+++ b/lib/ai/prompt-optimizer.ts
@@ -611,7 +611,7 @@ export async function optimizePromptWithLLM(params: {
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const isReasoningCompatibleProvider = (providerType: string) =>
- ["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
+ ["zhipu", "alibaba", "openai", "custom", "local", "vip98k", "volcano-multimodal", "volcano-vision"].includes(providerType);
const normalizedPreferredModelId = String(preferredModelId || "").trim();
const preferredOptimizerModel = normalizedPreferredModelId
? allModels.find(
diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts
index 56e4ce2b9..674dbc577 100644
--- a/lib/ai/providers/vip98k.ts
+++ b/lib/ai/providers/vip98k.ts
@@ -85,6 +85,9 @@ function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) {
getStringField(metadata, "modelType");
if (configuredKind.includes("world")) return "world";
if (configuredKind.includes("video")) return "video";
+ if (configuredKind.includes("model-3d") || configuredKind === "3d") {
+ return "model-3d";
+ }
if (configuredKind.includes("image-tool")) return "image-tool";
if (configuredKind.includes("image")) return "image";
if (configuredKind.includes("audio")) return "audio";
@@ -393,6 +396,100 @@ async function submitTask(params: {
};
}
+export type Vip98kExternalTaskSubmission = {
+ taskId: string;
+ resultPath: string;
+ outputUrls: string[];
+ payload: unknown;
+};
+
+export type Vip98kExternalTaskStatus = {
+ taskId: string;
+ status: string;
+ done: boolean;
+ failed: boolean;
+ outputUrls: string[];
+ payload: unknown;
+};
+
+export async function submitVip98kExternalTask(params: {
+ apiKey: string;
+ baseURL?: string | null;
+ modelId: string;
+ kind: string;
+ prompt?: string;
+ imageUrls?: string[];
+ videoUrls?: string[];
+ videoParams?: Record;
+ imageParams?: Record;
+ modelConfig?: Vip98kModelRuntimeConfig;
+}): Promise {
+ const [imageUrls, videoUrls] = await Promise.all([
+ Promise.all((params.imageUrls || []).map(ensurePublicMediaUrl)),
+ Promise.all((params.videoUrls || []).map(ensurePublicMediaUrl)),
+ ]);
+ const taskParams: Vip98kTaskParams = {
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ modelId: params.modelId,
+ kind: params.kind,
+ prompt: String(params.prompt || "Process media").trim() || "Process media",
+ imageUrls,
+ videoUrls,
+ videoParams: params.videoParams,
+ imageParams: params.imageParams,
+ modelConfig: params.modelConfig || {},
+ };
+ const submitted = await submitTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ body: buildTaskBody(taskParams),
+ });
+ return {
+ taskId: submitted.taskId,
+ resultPath: submitted.resultPath,
+ outputUrls: submitted.outputs,
+ payload: submitted.payload,
+ };
+}
+
+export async function getVip98kExternalTaskStatus(params: {
+ apiKey: string;
+ baseURL?: string | null;
+ taskId: string;
+ resultPath?: string | null;
+}): Promise {
+ const baseURL = normalizeVip98kBaseUrl(params.baseURL);
+ 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 outputUrls = collectOutputUrls(payload);
+ const failed = ["failed", "error", "timeout", "canceled", "cancelled"].includes(status);
+ const done =
+ ["succeeded", "success", "completed", "done", "finished"].includes(status) ||
+ (extractDone(payload) && outputUrls.length > 0);
+ return {
+ taskId: params.taskId,
+ status,
+ done,
+ failed,
+ outputUrls,
+ payload,
+ };
+}
+
async function waitForTask(params: {
apiKey: string;
baseURL?: string | null;
@@ -545,23 +642,67 @@ function formatWorldMarkdown(urls: string[]) {
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,
+export type Vip98kExternalTaskResult = {
+ taskId: string;
+ outputUrls: string[];
+ payload: unknown;
+};
+
+export async function executeVip98kExternalTask(params: {
+ apiKey: string;
+ baseURL?: string | null;
+ modelId: string;
+ kind: string;
+ prompt?: string;
+ imageUrls?: string[];
+ videoUrls?: string[];
+ videoParams?: Record;
+ imageParams?: Record;
+ modelConfig?: Vip98kModelRuntimeConfig;
+}): Promise {
+ const submitted = await submitVip98kExternalTask({
+ ...params,
});
- 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 (submitted.outputUrls.length > 0) {
+ return {
+ taskId: submitted.taskId,
+ outputUrls: submitted.outputUrls,
+ payload: submitted.payload,
+ };
+ }
+ if (!submitted.taskId) {
+ throw new Error("VIP98K task submission returned neither a task ID nor output URLs");
+ }
+
+ const completed = await waitForTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ taskId: submitted.taskId,
+ resultPath: submitted.resultPath,
+ modelConfig: params.modelConfig || {},
+ });
+ return {
+ taskId: submitted.taskId,
+ outputUrls: completed.outputs,
+ payload: completed.payload,
+ };
+}
+
+async function runTask(params: Vip98kTaskParams) {
+ const completed = await executeVip98kExternalTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ modelId: params.modelId,
+ kind: params.kind,
+ prompt: params.prompt,
+ imageUrls: params.imageUrls,
+ videoUrls: params.videoUrls,
+ videoParams: params.videoParams,
+ imageParams: params.imageParams,
+ modelConfig: params.modelConfig,
+ });
+ const outputUrls = completed.outputUrls;
if (params.kind === "video") {
const videoUrl =
@@ -580,7 +721,7 @@ async function runTask(params: Vip98kTaskParams) {
return formatImageMarkdown(archived, imageSize);
}
- if (params.kind === "world") {
+ if (params.kind === "world" || params.kind === "model-3d") {
return formatWorldMarkdown(outputUrls);
}
diff --git a/lib/ai/rmbg/service.ts b/lib/ai/rmbg/service.ts
index 1776e8a49..80d4a3763 100644
--- a/lib/ai/rmbg/service.ts
+++ b/lib/ai/rmbg/service.ts
@@ -4,6 +4,7 @@ import {
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
+import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
@@ -11,7 +12,7 @@ import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("rmbg-service");
-export type RmbgProvider = "rmbg" | "photoroom" | "aoscdn";
+export type RmbgProvider = "vip98k" | "rmbg" | "photoroom" | "aoscdn";
type RmbgConfig = {
provider?: RmbgProvider;
@@ -269,6 +270,44 @@ async function processWithRMBG(
}
}
+async function processWithVip98k(
+ imageBuffer: Buffer,
+): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
+ const startedAt = Date.now();
+ try {
+ const result = await executeConfiguredVip98kTask({
+ modelId: "remove-background",
+ kind: "image-tool",
+ prompt: "Remove the image background and return a transparent PNG.",
+ imageUrls: [`data:image/png;base64,${imageBuffer.toString("base64")}`],
+ });
+ const outputUrl = result.outputUrls[0];
+ if (!outputUrl) {
+ return { success: false, error: "VIP98K 抠图未返回图片" };
+ }
+ const response = await fetch(outputUrl, {
+ cache: "no-store",
+ signal: AbortSignal.timeout(120_000),
+ });
+ if (!response.ok) {
+ return {
+ success: false,
+ error: `VIP98K 抠图结果下载失败(HTTP ${response.status})`,
+ };
+ }
+ return {
+ success: true,
+ imageBuffer: Buffer.from(await response.arrayBuffer()),
+ processing_time: (Date.now() - startedAt) / 1000,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "VIP98K 抠图失败",
+ };
+ }
+}
+
async function processWithPhotoRoom(
imageBuffer: Buffer,
apiKey: string
@@ -493,7 +532,9 @@ async function getValidatedRmbgConfig(): Promise<{ provider: RmbgProvider; confi
}
function getModelId(provider: RmbgProvider) {
- return provider === "photoroom"
+ return provider === "vip98k"
+ ? "remove-background"
+ : provider === "photoroom"
? "photoroom-hd"
: provider === "aoscdn"
? "aos-segmentation"
@@ -585,7 +626,19 @@ export async function executeRmbgJob(
let resultBase64: string;
let processingTime: number;
- if (provider === "photoroom") {
+ if (provider === "vip98k") {
+ const result = await processWithVip98k(imageBuffer);
+ if (!result.success || !result.imageBuffer) {
+ logger.warn("job_provider_failed", {
+ provider,
+ reasonLength: result.error?.length ?? 0,
+ });
+ throw new RmbgServiceError("抠图处理失败", 500, result.error);
+ }
+ resultBuffer = result.imageBuffer;
+ resultBase64 = resultBuffer.toString("base64");
+ processingTime = result.processing_time || 0;
+ } else if (provider === "photoroom") {
const result = await processWithPhotoRoom(imageBuffer, config.photoroomApiKey!);
if (!result.success || !result.imageBuffer) {
logger.warn("job_provider_failed", {
diff --git a/lib/ai/smart-analyzer.ts b/lib/ai/smart-analyzer.ts
index c22451481..c69e0c949 100644
--- a/lib/ai/smart-analyzer.ts
+++ b/lib/ai/smart-analyzer.ts
@@ -1717,7 +1717,7 @@ type VisionSummaryModel = Awaited<
>[number];
function isAnalyzerGenerateTextProviderType(providerType: string): boolean {
- return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
+ return ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(providerType);
}
function getAnalyzerModelIncompatibleReason(params: {
diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts
index 085d543b0..c4da31d78 100644
--- a/lib/ai/vip98k-models.ts
+++ b/lib/ai/vip98k-models.ts
@@ -86,12 +86,17 @@ const TYPE_SORT_ORDER: Record = {
image: 100,
video: 200,
world: 300,
+ model_3d: 320,
model: 400,
image_tool: 800,
audio: 900,
document_tool: 950,
};
+const IMAGE_TOOL_MODEL_IDS = new Set([
+ "image-upscaler",
+]);
+
function toRecord(value: unknown): Record | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record;
@@ -132,6 +137,25 @@ function normalizeCatalogType(model: Vip98kCatalogModel): Vip98kCatalogModelType
.toLowerCase();
}
+function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
+ const catalogType = normalizeCatalogType(model);
+ const modelId = getModelId(model).toLowerCase();
+ const capabilities = getCapabilities(model);
+
+ if (
+ capabilities.includes("image-to-3d") ||
+ capabilities.includes("text-to-3d")
+ ) {
+ return "model_3d";
+ }
+
+ if (catalogType === "image" && IMAGE_TOOL_MODEL_IDS.has(modelId)) {
+ return "image_tool";
+ }
+
+ return catalogType;
+}
+
function normalizeImageSize(value: string): string | null {
const normalized = value.trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
@@ -447,18 +471,71 @@ function createWorldPreset(
};
}
+function createThreeDPreset(
+ model: Vip98kCatalogModel,
+ index: number,
+): Vip98kModelPreset {
+ const modelId = getModelId(model);
+ const displayName = getDisplayName(model, modelId);
+ const catalogCapabilities = getCapabilities(model);
+ const supportsImageInput = catalogCapabilities.includes("image-to-3d");
+ const supportedModes = [
+ ...(catalogCapabilities.includes("text-to-3d") ? ["text-to-3d"] : []),
+ ...(supportsImageInput ? ["image-to-3d"] : []),
+ ];
+
+ return {
+ modelId,
+ displayName,
+ description: model.description || `VIP98K 3D model (${modelId})`,
+ type: "model_3d",
+ supportsVision: supportsImageInput,
+ supportsImageGen: false,
+ isHidden: false,
+ capabilities: {
+ provider: "vip98k",
+ kind: "model-3d",
+ mediaType: "model-3d",
+ modelType: "model-3d",
+ outputType: "3d",
+ supportedModes,
+ maxReferenceImages: supportsImageInput ? 1 : 0,
+ vip98k: getVip98kMetadata(model, "model_3d"),
+ },
+ defaultParams: {
+ provider: "vip98k",
+ kind: "model-3d",
+ endpoint: modelId,
+ taskApi: "external-task",
+ supportedModes,
+ },
+ imageGenConfig: null,
+ sortOrder: TYPE_SORT_ORDER.model_3d + index,
+ };
+}
+
function createChatPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
+ const catalogCapabilities = getCapabilities(model);
+ const inputModes = getInputModes(model);
+ const supportsVision =
+ catalogCapabilities.some((capability) =>
+ ["vision", "multimodal", "image-input", "image-understanding"].includes(
+ capability,
+ ),
+ ) ||
+ inputModes.some((mode) => ["image", "url", "file"].includes(mode)) ||
+ /^(?:gpt-|claude-|qwen)/i.test(modelId);
return {
modelId,
displayName,
description: model.description || `VIP98K text model (${modelId})`,
type: "model",
- supportsVision: false,
+ supportsVision,
supportsImageGen: false,
isHidden: false,
capabilities: {
@@ -468,6 +545,8 @@ function createChatPreset(
modelType: "model",
outputType: "text",
apiFormat: "openai-compatible",
+ inputModes,
+ supportedModes: supportsVision ? ["text", "vision"] : ["text"],
vip98k: getVip98kMetadata(model, "model"),
},
defaultParams: {
@@ -529,10 +608,11 @@ export function buildVip98kModelPresets(
.map((model, index) => {
const modelId = getModelId(model);
if (!modelId) return null;
- const type = normalizeCatalogType(model);
+ const type = resolvePresetType(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_3d") return createThreeDPreset(model, index);
if (type === "model") return createChatPreset(model, index);
return createToolPreset(model, type, index);
})
diff --git a/lib/ai/vip98k-runtime.ts b/lib/ai/vip98k-runtime.ts
new file mode 100644
index 000000000..47ccea513
--- /dev/null
+++ b/lib/ai/vip98k-runtime.ts
@@ -0,0 +1,165 @@
+import "server-only";
+
+import { and, eq } from "drizzle-orm";
+import {
+ executeVip98kExternalTask,
+ getVip98kExternalTaskStatus,
+ submitVip98kExternalTask,
+} from "@/lib/ai/providers/vip98k";
+import { db } from "@/lib/db";
+import { getAiProvidersByType } from "@/lib/db/queries";
+import { aiModel } from "@/lib/db/schema";
+
+export type Vip98kRuntimeProvider = {
+ id: string;
+ name: string;
+ apiKey: string;
+ baseUrl: string;
+};
+
+let providerCursor = 0;
+
+export async function getVip98kRuntimeProviders(): Promise {
+ const providers = await getAiProvidersByType({
+ type: "vip98k",
+ enabledOnly: true,
+ });
+ return providers
+ .map((provider) => ({
+ id: provider.id,
+ name: provider.name,
+ apiKey: String(provider.apiKey || "").trim(),
+ baseUrl: String(provider.baseUrl || "").trim(),
+ }))
+ .filter((provider) => Boolean(provider.apiKey && provider.baseUrl));
+}
+
+export async function getVip98kRuntimeProvider(
+ preferredProviderId?: string | null,
+): Promise {
+ const providers = await getVip98kRuntimeProviders();
+ const preferredId = String(preferredProviderId || "").trim();
+ if (preferredId) {
+ const matched = providers.find((provider) => provider.id === preferredId);
+ if (matched) return matched;
+ }
+ if (providers.length === 0) {
+ throw new Error("VIP98K 服务商未配置或未启用");
+ }
+ const selected = providers[providerCursor % providers.length]!;
+ providerCursor = (providerCursor + 1) % providers.length;
+ return selected;
+}
+
+export async function executeConfiguredVip98kTask(params: {
+ modelId: string;
+ kind: string;
+ prompt?: string;
+ imageUrls?: string[];
+ videoUrls?: string[];
+ imageParams?: Record;
+ videoParams?: Record;
+ preferredProviderId?: string | null;
+}) {
+ const provider = await getVip98kRuntimeProvider(params.preferredProviderId);
+ const model = await db.query.aiModel.findFirst({
+ where: and(
+ eq(aiModel.providerId, provider.id),
+ eq(aiModel.modelId, params.modelId),
+ eq(aiModel.isEnabled, true),
+ ),
+ });
+ if (!model) {
+ throw new Error(`VIP98K 模型未同步或未启用:${params.modelId}`);
+ }
+
+ const result = await executeVip98kExternalTask({
+ apiKey: provider.apiKey,
+ baseURL: provider.baseUrl,
+ modelId: params.modelId,
+ kind: params.kind,
+ prompt: params.prompt,
+ imageUrls: params.imageUrls,
+ videoUrls: params.videoUrls,
+ imageParams: params.imageParams,
+ videoParams: params.videoParams,
+ modelConfig: {
+ capabilities:
+ model.capabilities && typeof model.capabilities === "object"
+ ? (model.capabilities as Record)
+ : null,
+ imageGenConfig:
+ model.imageGenConfig && typeof model.imageGenConfig === "object"
+ ? (model.imageGenConfig as Record)
+ : null,
+ },
+ });
+
+ return {
+ ...result,
+ providerId: provider.id,
+ providerName: provider.name,
+ };
+}
+
+export async function submitConfiguredVip98kTask(params: {
+ modelId: string;
+ kind: string;
+ prompt?: string;
+ imageUrls?: string[];
+ videoUrls?: string[];
+ imageParams?: Record;
+ videoParams?: Record;
+ preferredProviderId?: string | null;
+}) {
+ const provider = await getVip98kRuntimeProvider(params.preferredProviderId);
+ const model = await db.query.aiModel.findFirst({
+ where: and(
+ eq(aiModel.providerId, provider.id),
+ eq(aiModel.modelId, params.modelId),
+ eq(aiModel.isEnabled, true),
+ ),
+ });
+ if (!model) {
+ throw new Error(`VIP98K 模型未同步或未启用:${params.modelId}`);
+ }
+
+ const result = await submitVip98kExternalTask({
+ apiKey: provider.apiKey,
+ baseURL: provider.baseUrl,
+ modelId: params.modelId,
+ kind: params.kind,
+ prompt: params.prompt,
+ imageUrls: params.imageUrls,
+ videoUrls: params.videoUrls,
+ imageParams: params.imageParams,
+ videoParams: params.videoParams,
+ modelConfig: {
+ capabilities:
+ model.capabilities && typeof model.capabilities === "object"
+ ? (model.capabilities as Record)
+ : null,
+ imageGenConfig:
+ model.imageGenConfig && typeof model.imageGenConfig === "object"
+ ? (model.imageGenConfig as Record)
+ : null,
+ },
+ });
+
+ return { ...result, providerId: provider.id, providerName: provider.name };
+}
+
+export async function getConfiguredVip98kTaskStatus(params: {
+ taskId: string;
+ preferredProviderId?: string | null;
+ resultPath?: string | null;
+}) {
+ const provider = await getVip98kRuntimeProvider(params.preferredProviderId);
+ const result = await getVip98kExternalTaskStatus({
+ apiKey: provider.apiKey,
+ baseURL: provider.baseUrl,
+ taskId: params.taskId,
+ resultPath: params.resultPath,
+ });
+ return { ...result, providerId: provider.id, providerName: provider.name };
+}
diff --git a/lib/video-agent/storyboard-command-interpreter.ts b/lib/video-agent/storyboard-command-interpreter.ts
index e8e0e0153..9eb466904 100644
--- a/lib/video-agent/storyboard-command-interpreter.ts
+++ b/lib/video-agent/storyboard-command-interpreter.ts
@@ -182,7 +182,7 @@ type EnabledModelWithProvider = Awaited<
>[number];
function isAgentGenerateTextProviderType(providerType: string): boolean {
- return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
+ return ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(providerType);
}
function isAgentGenerateTextCompatibleModel(item: EnabledModelWithProvider) {
diff --git a/lib/worldlabs/server.ts b/lib/worldlabs/server.ts
index 4baf32d52..61b7e6468 100644
--- a/lib/worldlabs/server.ts
+++ b/lib/worldlabs/server.ts
@@ -42,10 +42,17 @@ function providerToWorldLabsConfig(
export async function getWorldLabsProviderConfigs(): Promise<
WorldLabsProviderConfig[]
> {
- const providers = await getAiProvidersByType({
+ const legacyProviders = await getAiProvidersByType({
type: "worldlabs-3d",
enabledOnly: true,
});
+ const providers =
+ legacyProviders.length > 0
+ ? legacyProviders
+ : await getAiProvidersByType({
+ type: "vip98k",
+ enabledOnly: true,
+ });
const normalizedProviders = providers
.map((provider) => providerToWorldLabsConfig(provider))
@@ -105,7 +112,7 @@ export async function getWorldLabsProviderConfig(
return worldLabsProvider;
}
- throw new Error("未配置 World Labs API,请在系统设置-本地AI新增 World Labs 提供商");
+ throw new Error("未配置 3D 世界服务,请启用 VIP98K 服务商并同步 world-generation 模型");
}
export async function reportWorldLabsProviderRuntimeFailure(
diff --git a/package.json b/package.json
index 5ef2de287..3a0e9a64d 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,9 @@
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
"storage:configure-upload-lifecycle": "pnpm exec tsx scripts/storage/configure-multipart-lifecycle.ts",
- "verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts"
+ "verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts",
+ "providers:consolidate": "pnpm exec tsx scripts/consolidate-ai-providers.ts",
+ "providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts"
},
"dependencies": {
"@ai-sdk/gateway": "^1.0.15",
diff --git a/scripts/consolidate-ai-providers.ts b/scripts/consolidate-ai-providers.ts
new file mode 100644
index 000000000..781a3236c
--- /dev/null
+++ b/scripts/consolidate-ai-providers.ts
@@ -0,0 +1,443 @@
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import dotenv from "dotenv";
+import postgres from "postgres";
+import {
+ buildVip98kModelPresets,
+ fetchVip98kModelPresets,
+ type Vip98kCatalogModel,
+ type Vip98kModelPreset,
+} from "../lib/ai/vip98k-models";
+
+dotenv.config({ path: ".env.local" });
+dotenv.config();
+
+const KEEP_PROVIDER_TYPES = new Set(["vip98k", "fal-image", "fal-video"]);
+const REQUIRED_CAPABILITY_MODELS = [
+ "nano-banana-2",
+ "remove-background",
+ "enhance",
+ "image-expansion",
+ "world-generation",
+ "tencent/hunyuan3d-pro/text-to-3d",
+ "tencent/hunyuan3d-pro/image-to-3d",
+] as const;
+
+type ProviderRow = {
+ id: string;
+ name: string;
+ type: string;
+ baseUrl: string;
+ apiKey: string;
+ isEnabled: boolean;
+};
+
+type ModelRow = {
+ id: string;
+ providerId: string;
+ modelId: string;
+ displayName: string;
+ supportsVision: boolean;
+ supportsImageGen: boolean;
+ capabilities: unknown;
+ imageGenConfig: unknown;
+};
+
+function hasFlag(name: string) {
+ return process.argv.includes(name);
+}
+
+function getArgValue(name: string) {
+ const prefix = `${name}=`;
+ return process.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
+}
+
+function stableHash(value: unknown) {
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
+}
+
+function sortById(rows: T[]) {
+ return [...rows].sort((left, right) => left.id.localeCompare(right.id));
+}
+
+function isExplicitlyUnavailable(model: Vip98kCatalogModel) {
+ return ["disabled", "unavailable", "offline", "removed"].includes(
+ String(model.status || "").trim().toLowerCase(),
+ );
+}
+
+async function loadPresets(apiKey: string): Promise {
+ const catalogPath = getArgValue("--catalog") || process.env.VIP98K_CATALOG_FILE || "";
+ if (catalogPath) {
+ const payload = JSON.parse(await readFile(catalogPath, "utf8")) as {
+ data?: { models?: Vip98kCatalogModel[] } | Vip98kCatalogModel[];
+ models?: Vip98kCatalogModel[];
+ };
+ const dataModels = Array.isArray(payload.data) ? payload.data : payload.data?.models;
+ const models = (dataModels || payload.models || []).filter(
+ (model) => !isExplicitlyUnavailable(model),
+ );
+ if (models.length === 0) throw new Error("VIP98K catalog file contains no models");
+ return buildVip98kModelPresets(models);
+ }
+ return fetchVip98kModelPresets({
+ baseURL: process.env.VIP98K_BASE_URL || "https://api.vip98k.com",
+ apiKey,
+ });
+}
+
+function classifyOldModel(model: ModelRow, providerType: string) {
+ const serialized = JSON.stringify({
+ capabilities: model.capabilities,
+ imageGenConfig: model.imageGenConfig,
+ }).toLowerCase();
+ if (providerType.includes("video") || serialized.includes("video")) return "video";
+ if (
+ providerType.includes("3d") ||
+ serialized.includes("model-3d") ||
+ serialized.includes("text-to-3d") ||
+ serialized.includes("image-to-3d")
+ ) {
+ return "model-3d";
+ }
+ if (providerType.includes("world") || serialized.includes("world-generation")) return "world";
+ if (model.supportsImageGen || providerType.includes("image")) return "image";
+ return "chat";
+}
+
+function chooseTargetModelId(kind: string, presets: Vip98kModelPreset[]) {
+ const preferred: Record = {
+ chat: ["gpt-5.4-mini", "gpt-5.4", "claude-sonnet-4-6"],
+ image: ["nano-banana-2", "nano-banana-pro", "gpt-image-2-vip"],
+ video: ["google/veo-3.1-fast", "google/veo-3.1", "veo-3.1-fast"],
+ world: ["world-generation"],
+ "model-3d": [
+ "tencent/hunyuan3d-pro/image-to-3d",
+ "tencent/hunyuan3d-pro/text-to-3d",
+ ],
+ };
+ const candidates = preferred[kind] || preferred.chat;
+ const selected = candidates.find((modelId) => presets.some((preset) => preset.modelId === modelId));
+ if (selected) return selected;
+ const fallbackType = kind === "model-3d" ? "model_3d" : kind === "chat" ? "model" : kind;
+ return presets.find((preset) => preset.type === fallbackType)?.modelId || "";
+}
+
+function rewriteSettingValue(params: {
+ value: unknown;
+ oldProviders: Map;
+ oldModels: Map;
+ targetProviderId: string;
+ targetModelByKind: Record;
+ key?: string;
+}): unknown {
+ const { value, oldProviders, oldModels, targetProviderId, targetModelByKind } = params;
+ if (Array.isArray(value)) {
+ return value.map((item) => rewriteSettingValue({ ...params, value: item }));
+ }
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, nested]) => [
+ key,
+ rewriteSettingValue({ ...params, value: nested, key }),
+ ]),
+ );
+ }
+ if (typeof value !== "string") return value;
+
+ const oldProvider = oldProviders.get(value);
+ if (oldProvider) {
+ if (/type/i.test(params.key || "")) return "vip98k";
+ if (/name/i.test(params.key || "")) return "VIP98K";
+ return targetProviderId;
+ }
+ const oldModel = oldModels.get(value);
+ if (oldModel) {
+ const provider = oldProviders.get(oldModel.providerId);
+ const kind = classifyOldModel(oldModel, provider?.type || "");
+ // A value found in oldModels is a database UUID, regardless of the JSON key name.
+ return targetModelByKind[kind]?.id || targetModelByKind.chat.id;
+ }
+ if (/providerType/i.test(params.key || "")) {
+ const oldTypes = new Set(Array.from(oldProviders.values()).map((provider) => provider.type));
+ if (oldTypes.has(value)) return "vip98k";
+ }
+ for (const [kind, target] of Object.entries(targetModelByKind)) {
+ const oldRuntimeModel = Array.from(oldModels.values()).find((model) => {
+ const provider = oldProviders.get(model.providerId);
+ return (
+ model.modelId === value && classifyOldModel(model, provider?.type || "") === kind
+ );
+ });
+ if (oldRuntimeModel) return target.modelId;
+ }
+ return value;
+}
+
+function summarizePresets(presets: Vip98kModelPreset[]) {
+ return presets.reduce>((summary, preset) => {
+ const type = String(preset.type || "model");
+ summary[type] = (summary[type] || 0) + 1;
+ return summary;
+ }, {});
+}
+
+async function main() {
+ const apply = hasFlag("--apply");
+ const stage = hasFlag("--stage");
+ if (apply && stage) throw new Error("Use either --stage or --apply, not both");
+ const databaseUrl = process.env.POSTGRES_URL || "";
+ const apiKey = process.env.VIP98K_API_KEY || "";
+ if (!databaseUrl) throw new Error("POSTGRES_URL is required");
+ if (!apiKey) throw new Error("VIP98K_API_KEY is required and must not be passed on the command line");
+
+ const presets = await loadPresets(apiKey);
+ const modelIds = new Set(presets.map((preset) => preset.modelId));
+ const missingRequired = REQUIRED_CAPABILITY_MODELS.filter((modelId) => !modelIds.has(modelId));
+ const hasVideo = presets.some((preset) => preset.type === "video");
+ const hasChat = presets.some((preset) => preset.type === "model");
+ if (missingRequired.length > 0 || !hasVideo || !hasChat) {
+ throw new Error(
+ `VIP98K capability coverage incomplete: ${[
+ ...missingRequired,
+ ...(!hasVideo ? ["video"] : []),
+ ...(!hasChat ? ["multimodal/chat"] : []),
+ ].join(", ")}`,
+ );
+ }
+
+ const sql = postgres(databaseUrl, { prepare: false, max: 1 });
+ try {
+ const providers = await sql`select * from "AiProvider" order by "createdAt"`;
+ const models = await sql`select * from "AiModel" order by "createdAt"`;
+ const falProviders = providers.filter((provider) =>
+ ["fal-image", "fal-video"].includes(provider.type),
+ );
+ const falProviderIds = new Set(falProviders.map((provider) => provider.id));
+ const falFingerprintBefore = stableHash({
+ providers: sortById(falProviders),
+ models: sortById(models.filter((model) => falProviderIds.has(model.providerId))),
+ });
+ const removableProviders = providers.filter(
+ (provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ mode: apply ? "apply" : stage ? "stage" : "dry-run",
+ vip98kModels: presets.length,
+ vip98kByType: summarizePresets(presets),
+ falProvidersProtected: falProviders.length,
+ falModelsProtected: models.filter((model) => falProviderIds.has(model.providerId)).length,
+ providersToRemove: removableProviders.length,
+ modelsToRemove: models.filter((model) =>
+ removableProviders.some((provider) => provider.id === model.providerId),
+ ).length,
+ },
+ null,
+ 2,
+ ),
+ );
+
+ if (!apply && !stage) {
+ console.log(
+ "Dry run complete. Re-run with --stage to add VIP98K without deleting existing providers.",
+ );
+ return;
+ }
+
+ await sql.begin(async (tx) => {
+ const existingVipProviders = providers.filter((provider) => provider.type === "vip98k");
+ let targetProviderId = existingVipProviders[0]?.id || "";
+ if (targetProviderId) {
+ await tx`
+ update "AiProvider"
+ set name = 'VIP98K', "baseUrl" = 'https://api.vip98k.com', "apiKey" = ${apiKey},
+ "isEnabled" = true, "updatedAt" = now()
+ where id = ${targetProviderId}::uuid
+ `;
+ } else {
+ const [created] = await tx<{ id: string }[]>`
+ insert into "AiProvider" (name, type, "baseUrl", "apiKey", "isEnabled")
+ values ('VIP98K', 'vip98k', 'https://api.vip98k.com', ${apiKey}, true)
+ returning id
+ `;
+ targetProviderId = created?.id || "";
+ }
+ if (!targetProviderId) throw new Error("Failed to create VIP98K provider");
+
+ const modelDbIds = new Map();
+ for (const preset of presets) {
+ const existing = await tx<{ id: string }[]>`
+ select id from "AiModel"
+ where "providerId" = ${targetProviderId}::uuid and "modelId" = ${preset.modelId}
+ order by "createdAt" limit 1
+ `;
+ const values = {
+ description: preset.description,
+ capabilities: JSON.stringify(preset.capabilities),
+ defaultParams: JSON.stringify(preset.defaultParams),
+ imageGenConfig: preset.imageGenConfig ? JSON.stringify(preset.imageGenConfig) : null,
+ };
+ if (existing[0]) {
+ await tx`
+ update "AiModel" set
+ "displayName" = ${preset.displayName}, description = ${values.description},
+ "supportsVision" = ${preset.supportsVision}, "supportsImageGen" = ${preset.supportsImageGen},
+ "isEnabled" = true, "isHidden" = ${preset.isHidden}, "sortOrder" = ${preset.sortOrder},
+ capabilities = ${values.capabilities}::jsonb, "defaultParams" = ${values.defaultParams}::jsonb,
+ "imageGenConfig" = ${values.imageGenConfig}::jsonb, "updatedAt" = now()
+ where id = ${existing[0].id}::uuid
+ `;
+ modelDbIds.set(preset.modelId, existing[0].id);
+ } else {
+ const [created] = await tx<{ id: string }[]>`
+ insert into "AiModel" (
+ "providerId", "modelId", "displayName", description, "supportsVision",
+ "supportsImageGen", "isEnabled", "isHidden", "sortOrder", capabilities,
+ "defaultParams", "imageGenConfig", "isDefault", "isDefaultTextEditModel"
+ ) values (
+ ${targetProviderId}::uuid, ${preset.modelId}, ${preset.displayName}, ${values.description},
+ ${preset.supportsVision}, ${preset.supportsImageGen}, true, ${preset.isHidden},
+ ${preset.sortOrder}, ${values.capabilities}::jsonb, ${values.defaultParams}::jsonb,
+ ${values.imageGenConfig}::jsonb, false, false
+ ) returning id
+ `;
+ if (!created) throw new Error(`Failed to create VIP98K model ${preset.modelId}`);
+ modelDbIds.set(preset.modelId, created.id);
+ }
+ }
+
+ const targetModelByKind = Object.fromEntries(
+ ["chat", "image", "video", "world", "model-3d"].map((kind) => {
+ const modelId = chooseTargetModelId(kind, presets);
+ const id = modelDbIds.get(modelId) || "";
+ if (!id) throw new Error(`No VIP98K target model for ${kind}`);
+ return [kind, { id, modelId }];
+ }),
+ ) as Record;
+
+ if (stage) return;
+
+ await tx`
+ update "AiModel" set "isDefault" = false, "isDefaultTextEditModel" = false
+ where "providerId" = ${targetProviderId}::uuid
+ `;
+ await tx`
+ update "AiModel" set "isDefault" = true
+ where id = ${targetModelByKind.chat.id}::uuid
+ `;
+ await tx`
+ update "AiModel" set "isDefaultTextEditModel" = true,
+ "fallbackModelId1" = 'nano-banana-pro', "fallbackModelId2" = 'gpt-image-2-vip'
+ where id = ${targetModelByKind.image.id}::uuid
+ `;
+
+ const duplicateVipProviders = existingVipProviders.slice(1);
+ const replaceableProviders = [...removableProviders, ...duplicateVipProviders];
+ const oldProviderMap = new Map(
+ replaceableProviders.map((provider) => [provider.id, provider]),
+ );
+ const oldModels = models.filter((model) => oldProviderMap.has(model.providerId));
+ const oldModelMap = new Map(oldModels.map((model) => [model.id, model]));
+
+ if (oldModelMap.size > 0) {
+ for (const presetType of ["image", "video", "text"] as const) {
+ const target = presetType === "video"
+ ? targetModelByKind.video
+ : presetType === "image"
+ ? targetModelByKind.image
+ : targetModelByKind.chat;
+ await tx`
+ update "AgentPreset" set "defaultModelId" = ${target.id}::uuid, "updatedAt" = now()
+ where type = ${presetType}
+ and "defaultModelId" = any(${Array.from(oldModelMap.keys())}::uuid[])
+ `;
+ }
+ }
+
+ const settings = await tx<{ id: string; key: string; value: unknown }[]>`
+ select id, key, value from "SystemSettings"
+ `;
+ for (const setting of settings) {
+ let nextValue = rewriteSettingValue({
+ value: setting.value,
+ oldProviders: oldProviderMap,
+ oldModels: oldModelMap,
+ targetProviderId,
+ targetModelByKind,
+ });
+ if (setting.key === "rmbg_api_config") nextValue = { provider: "vip98k" };
+ if (setting.key === "enhance_api_config") {
+ nextValue = { provider: "vip98k", type: "clean", scaleFactor: 4 };
+ }
+ if (setting.key === "extend_api_config") {
+ nextValue = { provider: "vip98k", xScale: 1.5, yScale: 1.5 };
+ }
+ if (setting.key === "worldlabs_key_pool_config") {
+ nextValue = { baseUrl: "https://api.vip98k.com", providers: [] };
+ }
+ if (JSON.stringify(nextValue) !== JSON.stringify(setting.value)) {
+ await tx`
+ update "SystemSettings" set value = ${JSON.stringify(nextValue)}::jsonb, "updatedAt" = now()
+ where id = ${setting.id}::uuid
+ `;
+ }
+ }
+
+ const removableProviderIds = removableProviders.map((provider) => provider.id);
+ const duplicateVipProviderIds = duplicateVipProviders.map((provider) => provider.id);
+ const deleteIds = [...removableProviderIds, ...duplicateVipProviderIds];
+ if (deleteIds.length > 0) {
+ await tx`delete from "AiProvider" where id = any(${deleteIds}::uuid[])`;
+ }
+ });
+
+ const remainingProviders = await sql`select * from "AiProvider" order by "createdAt"`;
+ const remainingModels = await sql`select * from "AiModel" order by "createdAt"`;
+ const remainingFalProviders = remainingProviders.filter((provider) =>
+ ["fal-image", "fal-video"].includes(provider.type),
+ );
+ const remainingFalIds = new Set(remainingFalProviders.map((provider) => provider.id));
+ const falFingerprintAfter = stableHash({
+ providers: sortById(remainingFalProviders),
+ models: sortById(
+ remainingModels.filter((model) => remainingFalIds.has(model.providerId)),
+ ),
+ });
+ if (falFingerprintAfter !== falFingerprintBefore) {
+ throw new Error("FAL provider/model fingerprint changed; restore the verified database backup");
+ }
+ const invalidTypes = remainingProviders.filter(
+ (provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
+ );
+ if (apply && invalidTypes.length > 0) {
+ throw new Error(`Unexpected provider types remain: ${invalidTypes.map((item) => item.type).join(", ")}`);
+ }
+ console.log(
+ JSON.stringify(
+ {
+ success: true,
+ mode: apply ? "apply" : "stage",
+ providerTypes: Array.from(new Set(remainingProviders.map((provider) => provider.type))).sort(),
+ vip98kModels: remainingModels.filter((model) =>
+ remainingProviders.some(
+ (provider) => provider.id === model.providerId && provider.type === "vip98k",
+ ),
+ ).length,
+ falFingerprintUnchanged: true,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await sql.end();
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/scripts/test-vip98k-capability.ts b/scripts/test-vip98k-capability.ts
new file mode 100644
index 000000000..4504cc575
--- /dev/null
+++ b/scripts/test-vip98k-capability.ts
@@ -0,0 +1,472 @@
+import { createHash } from "node:crypto";
+import { appendFile, mkdir, readFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import dotenv from "dotenv";
+import sharp from "sharp";
+
+dotenv.config({ path: ".env.local" });
+dotenv.config();
+
+const VIP98K_BASE_URL = "https://api.vip98k.com";
+
+type Vip98kCatalogModel = {
+ id?: string;
+ modelId?: string;
+ model_id?: string;
+ slug?: string;
+ name?: string;
+};
+
+const CAPABILITIES = [
+ "chat",
+ "multimodal",
+ "image",
+ "multi-image-edit",
+ "rmbg",
+ "enhance",
+ "extend",
+ "video",
+ "world",
+ "text-to-3d",
+ "image-to-3d",
+] as const;
+
+type Capability = (typeof CAPABILITIES)[number];
+type LedgerEntry = {
+ runId: string;
+ capability: Capability;
+ modelId: string;
+ status: "started" | "success" | "failure" | "skipped";
+ startedAt: string;
+ finishedAt?: string;
+ durationMs?: number;
+ outputCount?: number;
+ outputDigest?: string;
+ error?: string;
+};
+
+const DEFAULT_MODEL_IDS: Record = {
+ chat: ["gpt-5.4-mini", "gpt-5.4", "claude-sonnet-4-6"],
+ multimodal: ["gpt-5.4-mini", "gpt-5.4", "claude-sonnet-4-6"],
+ image: ["nano-banana-2", "nano-banana-pro", "gpt-image-2-vip"],
+ "multi-image-edit": ["nano-banana-2", "nano-banana-pro", "gpt-image-2-vip"],
+ rmbg: ["remove-background"],
+ enhance: ["enhance", "image-upscaler"],
+ extend: ["image-expansion"],
+ video: [
+ "bytedance/seedance-v1-pro-fast/text-to-video",
+ "veo-3.1-fast",
+ "google/veo3.1-fast/text-to-video",
+ ],
+ world: ["world-generation"],
+ "text-to-3d": [
+ "tencent/hunyuan3d-pro/text-to-3d",
+ "tencent/hunyuan3d-rapid/text-to-3d",
+ ],
+ "image-to-3d": [
+ "tencent/hunyuan3d-pro/image-to-3d",
+ "tencent/hunyuan3d-rapid/image-to-3d",
+ ],
+};
+
+function getArgValue(name: string) {
+ const prefix = `${name}=`;
+ return process.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
+}
+
+function hasFlag(name: string) {
+ return process.argv.includes(name);
+}
+
+function getModelId(model: Vip98kCatalogModel) {
+ return String(model.id || model.modelId || model.model_id || model.slug || model.name || "").trim();
+}
+
+function normalizeVip98kBaseUrl(baseUrl?: string | null) {
+ const normalized = String(baseUrl || VIP98K_BASE_URL).trim().replace(/\/+$/, "");
+ if (!normalized) return VIP98K_BASE_URL;
+ return normalized.replace(/\/v1$/i, "").replace(/\/api\/external\/task$/i, "");
+}
+
+async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) {
+ const payload = await parseResponse(
+ await fetch(`${normalizeVip98kBaseUrl(params.baseURL)}/api/external/task/models`, {
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: "application/json",
+ },
+ signal: AbortSignal.timeout(60_000),
+ }),
+ );
+ const record = payload && typeof payload === "object" ? (payload as Record) : {};
+ const data = record.data;
+ const models =
+ (Array.isArray(data?.models) && data.models) ||
+ (Array.isArray(data?.items) && data.items) ||
+ (Array.isArray(data) && data) ||
+ (Array.isArray(record.models) && record.models) ||
+ [];
+ if (models.length === 0) throw new Error("VIP98K model catalog is empty");
+ return models as Vip98kCatalogModel[];
+}
+
+function sanitizeError(error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]").slice(0, 2000);
+}
+
+function digestOutputs(outputs: string[]) {
+ return createHash("sha256").update(outputs.join("\n")).digest("hex");
+}
+
+async function readLedger(path: string): Promise {
+ const content = await readFile(path, "utf8").catch(() => "");
+ return content
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .flatMap((line) => {
+ try {
+ return [JSON.parse(line) as LedgerEntry];
+ } catch {
+ return [];
+ }
+ });
+}
+
+async function writeLedger(path: string, entry: LedgerEntry) {
+ await mkdir(dirname(path), { recursive: true });
+ await appendFile(path, `${JSON.stringify(entry)}\n`, "utf8");
+}
+
+async function parseResponse(response: Response) {
+ const text = await response.text();
+ let payload: unknown = text;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ // Keep the response body for a useful provider error.
+ }
+ if (!response.ok) {
+ const record = payload && typeof payload === "object" ? (payload as Record) : {};
+ const message =
+ record.error?.message || record.error || record.message || text || `HTTP ${response.status}`;
+ throw new Error(`${String(message)} (HTTP ${response.status})`);
+ }
+ return payload;
+}
+
+function collectUrls(value: unknown, urls = new Set()) {
+ if (!value) return urls;
+ if (typeof value === "string") {
+ for (const match of value.match(/https?:\/\/[^\s"'<>()[\]{}]+/g) || []) {
+ urls.add(match.replace(/[),.;]+$/g, ""));
+ }
+ if (/^data:[^;]+;base64,/i.test(value)) urls.add(value);
+ return urls;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((item) => collectUrls(item, urls));
+ return urls;
+ }
+ if (typeof value === "object") {
+ Object.values(value as Record).forEach((item) => collectUrls(item, urls));
+ }
+ return urls;
+}
+
+function extractTaskId(payload: unknown) {
+ const root = payload && typeof payload === "object" ? (payload as Record) : {};
+ const data = root.data && typeof root.data === "object" ? root.data : root;
+ return String(data.taskId || data.task_id || data.id || data.requestId || "").trim();
+}
+
+function extractResultPath(payload: unknown) {
+ const root = payload && typeof payload === "object" ? (payload as Record) : {};
+ const data = root.data && typeof root.data === "object" ? root.data : root;
+ return String(data.resultPath || data.result_path || "").trim();
+}
+
+function extractStatus(payload: unknown) {
+ const root = payload && typeof payload === "object" ? (payload as Record) : {};
+ const data = root.data && typeof root.data === "object" ? root.data : root;
+ return String(data.status || data.state || data.taskStatus || "").trim().toLowerCase();
+}
+
+async function makeReferencePng(color: string, label: string) {
+ return sharp({
+ create: { width: 96, height: 96, channels: 4, background: color },
+ })
+ .composite([
+ {
+ input: Buffer.from(
+ ``,
+ ),
+ },
+ ])
+ .png()
+ .toBuffer();
+}
+
+async function runOpenAIImage(params: {
+ baseUrl: string;
+ apiKey: string;
+ modelId: string;
+ multiImage: boolean;
+}) {
+ const endpoint = `${params.baseUrl}/v1/images/${params.multiImage ? "edits" : "generations"}`;
+ if (!params.multiImage) {
+ const payload = await parseResponse(
+ await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: params.modelId,
+ prompt: "A small blue ceramic cup on a white studio background",
+ size: "1024x1024",
+ response_format: "url",
+ }),
+ signal: AbortSignal.timeout(900_000),
+ }),
+ );
+ const outputs = Array.from(collectUrls(payload));
+ if (outputs.length === 0) throw new Error("Image generation returned no image output");
+ return outputs;
+ }
+
+ const [left, right] = await Promise.all([
+ makeReferencePng("#2563eb", "A"),
+ makeReferencePng("#dc2626", "B"),
+ ]);
+ const form = new FormData();
+ form.append("model", params.modelId);
+ form.append("prompt", "Combine both references into one clean side-by-side product comparison image");
+ form.append("size", "1024x1024");
+ form.append("response_format", "url");
+ form.append("image[]", new Blob([left], { type: "image/png" }), "reference-a.png");
+ form.append("image[]", new Blob([right], { type: "image/png" }), "reference-b.png");
+ const payload = await parseResponse(
+ await fetch(endpoint, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${params.apiKey}` },
+ body: form,
+ signal: AbortSignal.timeout(900_000),
+ }),
+ );
+ const outputs = Array.from(collectUrls(payload));
+ if (outputs.length === 0) throw new Error("Multi-image edit returned no image output");
+ return outputs;
+}
+
+async function runChat(params: {
+ baseUrl: string;
+ apiKey: string;
+ modelId: string;
+ multimodal: boolean;
+}) {
+ const content = params.multimodal
+ ? [
+ { type: "text", text: "Reply with the dominant color in one English word." },
+ {
+ type: "image_url",
+ image_url: { url: "https://dummyimage.com/96x96/2563eb/ffffff.png&text=A" },
+ },
+ ]
+ : "Reply with exactly: vip98k-ok";
+ const payload = await parseResponse(
+ await fetch(`${params.baseUrl}/v1/chat/completions`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: params.modelId,
+ messages: [{ role: "user", content }],
+ max_tokens: 64,
+ }),
+ signal: AbortSignal.timeout(180_000),
+ }),
+ );
+ const text = JSON.stringify(payload);
+ if (!text || text === "{}") throw new Error("Chat request returned no content");
+ return [text.slice(0, 500)];
+}
+
+async function runExternalTask(params: {
+ baseUrl: string;
+ apiKey: string;
+ modelId: string;
+ capability: Capability;
+}) {
+ const imageUrl = "https://dummyimage.com/512x512/2563eb/ffffff.png&text=VIP98K";
+ const body: Record = {
+ model: params.modelId,
+ displayName: `provider-smoke-${params.capability}`,
+ prompt: "Create a minimal test result with one simple object",
+ };
+ if (["rmbg", "enhance", "extend", "image-to-3d"].includes(params.capability)) {
+ Object.assign(body, {
+ image: imageUrl,
+ imageUrl,
+ image_url: imageUrl,
+ images: [imageUrl],
+ imageUrls: [imageUrl],
+ });
+ }
+ if (params.capability === "extend") {
+ Object.assign(body, { aspectRatio: "16:9", aspect_ratio: "16:9", ratio: "16:9" });
+ }
+ if (params.capability === "video") {
+ Object.assign(body, { mode: "text-to-video", duration: 4, resolution: "480p", ratio: "16:9" });
+ }
+ if (params.capability === "world") body.mode = "world-generation";
+
+ const submitted = await parseResponse(
+ await fetch(`${params.baseUrl}/api/external/task/generate`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(180_000),
+ }),
+ );
+ const immediate = Array.from(collectUrls(submitted));
+ if (immediate.length > 0) return immediate;
+ const taskId = extractTaskId(submitted);
+ if (!taskId) throw new Error("External task returned neither output nor task ID");
+ const resultPath = extractResultPath(submitted);
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < 1_200_000) {
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000));
+ const resultUrl = resultPath
+ ? resultPath.startsWith("http")
+ ? resultPath
+ : `${params.baseUrl}${resultPath.startsWith("/") ? "" : "/"}${resultPath}`
+ : `${params.baseUrl}/api/external/task/result?taskId=${encodeURIComponent(taskId)}`;
+ const payload = await parseResponse(
+ await fetch(resultUrl, {
+ headers: { Authorization: `Bearer ${params.apiKey}` },
+ signal: AbortSignal.timeout(60_000),
+ }),
+ );
+ const status = extractStatus(payload);
+ const outputs = Array.from(collectUrls(payload));
+ if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
+ throw new Error(`External task failed with status ${status}`);
+ }
+ if (
+ ["succeeded", "success", "completed", "done", "finished"].includes(status) ||
+ outputs.length > 0
+ ) {
+ if (outputs.length === 0) throw new Error("External task completed without output URL");
+ return outputs;
+ }
+ }
+ throw new Error("External task did not complete within 20 minutes");
+}
+
+async function main() {
+ const capabilityArg = getArgValue("--capability");
+ if (!CAPABILITIES.includes(capabilityArg as Capability)) {
+ throw new Error(`Pass exactly one --capability=${CAPABILITIES.join("|")}`);
+ }
+ const capability = capabilityArg as Capability;
+ const apiKey = String(process.env.VIP98K_API_KEY || "").trim();
+ if (!apiKey) throw new Error("VIP98K_API_KEY is required and must remain server-side");
+ const baseUrl = normalizeVip98kBaseUrl(process.env.VIP98K_BASE_URL || VIP98K_BASE_URL);
+ const catalog = await fetchVip98kCatalog({ baseURL: baseUrl, apiKey });
+ const availableModelIds = new Set(catalog.map(getModelId));
+ const explicitModelId = getArgValue("--model");
+ const modelId =
+ explicitModelId || DEFAULT_MODEL_IDS[capability].find((candidate) => availableModelIds.has(candidate));
+ if (!modelId || !availableModelIds.has(modelId)) {
+ throw new Error(`No catalog model is available for ${capability}; pass --model=`);
+ }
+
+ const ledgerPath = resolve(
+ getArgValue("--ledger") ||
+ process.env.VIP98K_TEST_LEDGER ||
+ ".local/vip98k-capability-tests.jsonl",
+ );
+ const ledger = await readLedger(ledgerPath);
+ if (
+ !hasFlag("--force") &&
+ ledger.some(
+ (entry) =>
+ entry.capability === capability && entry.modelId === modelId && entry.status === "success",
+ )
+ ) {
+ console.log(JSON.stringify({ skipped: true, capability, modelId, ledgerPath }, null, 2));
+ return;
+ }
+
+ const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const startedAt = new Date();
+ await writeLedger(ledgerPath, {
+ runId,
+ capability,
+ modelId,
+ status: "started",
+ startedAt: startedAt.toISOString(),
+ });
+
+ try {
+ let outputs: string[];
+ if (capability === "chat" || capability === "multimodal") {
+ outputs = await runChat({
+ baseUrl,
+ apiKey,
+ modelId,
+ multimodal: capability === "multimodal",
+ });
+ } else if (capability === "image" || capability === "multi-image-edit") {
+ outputs = await runOpenAIImage({
+ baseUrl,
+ apiKey,
+ modelId,
+ multiImage: capability === "multi-image-edit",
+ });
+ } else {
+ outputs = await runExternalTask({ baseUrl, apiKey, modelId, capability });
+ }
+ const finishedAt = new Date();
+ const entry: LedgerEntry = {
+ runId,
+ capability,
+ modelId,
+ status: "success",
+ startedAt: startedAt.toISOString(),
+ finishedAt: finishedAt.toISOString(),
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
+ outputCount: outputs.length,
+ outputDigest: digestOutputs(outputs),
+ };
+ await writeLedger(ledgerPath, entry);
+ console.log(JSON.stringify({ ...entry, ledgerPath }, null, 2));
+ } catch (error) {
+ const finishedAt = new Date();
+ const entry: LedgerEntry = {
+ runId,
+ capability,
+ modelId,
+ status: "failure",
+ startedAt: startedAt.toISOString(),
+ finishedAt: finishedAt.toISOString(),
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
+ error: sanitizeError(error),
+ };
+ await writeLedger(ledgerPath, entry);
+ console.error(JSON.stringify({ ...entry, ledgerPath }, null, 2));
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(sanitizeError(error));
+ process.exit(1);
+});