fix: reconcile VIP98K media catalog
This commit is contained in:
parent
712d4350a0
commit
f3f1fdafbd
@ -7691,7 +7691,7 @@ export function AdminSettingsClient() {
|
|||||||
if (
|
if (
|
||||||
!options?.skipConfirm &&
|
!options?.skipConfirm &&
|
||||||
!confirm(
|
!confirm(
|
||||||
"将从 VIP98K 模型目录同步全部模型,并按图片、视频、3D 世界、文本、图片工具、OCR、音频工具分类。图片模型会配置比例、1K/2K/4K 和多参考图能力。\n\n继续吗?"
|
"将同步 VIP98K 当前可用目录,停用服务商已关闭的模型,并排除纯文生图和纯文生视频模型。图片编辑、图生视频、参考媒体、多模态、3D 与工具模型会保留;你此前手动删除的模型不会恢复。\n\n继续吗?"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -7710,8 +7710,9 @@ export function AdminSettingsClient() {
|
|||||||
const totalSynced =
|
const totalSynced =
|
||||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||||
const totalCatalog = Number(data.counts?.total || totalSynced);
|
const totalCatalog = Number(data.counts?.total || totalSynced);
|
||||||
|
const totalDisabled = Number(data.disabledCount || 0);
|
||||||
toast.success(
|
toast.success(
|
||||||
`已同步 ${totalSynced} 个 VIP98K 模型(目录共 ${totalCatalog} 个)`
|
`已同步 ${totalSynced} 个 VIP98K 模型,停用 ${totalDisabled} 个旧模型(可用目录 ${totalCatalog} 个)`
|
||||||
);
|
);
|
||||||
mutateModels();
|
mutateModels();
|
||||||
if (data.errors?.length) {
|
if (data.errors?.length) {
|
||||||
|
|||||||
@ -75,6 +75,7 @@ export async function POST(request: Request) {
|
|||||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -91,14 +92,38 @@ export async function POST(request: Request) {
|
|||||||
baseURL: provider.baseUrl,
|
baseURL: provider.baseUrl,
|
||||||
apiKey: provider.apiKey,
|
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
|
const presets = requestedModelIds
|
||||||
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||||
: catalogPresets;
|
: preserveExistingCatalog
|
||||||
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
|
: catalogPresets;
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
const addedModels: AiModel[] = [];
|
||||||
const updatedModels: AiModel[] = [];
|
const updatedModels: AiModel[] = [];
|
||||||
|
const disabledModels: AiModel[] = [];
|
||||||
const errors: Array<{ modelId: string; error: string }> = [];
|
const errors: Array<{ modelId: string; error: string }> = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const preset of presets) {
|
for (const preset of presets) {
|
||||||
try {
|
try {
|
||||||
const existing = await db.query.aiModel.findFirst({
|
const existing = await db.query.aiModel.findFirst({
|
||||||
@ -113,7 +138,8 @@ export async function POST(request: Request) {
|
|||||||
description: preset.description,
|
description: preset.description,
|
||||||
supportsVision: preset.supportsVision,
|
supportsVision: preset.supportsVision,
|
||||||
supportsImageGen: preset.supportsImageGen,
|
supportsImageGen: preset.supportsImageGen,
|
||||||
isEnabled: true,
|
isEnabled:
|
||||||
|
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
||||||
isHidden: preset.isHidden,
|
isHidden: preset.isHidden,
|
||||||
sortOrder: preset.sortOrder,
|
sortOrder: preset.sortOrder,
|
||||||
capabilities: preset.capabilities,
|
capabilities: preset.capabilities,
|
||||||
@ -161,8 +187,10 @@ export async function POST(request: Request) {
|
|||||||
success: true,
|
success: true,
|
||||||
addedCount: addedModels.length,
|
addedCount: addedModels.length,
|
||||||
updatedCount: updatedModels.length,
|
updatedCount: updatedModels.length,
|
||||||
|
disabledCount: disabledModels.length,
|
||||||
addedModels,
|
addedModels,
|
||||||
updatedModels,
|
updatedModels,
|
||||||
|
disabledModelIds: disabledModels.map((model) => model.modelId),
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
counts: {
|
counts: {
|
||||||
requested: presets.length,
|
requested: presets.length,
|
||||||
|
|||||||
@ -3,10 +3,17 @@ import "server-only";
|
|||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
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 { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
const logger = createLogger("vip98k-provider");
|
||||||
|
|
||||||
interface Vip98kConfig {
|
interface Vip98kConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string | null;
|
baseURL?: string | null;
|
||||||
@ -30,15 +37,7 @@ type Vip98kModelRuntimeConfig = {
|
|||||||
imageGenConfig?: Record<string, unknown> | null;
|
imageGenConfig?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Vip98kInvocation = {
|
type Vip98kInvocation = Vip98kTaskInvocation;
|
||||||
kind: string;
|
|
||||||
method: string;
|
|
||||||
path: string;
|
|
||||||
contentType: string;
|
|
||||||
requestExample: Record<string, unknown>;
|
|
||||||
resultMethod: string;
|
|
||||||
resultPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UploadedImage = {
|
type UploadedImage = {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
@ -491,14 +490,38 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
imageParams: params.imageParams,
|
imageParams: params.imageParams,
|
||||||
modelConfig: params.modelConfig || {},
|
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({
|
const submitted = await submitTask({
|
||||||
apiKey: params.apiKey,
|
apiKey: params.apiKey,
|
||||||
baseURL: params.baseURL,
|
baseURL: params.baseURL,
|
||||||
path: invocation?.path,
|
path: invocation?.path,
|
||||||
method: invocation?.method,
|
method: invocation?.method,
|
||||||
contentType: invocation?.contentType,
|
contentType: invocation?.contentType,
|
||||||
body: buildTaskBody(taskParams, invocation),
|
body,
|
||||||
});
|
});
|
||||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||||
return {
|
return {
|
||||||
@ -653,8 +676,12 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.imageUrls.length > 0) {
|
if (params.imageUrls.length > 0) {
|
||||||
|
let hasImageBinding = false;
|
||||||
const setImageField = (key: string, value: unknown) => {
|
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("images", params.imageUrls);
|
||||||
setImageField("imageUrls", params.imageUrls);
|
setImageField("imageUrls", params.imageUrls);
|
||||||
@ -670,6 +697,7 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
|||||||
if (Array.isArray(body.video_clips)) {
|
if (Array.isArray(body.video_clips)) {
|
||||||
const template = toRecord(body.video_clips[0]) || {};
|
const template = toRecord(body.video_clips[0]) || {};
|
||||||
body.video_clips = params.imageUrls.map((url) => ({ ...template, url }));
|
body.video_clips = params.imageUrls.map((url) => ({ ...template, url }));
|
||||||
|
hasImageBinding = true;
|
||||||
}
|
}
|
||||||
if (params.imageUrls[1]) {
|
if (params.imageUrls[1]) {
|
||||||
setImageField("lastFrameImage", params.imageUrls[1]);
|
setImageField("lastFrameImage", params.imageUrls[1]);
|
||||||
@ -677,6 +705,12 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
|
|||||||
setImageField("endImageUrl", params.imageUrls[1]);
|
setImageField("endImageUrl", params.imageUrls[1]);
|
||||||
setImageField("end_image_url", 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) {
|
if (params.videoUrls.length > 0) {
|
||||||
|
|||||||
@ -57,6 +57,11 @@ export interface Vip98kCatalogModel {
|
|||||||
pricing?: unknown;
|
pricing?: unknown;
|
||||||
price?: unknown;
|
price?: unknown;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
available?: boolean;
|
||||||
|
isAvailable?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
invocation?: Vip98kInvocationContract;
|
invocation?: Vip98kInvocationContract;
|
||||||
taskTypes?: unknown;
|
taskTypes?: unknown;
|
||||||
task_types?: unknown;
|
task_types?: unknown;
|
||||||
@ -300,6 +305,95 @@ function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
|||||||
return catalogType;
|
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 {
|
function normalizeImageSize(value: string): string | null {
|
||||||
const normalized = value.trim().toUpperCase();
|
const normalized = value.trim().toUpperCase();
|
||||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||||||
@ -767,7 +861,7 @@ function createToolPreset(
|
|||||||
export function buildVip98kModelPresets(
|
export function buildVip98kModelPresets(
|
||||||
models: Vip98kCatalogModel[],
|
models: Vip98kCatalogModel[],
|
||||||
): Vip98kModelPreset[] {
|
): Vip98kModelPreset[] {
|
||||||
return models
|
return filterVip98kCatalogModels(models)
|
||||||
.map((model, index) => {
|
.map((model, index) => {
|
||||||
const modelId = getModelId(model);
|
const modelId = getModelId(model);
|
||||||
if (!modelId) return null;
|
if (!modelId) return null;
|
||||||
|
|||||||
67
lib/ai/vip98k-task-invocation.ts
Normal file
67
lib/ai/vip98k-task-invocation.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -46,12 +46,14 @@ export default defineConfig({
|
|||||||
/routes\/video-mark-prompt\.test\.ts/,
|
/routes\/video-mark-prompt\.test\.ts/,
|
||||||
/routes\/video-subtitle-erase-tools\.test\.ts/,
|
/routes\/video-subtitle-erase-tools\.test\.ts/,
|
||||||
/routes\/home-sidebar-auth\.test\.ts/,
|
/routes\/home-sidebar-auth\.test\.ts/,
|
||||||
|
/routes\/model-classification\.test\.ts/,
|
||||||
/routes\/openclaw-client-errors\.test\.ts/,
|
/routes\/openclaw-client-errors\.test\.ts/,
|
||||||
/routes\/openclaw-config\.test\.ts/,
|
/routes\/openclaw-config\.test\.ts/,
|
||||||
/routes\/openclaw-durable-task-state\.test\.ts/,
|
/routes\/openclaw-durable-task-state\.test\.ts/,
|
||||||
/routes\/openclaw-request-state\.test\.ts/,
|
/routes\/openclaw-request-state\.test\.ts/,
|
||||||
/routes\/openclaw-video-attachments\.test\.ts/,
|
/routes\/openclaw-video-attachments\.test\.ts/,
|
||||||
/routes\/openapi-access-key\.test\.ts/,
|
/routes\/openapi-access-key\.test\.ts/,
|
||||||
|
/routes\/vip98k-reference-routing\.test\.ts/,
|
||||||
],
|
],
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
|||||||
@ -9,7 +9,11 @@ import {
|
|||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
} from "@/lib/ai/model-classification";
|
} from "@/lib/ai/model-classification";
|
||||||
import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
|
import {
|
||||||
|
buildVip98kModelPresets,
|
||||||
|
isVip98kCatalogModelEligible,
|
||||||
|
isVip98kImageModelVisible,
|
||||||
|
} from "@/lib/ai/vip98k-models";
|
||||||
|
|
||||||
const atlasHunyuan3D = {
|
const atlasHunyuan3D = {
|
||||||
model: "tencent/hunyuan3d-pro/text-to-3d",
|
model: "tencent/hunyuan3d-pro/text-to-3d",
|
||||||
@ -196,7 +200,8 @@ test.describe("AI model media classification", () => {
|
|||||||
modelId: "mislabelled-video",
|
modelId: "mislabelled-video",
|
||||||
displayName: "Mislabelled video",
|
displayName: "Mislabelled video",
|
||||||
type: "image",
|
type: "image",
|
||||||
capabilities: ["text_to_video"],
|
capabilities: ["text_to_video", "image_to_video"],
|
||||||
|
inputModes: ["text", "image"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
modelId: "enhance",
|
modelId: "enhance",
|
||||||
@ -264,7 +269,7 @@ test.describe("AI model media classification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("only shows VIP98K image models verified as released in 2026 or later", () => {
|
test("only shows VIP98K image models verified as released in 2026 or later", () => {
|
||||||
const presets = buildVip98kModelPresets([
|
const models = [
|
||||||
{
|
{
|
||||||
modelId: "black-forest-labs/flux-dev",
|
modelId: "black-forest-labs/flux-dev",
|
||||||
displayName: "Flux Dev",
|
displayName: "Flux Dev",
|
||||||
@ -291,13 +296,60 @@ test.describe("AI model media classification", () => {
|
|||||||
capabilities: ["text_to_image"],
|
capabilities: ["text_to_image"],
|
||||||
releasedAt: "2025-12-15",
|
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(
|
expect(
|
||||||
presets.find((item) => item.modelId === "google/nano-banana-2/text-to-image")?.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);
|
).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
62
tests/routes/vip98k-reference-routing.test.ts
Normal file
62
tests/routes/vip98k-reference-routing.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user