From 434d0a63cd66a8cd1a552578175ea8df92754135 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 7 Sep 2026 11:16:45 +0800 Subject: [PATCH] fix: keep verified vip98k image models available --- lib/ai/vip98k-models.ts | 1 + scripts/configure-image-provider-allowlist.ts | 42 ++++++++++--- scripts/test-vip98k-image-models.ts | 62 ++++++++++++++++--- tests/routes/vip98k-reference-routing.test.ts | 14 ++++- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index cccaeb74e..37133d6d8 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -181,6 +181,7 @@ const IMAGE_TOOL_OPERATION_BY_MODEL_ID: Record = { // available to backend routing but remain hidden until their 2026+ release is verified. const VERIFIED_2026_IMAGE_MODEL_PATTERNS = [ /^nano-banana-2(?:$|[-/])/, + /^nano-banana-pro$/, /^gpt-image-2(?:$|[-/])/, /^alibaba\/wan-2\.(?:6|7)(?:$|[-/])/, /^black-forest-labs\/flux-2(?:$|[-/])/, diff --git a/scripts/configure-image-provider-allowlist.ts b/scripts/configure-image-provider-allowlist.ts index 745492d15..e98b5a691 100644 --- a/scripts/configure-image-provider-allowlist.ts +++ b/scripts/configure-image-provider-allowlist.ts @@ -60,6 +60,27 @@ async function main() { } const vip98kProvider = vip98kProviders[0]; if (!vip98kProvider) throw new Error("VIP98K provider is missing"); + const existingVip98kModels = await sql< + Array<{ + id: string; + modelId: string; + healthStatus: string | null; + }> + >` + select id, "modelId", "healthStatus" + from "AiModel" + where "providerId" = ${vip98kProvider.id}::uuid + `; + const existingVip98kByModelId = new Map( + existingVip98kModels.map((model) => [model.modelId, model]), + ); + const autoDisabledQualifiedModelIds = qualifiedVip98kPresets + .filter( + (preset) => + existingVip98kByModelId.get(preset.modelId)?.healthStatus === + "auto_disabled", + ) + .map((preset) => preset.modelId); const disallowedImageModels = await sql< Array<{ id: string; providerType: string; modelId: string }> @@ -102,6 +123,7 @@ async function main() { mode: apply ? "apply" : "dry-run", allowedImageProviderTypes: ALLOWED_IMAGE_PROVIDER_TYPES, qualifiedVip98kModels: qualifiedVip98kPresets.map((preset) => preset.modelId), + autoDisabledVip98kModelsPreserved: autoDisabledQualifiedModelIds, disallowedImageModelsToDisable: disallowedImageModels.length, staleVip98kImageModelsToDisable: staleVip98kImageModels.length, imageOnlyProvidersToDisable: imageOnlyProviders.map((provider) => ({ @@ -128,26 +150,26 @@ async function main() { `; for (const preset of qualifiedVip98kPresets) { + const preserveAutoDisabled = autoDisabledQualifiedModelIds.includes( + preset.modelId, + ); const capabilities = tx.json(toJsonValue(preset.capabilities)); const defaultParams = tx.json(toJsonValue(preset.defaultParams)); const imageGenConfig = preset.imageGenConfig ? tx.json(toJsonValue(preset.imageGenConfig)) : null; - const existing = await tx>` - select id from "AiModel" - where "providerId" = ${vip98kProvider.id}::uuid and "modelId" = ${preset.modelId} - order by "createdAt" limit 1 - `; - if (existing[0]) { + const existing = existingVip98kByModelId.get(preset.modelId); + if (existing) { await tx` update "AiModel" set "displayName" = ${preset.displayName}, description = ${preset.description}, "supportsVision" = ${preset.supportsVision}, "supportsImageGen" = true, - "isEnabled" = true, "isHidden" = false, "sortOrder" = ${preset.sortOrder}, + "isEnabled" = ${!preserveAutoDisabled}, "isHidden" = false, + "sortOrder" = ${preset.sortOrder}, capabilities = ${capabilities}, "defaultParams" = ${defaultParams}, "imageGenConfig" = ${imageGenConfig}, "fallbackModelId1" = null, "fallbackModelId2" = null, "updatedAt" = now() - where id = ${existing[0].id}::uuid + where id = ${existing.id}::uuid `; } else { await tx` @@ -205,7 +227,9 @@ async function main() { if (invalidModels.length > 0) { throw new Error("Disallowed image models remain enabled after apply"); } - if (activeVip98kModels.length !== qualifiedVip98kPresets.length) { + const expectedActiveVip98kCount = + qualifiedVip98kPresets.length - autoDisabledQualifiedModelIds.length; + if (activeVip98kModels.length !== expectedActiveVip98kCount) { throw new Error("VIP98K active image model count does not match qualified catalog"); } console.log( diff --git a/scripts/test-vip98k-image-models.ts b/scripts/test-vip98k-image-models.ts index d5bca9881..de5313a88 100644 --- a/scripts/test-vip98k-image-models.ts +++ b/scripts/test-vip98k-image-models.ts @@ -11,10 +11,35 @@ const BASE_URL = String(process.env.VIP98K_BASE_URL || "https://api.vip98k.com") .trim() .replace(/\/+$/, "") .replace(/\/v1$/i, ""); -const REFERENCE_URLS = [ +const DEFAULT_REFERENCE_URLS = [ "https://dummyimage.com/512x512/2563eb/ffffff.png&text=BLUE+A", "https://dummyimage.com/512x512/dc2626/ffffff.png&text=RED+B", ]; +const REFERENCE_URLS = (() => { + const configured = String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim(); + if (!configured) return DEFAULT_REFERENCE_URLS; + const urls = configured + .split(/\r?\n/) + .map((url) => url.trim()) + .filter((url) => /^https?:\/\//i.test(url)); + if (urls.length < 2) { + throw new Error("VIP98K_IMAGE_TEST_REFERENCES must contain at least two URLs"); + } + return urls; +})(); +const USES_CUSTOM_REFERENCES = Boolean( + String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim(), +); +const MODEL_FILTER = new Set( + String(process.env.VIP98K_IMAGE_TEST_MODELS || "") + .split(",") + .map((modelId) => modelId.trim()) + .filter(Boolean), +); +const TEST_PROMPT = + String(process.env.VIP98K_IMAGE_TEST_PROMPT || "").trim() || + "Use BOTH references. Place the blue A card on the left and the red B card on the right. " + + "Preserve both labels and colors in a landscape 16:9 composition at 1024x576."; const TERMINAL_STATUSES = new Set(["succeeded", "success", "completed", "done"]); const FAILURE_STATUSES = new Set(["failed", "error", "timeout", "canceled", "cancelled"]); @@ -162,12 +187,20 @@ async function pollTask(apiKey: string, taskId: string, resultPath: string) { throw new Error("VIP98K task did not finish within 15 minutes"); } -async function analyzeImage(url: string) { +async function analyzeImage(url: string, modelId: string) { const response = await fetch(url, { signal: AbortSignal.timeout(120_000) }); if (!response.ok) throw new Error(`Generated image download failed (HTTP ${response.status})`); const buffer = Buffer.from(await response.arrayBuffer()); const image = sharp(buffer).removeAlpha(); const metadata = await image.metadata(); + const outputPath = resolve( + ".local", + `vip98k-${modelId.replace(/[^a-z0-9._-]+/gi, "-")}-latest.${ + metadata.format === "jpeg" ? "jpg" : metadata.format || "png" + }`, + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, buffer); const { data, info } = await image.resize(256, 256, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true, }); @@ -188,6 +221,7 @@ async function analyzeImage(url: string) { blueShare: Number((bluePixels / pixels).toFixed(4)), redShare: Number((redPixels / pixels).toFixed(4)), bothReferencesDetected: bluePixels / pixels > 0.03 && redPixels / pixels > 0.03, + outputPath, sha256: createHash("sha256").update(buffer).digest("hex"), }; } @@ -216,9 +250,7 @@ async function testModel(apiKey: string, model: JsonRecord) { const body = { ...asRecord(invocation.request_example) }; body.model = modelId; body.file_urls = REFERENCE_URLS; - body.prompt = - "Use BOTH references. Place the blue A card on the left and the red B card on the right. " + - "Preserve both labels and colors in a landscape 16:9 composition at 1024x576."; + body.prompt = TEST_PROMPT; const submitted = await readJson( await fetch(`${BASE_URL}${invocation.path}`, { method: String(invocation.method || "POST").toUpperCase(), @@ -238,11 +270,13 @@ async function testModel(apiKey: string, model: JsonRecord) { ); if (!resultPath) throw new Error(`${modelId} publishes no result path`); const completed = await pollTask(apiKey, taskId, resultPath); - const image = await analyzeImage(completed.outputUrl); + const image = await analyzeImage(completed.outputUrl, modelId); + const passed = USES_CUSTOM_REFERENCES || image.bothReferencesDetected; return { modelId, - status: image.bothReferencesDetected ? "passed" : "failed", - eligibleForMultiImageEditing: image.bothReferencesDetected, + status: passed ? "passed" : "failed", + eligibleForMultiImageEditing: passed, + referenceVerification: USES_CUSTOM_REFERENCES ? "manual" : "color-marker", publishedRequestKeys: keys.sort(), requested: { referenceImageCount: 2, aspectRatio: "16:9", size: "1024x576" }, nativeParameterSupport: { aspectRatio: nativeAspectRatio, size: nativeSize }, @@ -254,7 +288,14 @@ async function testModel(apiKey: string, model: JsonRecord) { async function main() { const apiKey = String(process.env.VIP98K_API_KEY || "").trim(); if (!apiKey) throw new Error("VIP98K_API_KEY is required"); - const models = await fetchCatalog(apiKey); + const authorizedModels = await fetchCatalog(apiKey); + const models = + MODEL_FILTER.size > 0 + ? authorizedModels.filter((model: JsonRecord) => + MODEL_FILTER.has(String(model.id || "")), + ) + : authorizedModels; + if (models.length === 0) throw new Error("No authorized VIP98K image models matched"); const results: unknown[] = []; for (const model of models) { try { @@ -271,7 +312,8 @@ async function main() { const report = { generatedAt: new Date().toISOString(), baseUrl: BASE_URL, - authorizedImageModelCount: models.length, + authorizedImageModelCount: authorizedModels.length, + testedImageModelCount: models.length, results, }; const outputPath = resolve( diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index bb42e16e7..274f76c8a 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -5,7 +5,10 @@ import { selectVip98kTaskInvocation, type Vip98kTaskInvocation, } from "@/lib/ai/vip98k-task-invocation"; -import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models"; +import { + buildVip98kModelPresets, + isVip98kImageModelVisible, +} from "@/lib/ai/vip98k-models"; import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; const textToImageInvocation: Vip98kTaskInvocation = { @@ -20,6 +23,15 @@ const textToImageInvocation: Vip98kTaskInvocation = { }; test.describe("VIP98K reference-image routing", () => { + test("shows the newly published Nano Banana Pro image editor", () => { + expect( + isVip98kImageModelVisible({ + modelId: "nano-banana-pro", + type: "image", + }), + ).toBe(true); + }); + test("replaces a text-to-image alias invocation with the resolved reference tool", () => { const invocation = resolveVip98kTaskInvocation( "google/nano-banana-2/reference-to-image",