fix: keep verified vip98k image models available

This commit is contained in:
134 2026-09-07 11:16:45 +08:00
parent 608bd25679
commit 434d0a63cd
4 changed files with 99 additions and 20 deletions

View File

@ -181,6 +181,7 @@ const IMAGE_TOOL_OPERATION_BY_MODEL_ID: Record<string, string> = {
// 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(?:$|[-/])/,

View File

@ -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<Array<{ id: string }>>`
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(

View File

@ -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(

View File

@ -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",