fix: reconcile VIP98K media catalog

This commit is contained in:
134 2026-08-10 23:09:26 +08:00
parent 74ea0ca9fc
commit 2fee4a4a8c
8 changed files with 364 additions and 58 deletions

View File

@ -7663,7 +7663,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
"将从 VIP98K 模型目录同步全部模型,并按图片、视频、3D 世界、文本和工具分类。图片模型会配置比例、1K/2K/4K 和多参考图能力。\n\n继续吗?"
"将同步 VIP98K 当前可用目录,停用服务商已关闭的模型,并排除纯文生图和纯文生视频模型。图片编辑、图生视频、参考媒体、多模态、3D 与工具模型会保留;你此前手动删除的模型不会恢复。\n\n继续吗?"
)
) {
return;
@ -7681,8 +7681,10 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
const totalCatalog = Number(data.counts?.total || totalSynced);
const totalDisabled = Number(data.disabledCount || 0);
toast.success(
`已同步 ${totalSynced} 个 VIP98K 模型(目录共 ${Number(data.counts?.total || totalSynced)} 个)`
`已同步 ${totalSynced} 个 VIP98K 模型,停用 ${totalDisabled} 个旧模型(可用目录 ${totalCatalog} 个)`
);
mutateModels();
if (data.errors?.length) {

View File

@ -2,15 +2,10 @@ import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
fetchVip98kModelPresets,
hasVip98kTextToImageMode,
type Vip98kModelPreset,
} from "@/lib/ai/vip98k-models";
import { db } from "@/lib/db";
import {
deleteAiModel,
getAiModelsByProviderId,
getAiProviderById,
} from "@/lib/db/queries";
import { getAiProviderById } from "@/lib/db/queries";
import { aiModel, type AiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
@ -80,6 +75,7 @@ export async function POST(request: Request) {
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
)
: null;
const preserveDeletedModels = body?.preserveDeletedModels !== false;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
@ -96,38 +92,35 @@ export async function POST(request: Request) {
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const existingModels = await db.query.aiModel.findMany({
where: eq(aiModel.providerId, providerId),
});
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
const preserveExistingCatalog =
requestedModelIds === null &&
preserveDeletedModels &&
existingModels.length > 0;
const presets = requestedModelIds
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalogPresets;
: preserveExistingCatalog
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const removedModels: AiModel[] = [];
const disabledModels: AiModel[] = [];
const errors: Array<{ modelId: string; error: string }> = [];
const existingModels = await getAiModelsByProviderId({ providerId });
const textToImageModels = existingModels.filter(
(model) =>
model.supportsImageGen &&
hasVip98kTextToImageMode({
modelId: model.modelId,
capabilities: model.capabilities,
defaultParams: model.defaultParams,
}),
);
for (const model of textToImageModels) {
try {
const deleted = await deleteAiModel({ id: model.id });
removedModels.push(...deleted);
} catch (error) {
console.error(
`[VIP98K Models API] Failed to remove text-to-image model ${model.modelId}:`,
error,
);
errors.push({
modelId: model.modelId,
error: error instanceof Error ? error.message : "Removal failed",
});
if (requestedModelIds === null) {
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
for (const existing of existingModels) {
if (catalogModelIds.has(existing.modelId)) continue;
const [disabled] = await db
.update(aiModel)
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
.where(eq(aiModel.id, existing.id))
.returning();
if (disabled) disabledModels.push(disabled);
}
}
@ -145,7 +138,8 @@ export async function POST(request: Request) {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isEnabled:
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
isHidden: preset.isHidden,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities,
@ -193,10 +187,10 @@ export async function POST(request: Request) {
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
removedCount: removedModels.length,
disabledCount: disabledModels.length,
addedModels,
updatedModels,
removedModels,
disabledModelIds: disabledModels.map((model) => model.modelId),
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,

View File

@ -3,10 +3,17 @@ import "server-only";
import { createOpenAI } from "@ai-sdk/openai";
import sharp from "sharp";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import {
resolveVip98kTaskInvocation,
type Vip98kTaskInvocation,
} from "@/lib/ai/vip98k-task-invocation";
import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
import { createLogger } from "@/lib/observability/logger";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("vip98k-provider");
interface Vip98kConfig {
apiKey: string;
baseURL?: string | null;
@ -30,15 +37,7 @@ type Vip98kModelRuntimeConfig = {
imageGenConfig?: Record<string, unknown> | null;
};
type Vip98kInvocation = {
kind: string;
method: string;
path: string;
contentType: string;
requestExample: Record<string, unknown>;
resultMethod: string;
resultPath: string;
};
type Vip98kInvocation = Vip98kTaskInvocation;
type UploadedImage = {
thumbnail: string;
@ -491,14 +490,38 @@ export async function submitVip98kExternalTask(params: {
imageParams: params.imageParams,
modelConfig: params.modelConfig || {},
};
const invocation = getVip98kInvocation(taskParams.modelConfig);
const invocation = resolveVip98kTaskInvocation(
taskParams.modelId,
getVip98kInvocation(taskParams.modelConfig)
);
const body = buildTaskBody(taskParams, invocation);
const imageBindingKeys = [
"images",
"imageUrls",
"referenceImages",
"reference_images",
"image",
"imageUrl",
"image_url",
"video_clips",
].filter((key) => key in body);
logger.info("task submit audit", {
modelId: taskParams.modelId,
kind: taskParams.kind,
invocationKind: invocation?.kind || "task",
invocationPath: invocation?.path || "/api/external/task",
imageCount: imageUrls.length,
videoCount: videoUrls.length,
imageBindingKeys,
bodyKeys: Object.keys(body).sort(),
});
const submitted = await submitTask({
apiKey: params.apiKey,
baseURL: params.baseURL,
path: invocation?.path,
method: invocation?.method,
contentType: invocation?.contentType,
body: buildTaskBody(taskParams, invocation),
body,
});
const resultPath = submitted.resultPath || invocation?.resultPath || "";
return {
@ -653,8 +676,12 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
}
if (params.imageUrls.length > 0) {
let hasImageBinding = false;
const setImageField = (key: string, value: unknown) => {
if (key in body || !invocation) body[key] = value;
if (key in body || !invocation) {
body[key] = value;
hasImageBinding = true;
}
};
setImageField("images", params.imageUrls);
setImageField("imageUrls", params.imageUrls);
@ -670,6 +697,7 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
if (Array.isArray(body.video_clips)) {
const template = toRecord(body.video_clips[0]) || {};
body.video_clips = params.imageUrls.map((url) => ({ ...template, url }));
hasImageBinding = true;
}
if (params.imageUrls[1]) {
setImageField("lastFrameImage", params.imageUrls[1]);
@ -677,6 +705,12 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
setImageField("endImageUrl", params.imageUrls[1]);
setImageField("end_image_url", params.imageUrls[1]);
}
if (
!hasImageBinding &&
(params.kind === "image" || params.kind === "image-tool")
) {
body.images = params.imageUrls;
}
}
if (params.videoUrls.length > 0) {

View File

@ -57,6 +57,11 @@ export interface Vip98kCatalogModel {
pricing?: unknown;
price?: unknown;
status?: string;
available?: boolean;
isAvailable?: boolean;
enabled?: boolean;
isEnabled?: boolean;
is_active?: boolean;
invocation?: Vip98kInvocationContract;
taskTypes?: unknown;
task_types?: unknown;
@ -300,6 +305,95 @@ function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
return catalogType;
}
const UNAVAILABLE_STATUS_TOKENS = [
"disabled",
"unavailable",
"closed",
"offline",
"deprecated",
"removed",
"retired",
"suspended",
] as const;
function getInvocationRequestKeys(model: Vip98kCatalogModel) {
const invocation = toRecord(model.invocation);
return Object.keys(toRecord(invocation?.request_example) || {}).map((key) =>
key.trim().toLowerCase().replace(/_/g, "-"),
);
}
function hasMediaReferenceInput(
model: Vip98kCatalogModel,
type: Vip98kCatalogModelType,
) {
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model).map((mode) => mode.replace(/_/g, "-"));
const requestKeys = getInvocationRequestKeys(model);
const modelId = getModelId(model).toLowerCase();
const mediaInputModes = new Set([
"image",
"images",
"url",
"file",
"video",
"audio",
"base64",
]);
const hasMediaCapability = capabilities.some((capability) => {
if (capability.startsWith("text-to-")) return false;
if (type === "image") {
return capability.endsWith("-to-image") || capability.includes("image-edit");
}
return (
capability.endsWith("-to-video") ||
capability.startsWith("video-") ||
capability.includes("video-edit")
);
});
const hasMediaRequestField = requestKeys.some((key) =>
/(?:image|video|audio|reference|first-?frame|last-?frame|file|url)/.test(key),
);
const hasMediaModelPath =
type === "image"
? /(?:image-to-image|reference-to-image|\/edit(?:$|[\/-]))/.test(modelId)
: /(?:image-to-video|video-to-video|reference-to-video|first-frame|start-end|video-edit|video-extend)/.test(
modelId,
);
return (
hasMediaCapability ||
inputModes.some((mode) => mediaInputModes.has(mode)) ||
hasMediaRequestField ||
hasMediaModelPath
);
}
export function isVip98kCatalogModelAvailable(model: Vip98kCatalogModel) {
const status = String(model.status || "").trim().toLowerCase();
if (UNAVAILABLE_STATUS_TOKENS.some((token) => status.includes(token))) {
return false;
}
return !(
model.available === false ||
model.isAvailable === false ||
model.enabled === false ||
model.isEnabled === false ||
model.is_active === false
);
}
export function isVip98kCatalogModelEligible(model: Vip98kCatalogModel) {
if (!getModelId(model) || !isVip98kCatalogModelAvailable(model)) return false;
const type = resolvePresetType(model);
if (type !== "image" && type !== "video") return true;
return hasMediaReferenceInput(model, type);
}
export function filterVip98kCatalogModels(models: Vip98kCatalogModel[]) {
return models.filter(isVip98kCatalogModelEligible);
}
function normalizeImageSize(value: string): string | null {
const normalized = value.trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
@ -801,11 +895,10 @@ function createToolPreset(
export function buildVip98kModelPresets(
models: Vip98kCatalogModel[],
): Vip98kModelPreset[] {
return models
return filterVip98kCatalogModels(models)
.map((model, index) => {
const modelId = getModelId(model);
if (!modelId) return null;
if (isVip98kTextToImageCatalogModel(model)) return null;
const type = resolvePresetType(model);
if (type === "image") return createImagePreset(model, index);
if (type === "video") return createVideoPreset(model, index);

View File

@ -0,0 +1,67 @@
export type Vip98kTaskInvocation = {
kind: string;
method: string;
path: string;
contentType: string;
requestExample: Record<string, unknown>;
resultMethod: string;
resultPath: string;
};
type KnownImageInput = "images" | "video_clips";
const KNOWN_IMAGE_TOOL_INPUTS: Record<string, KnownImageInput> = {
"google/nano-banana-2/reference-to-image": "video_clips",
"google/nano-banana-pro/edit": "images",
"openai/gpt-image-2/edit": "images",
};
function getInvocationToolId(invocation: Vip98kTaskInvocation | null) {
if (!invocation || invocation.kind !== "tool") return null;
const query = invocation.path.split("?", 2)[1] || "";
return new URLSearchParams(query).get("tool_id");
}
function createKnownImageToolInvocation(
modelId: string,
imageInput: KnownImageInput
): Vip98kTaskInvocation {
const encodedModelId = encodeURIComponent(modelId);
const requestExample =
imageInput === "video_clips"
? {
prompt: "",
video_clips: [{ fps: 1, url: "", ends: 0, start: 0 }],
}
: { prompt: "", images: [""] };
return {
kind: "tool",
method: "POST",
path: `/api/external/tools/generate?tool_id=${encodedModelId}`,
contentType: "application/json",
requestExample,
resultMethod: "GET",
resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`,
};
}
/**
* A frontend alias may resolve to a different VIP98K tool at runtime. In that
* case the selected model's invocation cannot be reused because its endpoint
* and reference-image field belong to the alias rather than the resolved tool.
*/
export function resolveVip98kTaskInvocation(
modelId: string,
configuredInvocation: Vip98kTaskInvocation | null
) {
const normalizedModelId = modelId.trim().toLowerCase();
const knownImageInput = KNOWN_IMAGE_TOOL_INPUTS[normalizedModelId];
if (!knownImageInput) return configuredInvocation;
if (getInvocationToolId(configuredInvocation)?.toLowerCase() === normalizedModelId) {
return configuredInvocation;
}
return createKnownImageToolInvocation(modelId, knownImageInput);
}

View File

@ -23,6 +23,7 @@ export default defineConfig({
/routes\/hicad-embedded-performance\.test\.ts/,
/routes\/hicad-manufacturing-extension\.test\.ts/,
/routes\/openapi-access-key\.test\.ts/,
/routes\/vip98k-reference-routing\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,

View File

@ -14,7 +14,11 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
import {
buildVip98kModelPresets,
isVip98kCatalogModelEligible,
isVip98kImageModelVisible,
} from "@/lib/ai/vip98k-models";
const atlasHunyuan3D = {
model: "tencent/hunyuan3d-pro/text-to-3d",
@ -312,7 +316,8 @@ test.describe("AI model media classification", () => {
modelId: "mislabelled-video",
displayName: "Mislabelled video",
type: "image",
capabilities: ["text_to_video"],
capabilities: ["text_to_video", "image_to_video"],
inputModes: ["text", "image"],
},
{
modelId: "enhance",
@ -380,7 +385,7 @@ test.describe("AI model media classification", () => {
});
test("only shows VIP98K image models verified as released in 2026 or later", () => {
const presets = buildVip98kModelPresets([
const models = [
{
modelId: "black-forest-labs/flux-dev",
displayName: "Flux Dev",
@ -407,14 +412,61 @@ test.describe("AI model media classification", () => {
capabilities: ["image_to_image"],
releasedAt: "2025-12-15",
},
]);
];
expect(presets.find((item) => item.modelId === "black-forest-labs/flux-dev")?.isHidden).toBe(true);
expect(isVip98kImageModelVisible(models[0])).toBe(false);
expect(isVip98kImageModelVisible(models[1])).toBe(true);
expect(isVip98kImageModelVisible(models[2])).toBe(true);
expect(isVip98kImageModelVisible(models[3])).toBe(false);
});
test("excludes pure text image/video models but keeps reference and 3D models", () => {
expect(
presets.find((item) => item.modelId === "google/nano-banana-2/edit")?.isHidden,
isVip98kCatalogModelEligible({
modelId: "openai/gpt-image-2/text-to-image",
type: "image",
capabilities: ["text-to-image"],
}),
).toBe(false);
expect(
isVip98kCatalogModelEligible({
modelId: "google/veo/text-to-video",
type: "video",
capabilities: ["text-to-video"],
}),
).toBe(false);
expect(
isVip98kCatalogModelEligible({
modelId: "google/nano-banana-2/edit",
type: "image",
capabilities: ["text-to-image", "image-to-image"],
}),
).toBe(true);
expect(
isVip98kCatalogModelEligible({
modelId: "bytedance/seedance/image-to-video",
type: "video",
capabilities: ["text-to-video", "image-to-video"],
}),
).toBe(true);
expect(
isVip98kCatalogModelEligible({
modelId: "tencent/hunyuan3d-pro/text-to-3d",
type: "image",
capabilities: ["text-to-3d"],
}),
).toBe(true);
});
test("excludes catalog models explicitly marked unavailable", () => {
expect(
isVip98kCatalogModelEligible({
modelId: "google/nano-banana-2/edit",
type: "image",
capabilities: ["image-to-image"],
status: "closed",
}),
).toBe(false);
expect(presets.find((item) => item.modelId === "future-image-with-date")?.isHidden).toBe(false);
expect(presets.find((item) => item.modelId === "old-image-with-date")?.isHidden).toBe(true);
});
test("excludes VIP98K text-to-image models without applying a price limit", () => {
@ -446,6 +498,7 @@ test.describe("AI model media classification", () => {
]);
expect(presets.map((item) => item.modelId)).toEqual([
"mixed-image-model",
"expensive-image-edit",
"remove-background",
]);

View File

@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import {
resolveVip98kTaskInvocation,
type Vip98kTaskInvocation,
} from "@/lib/ai/vip98k-task-invocation";
const textToImageInvocation: Vip98kTaskInvocation = {
kind: "tool",
method: "POST",
path: "/api/external/tools/generate?tool_id=google%2Fnano-banana-2%2Ftext-to-image",
contentType: "application/json",
requestExample: { prompt: "Generate an image" },
resultMethod: "GET",
resultPath:
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
};
test.describe("VIP98K reference-image routing", () => {
test("replaces a text-to-image alias invocation with the resolved reference tool", () => {
const invocation = resolveVip98kTaskInvocation(
"google/nano-banana-2/reference-to-image",
textToImageInvocation
);
expect(invocation?.path).toContain(
"tool_id=google%2Fnano-banana-2%2Freference-to-image"
);
expect(invocation?.resultPath).toContain(
"tool_id=google%2Fnano-banana-2%2Freference-to-image"
);
expect(invocation?.requestExample).toMatchObject({
prompt: "",
video_clips: [{ fps: 1, url: "", ends: 0, start: 0 }],
});
});
test("keeps the catalog invocation when it already targets the resolved tool", () => {
const catalogInvocation: Vip98kTaskInvocation = {
...textToImageInvocation,
path: "/api/external/tools/generate?tool_id=google%2Fnano-banana-2%2Freference-to-image",
resultPath:
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Freference-to-image&taskId={task_id}",
requestExample: {
prompt: "Catalog prompt",
video_clips: [{ fps: 2, url: "catalog", ends: 1, start: 0 }],
},
};
expect(
resolveVip98kTaskInvocation(
"google/nano-banana-2/reference-to-image",
catalogInvocation
)
).toBe(catalogInvocation);
});
test("does not rewrite unrelated tools", () => {
expect(
resolveVip98kTaskInvocation("other/image-model", textToImageInvocation)
).toBe(textToImageInvocation);
});
});