feat(ai): consolidate providers around VIP98K and FAL
This commit is contained in:
parent
311d4a0ec3
commit
f414150e73
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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方式)...");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
165
lib/ai/vip98k-runtime.ts
Normal 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 };
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
443
scripts/consolidate-ai-providers.ts
Normal file
443
scripts/consolidate-ai-providers.ts
Normal 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);
|
||||
});
|
||||
472
scripts/test-vip98k-capability.ts
Normal file
472
scripts/test-vip98k-capability.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user