From 2fee4a4a8ca51f2508390072ebcf049e180f0a8c Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 10 Aug 2026 23:09:26 +0800 Subject: [PATCH] fix: reconcile VIP98K media catalog --- .../settings/admin-settings-client.tsx | 6 +- app/api/admin/vip98k-models/route.ts | 62 ++++++------ lib/ai/providers/vip98k.ts | 58 ++++++++--- lib/ai/vip98k-models.ts | 97 ++++++++++++++++++- lib/ai/vip98k-task-invocation.ts | 67 +++++++++++++ playwright.helpers.config.ts | 1 + tests/routes/model-classification.test.ts | 69 +++++++++++-- tests/routes/vip98k-reference-routing.test.ts | 62 ++++++++++++ 8 files changed, 364 insertions(+), 58 deletions(-) create mode 100644 lib/ai/vip98k-task-invocation.ts create mode 100644 tests/routes/vip98k-reference-routing.test.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 514c4ceb1..20a8c15ff 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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) { diff --git a/app/api/admin/vip98k-models/route.ts b/app/api/admin/vip98k-models/route.ts index 11929f657..d1c406818 100644 --- a/app/api/admin/vip98k-models/route.ts +++ b/app/api/admin/vip98k-models/route.ts @@ -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, diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 4d7833db5..30462234d 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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 | null; }; -type Vip98kInvocation = { - kind: string; - method: string; - path: string; - contentType: string; - requestExample: Record; - 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) { diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 2557c9603..33e54ce23 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -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); diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts new file mode 100644 index 000000000..83ebfdfe1 --- /dev/null +++ b/lib/ai/vip98k-task-invocation.ts @@ -0,0 +1,67 @@ +export type Vip98kTaskInvocation = { + kind: string; + method: string; + path: string; + contentType: string; + requestExample: Record; + resultMethod: string; + resultPath: string; +}; + +type KnownImageInput = "images" | "video_clips"; + +const KNOWN_IMAGE_TOOL_INPUTS: Record = { + "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); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 04be7311c..1c1ca1287 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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, diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts index 1bfa3074d..2c5297a50 100644 --- a/tests/routes/model-classification.test.ts +++ b/tests/routes/model-classification.test.ts @@ -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", ]); diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts new file mode 100644 index 000000000..1cf877f38 --- /dev/null +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -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); + }); +});