feat(ai): consolidate providers around VIP98K and FAL

This commit is contained in:
134 2026-08-08 16:20:48 +08:00 committed by root
parent 725a93331a
commit 4a1536515e
22 changed files with 1840 additions and 53 deletions

View File

@ -156,12 +156,15 @@ export function LocalAiImagePipelineSections({
},
{
label:
rmbgConfig.apiKey ||
rmbgConfig.provider === "vip98k"
? "服务商 Key 已配置"
: rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
? "Key 已配置"
: "Key 待配置",
tone:
rmbgConfig.provider === "vip98k" ||
rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
@ -176,7 +179,26 @@ export function LocalAiImagePipelineSections({
{/* 服务提供商选择 */}
<div className="space-y-3">
<Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "vip98k"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "vip98k"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
</div>
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg"
@ -263,6 +285,13 @@ export function LocalAiImagePipelineSections({
<Separator />
{rmbgConfig.provider === "vip98k" && (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,不会在此重复保存。
</div>
)}
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
@ -468,7 +497,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
</>
}
expanded={isLocalAiSectionExpanded("enhance")}
@ -478,17 +507,21 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: enhanceConfig.useBackupApi
label: enhanceConfig.provider === "vip98k"
? "VIP98K"
: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN"
: "主 API",
tone: "neutral",
},
{
label:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置"
: "Key 待配置",
tone:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled"
: "warning",
@ -498,6 +531,13 @@ export function LocalAiImagePipelineSections({
}
title="变清晰API配置"
>
{enhanceConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
VIP98K 服务商读取。
</div>
) : (
<>
{/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
@ -746,6 +786,8 @@ export function LocalAiImagePipelineSections({
</div>
</>
)}
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
@ -2215,7 +2257,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
</>
}
expanded={isLocalAiSectionExpanded("extend")}
@ -2225,8 +2267,14 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
tone: extendConfig.apiKey ? "enabled" : "warning",
label:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "Key 已配置"
: "Key 待配置",
tone:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "enabled"
: "warning",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2237,6 +2285,12 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
{extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
) : (
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
@ -2253,6 +2307,7 @@ export function LocalAiImagePipelineSections({
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>

View File

@ -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;

View File

@ -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,

View File

@ -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);

View File

@ -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<ReturnType<typeof getVip98kRuntimeProvider>> | 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()

View File

@ -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<ReturnType<typeof getTripoProviderConfig>> | 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;

View File

@ -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 &&

View File

@ -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 &&

View File

@ -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方式)...");

View File

@ -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) {

View File

@ -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<string> {
async function extendImage(
imageUrl: string,
apiKey: string,
config: ExtendConfig,
xScale: number,
yScale: number,
): Promise<string> {
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<ExtendConfig> {
}
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,

View File

@ -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(

View File

@ -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<string, unknown>;
imageParams?: Record<string, unknown>;
modelConfig?: Vip98kModelRuntimeConfig;
}): Promise<Vip98kExternalTaskSubmission> {
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<Vip98kExternalTaskStatus> {
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<string, unknown>;
imageParams?: Record<string, unknown>;
modelConfig?: Vip98kModelRuntimeConfig;
}): Promise<Vip98kExternalTaskResult> {
const submitted = await submitVip98kExternalTask({
...params,
});
const outputUrls = submitted.outputs.length
? submitted.outputs
: await waitForTask({
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,
}).then((result) => result.outputs);
});
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);
}

View File

@ -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", {

View File

@ -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: {

View File

@ -86,12 +86,17 @@ const TYPE_SORT_ORDER: Record<string, number> = {
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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@ -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);
})

165
lib/ai/vip98k-runtime.ts Normal file
View File

@ -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<Vip98kRuntimeProvider[]> {
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<Vip98kRuntimeProvider> {
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<string, unknown>;
videoParams?: Record<string, unknown>;
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<string, unknown>)
: null,
imageGenConfig:
model.imageGenConfig && typeof model.imageGenConfig === "object"
? (model.imageGenConfig as Record<string, unknown>)
: 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<string, unknown>;
videoParams?: Record<string, unknown>;
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<string, unknown>)
: null,
imageGenConfig:
model.imageGenConfig && typeof model.imageGenConfig === "object"
? (model.imageGenConfig as Record<string, unknown>)
: 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 };
}

View File

@ -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) {

View File

@ -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(

View File

@ -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",

View File

@ -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<T extends { id: string }>(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<Vip98kModelPreset[]> {
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<string, string[]> = {
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<string, ProviderRow>;
oldModels: Map<string, ModelRow>;
targetProviderId: string;
targetModelByKind: Record<string, { id: string; modelId: string }>;
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<string, unknown>).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<Record<string, number>>((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<ProviderRow[]>`select * from "AiProvider" order by "createdAt"`;
const models = await sql<ModelRow[]>`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<string, string>();
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<string, { id: string; modelId: string }>;
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<ProviderRow[]>`select * from "AiProvider" order by "createdAt"`;
const remainingModels = await sql<ModelRow[]>`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);
});

View File

@ -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<Capability, string[]> = {
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<string, any>) : {};
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<LedgerEntry[]> {
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<string, any>) : {};
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<string>()) {
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<string, unknown>).forEach((item) => collectUrls(item, urls));
}
return urls;
}
function extractTaskId(payload: unknown) {
const root = payload && typeof payload === "object" ? (payload as Record<string, any>) : {};
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<string, any>) : {};
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<string, any>) : {};
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(
`<svg width="96" height="96"><text x="48" y="58" text-anchor="middle" font-size="30" fill="white">${label}</text></svg>`,
),
},
])
.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<string, unknown> = {
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=<catalog-id>`);
}
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);
});