fix: enforce vip98k multi-image contracts

This commit is contained in:
134 2026-09-07 18:16:45 +08:00
parent 2dbb14bb6b
commit dffea4cdb3
5 changed files with 197 additions and 52 deletions

View File

@ -7,6 +7,7 @@ import sharp from "sharp";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import {
bindVip98kImages,
resolveVip98kImageTaskSize,
selectVip98kTaskInvocation,
type Vip98kTaskInvocation,
} from "@/lib/ai/vip98k-task-invocation";
@ -139,6 +140,8 @@ function parseVip98kInvocation(value: unknown): Vip98kInvocation | null {
path,
contentType: String(invocation.contentType || "application/json").trim(),
requestExample: { ...(toRecord(invocation.requestExample) || {}) },
inputSchema:
toRecord(invocation.inputSchema) || toRecord(invocation.input_schema),
resultMethod: String(invocation.resultMethod || "GET").trim().toUpperCase(),
resultPath: String(invocation.resultPath || "").trim(),
};
@ -156,11 +159,23 @@ function getVip98kInvocations(config: Vip98kModelRuntimeConfig) {
(item) =>
item.method === candidate.method &&
item.path === candidate.path &&
JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample),
JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample) &&
JSON.stringify(item.inputSchema || null) ===
JSON.stringify(candidate.inputSchema || null),
) === index,
);
}
function invocationSupportsBodyField(
invocation: Vip98kInvocation | null,
key: string,
) {
if (!invocation) return true;
if (key in invocation.requestExample) return true;
const properties = toRecord(toRecord(invocation.inputSchema)?.properties);
return Boolean(properties && key in properties);
}
function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) {
const capabilities = config.capabilities;
const metadata = getVip98kMetadata(config);
@ -1133,22 +1148,24 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
normalizeRatio(videoParams.aspectRatio) ||
normalizeRatio(imageParams.aspectRatio);
const duration = normalizeDuration(videoParams.duration);
const resolution = String(videoParams.resolution || imageParams.imageSize || "").trim();
const resolution = String(
videoParams.resolution || imageParams.size || imageParams.imageSize || "",
).trim();
const negativePrompt = String(videoParams.negativePrompt || imageParams.negativePrompt || "").trim();
const outputConstraintFallbacks: string[] = [];
let hasNativeAspectRatio = false;
let hasNativeResolution = false;
if (aspectRatio) {
if ("ratio" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "ratio")) {
body.ratio = aspectRatio;
hasNativeAspectRatio = true;
}
if ("aspectRatio" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "aspectRatio")) {
body.aspectRatio = aspectRatio;
hasNativeAspectRatio = true;
}
if ("aspect_ratio" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "aspect_ratio")) {
body.aspect_ratio = aspectRatio;
hasNativeAspectRatio = true;
}
@ -1156,21 +1173,33 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
if (duration && ("duration" in body || !invocation)) body.duration = duration;
if (duration && "duration_seconds" in body) body.duration_seconds = duration;
if (resolution) {
if ("resolution" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "resolution")) {
body.resolution = resolution;
hasNativeResolution = true;
}
if ("imageSize" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "imageSize")) {
body.imageSize = resolution;
hasNativeResolution = true;
}
if ("image_size" in body || !invocation) {
if (invocationSupportsBodyField(invocation, "image_size")) {
body.image_size = resolution;
hasNativeResolution = true;
}
if ("size" in body && typeof body.size === "string") {
body.size = resolution;
hasNativeResolution = true;
if (
invocationSupportsBodyField(invocation, "size") &&
typeof body.size === "string" &&
!invocationSupportsBodyField(invocation, "resolution")
) {
const resolvedTaskSize = resolveVip98kImageTaskSize({
imageSize: resolution,
aspectRatio,
fallbackSize: body.size,
});
if (resolvedTaskSize) {
body.size = resolvedTaskSize;
hasNativeResolution = resolvedTaskSize !== "auto";
hasNativeAspectRatio = Boolean(aspectRatio) && resolvedTaskSize !== "auto";
}
}
}
if (negativePrompt) {

View File

@ -16,6 +16,7 @@ export interface Vip98kInvocationContract {
path?: string;
content_type?: string;
request_example?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
result_method?: string;
result_path?: string;
}
@ -322,11 +323,13 @@ const UNAVAILABLE_STATUS_TOKENS = [
] as const;
function getInvocationRequestKeys(model: Vip98kCatalogModel) {
return getInvocationCandidates(model).flatMap((invocation) =>
Object.keys(toRecord(invocation.request_example) || {}).map((key) =>
key.trim().toLowerCase().replace(/_/g, "-"),
),
);
return getInvocationCandidates(model).flatMap((invocation) => {
const schemaProperties = toRecord(toRecord(invocation.input_schema)?.properties);
return [
...Object.keys(toRecord(invocation.request_example) || {}),
...Object.keys(schemaProperties || {}),
].map((key) => key.trim().toLowerCase().replace(/_/g, "-"));
});
}
function getInvocationCandidates(model: Vip98kCatalogModel) {
@ -340,7 +343,9 @@ function getInvocationCandidates(model: Vip98kCatalogModel) {
String(item?.method || "POST") === String(candidate?.method || "POST") &&
String(item?.path || "") === String(candidate?.path || "") &&
JSON.stringify(item?.request_example || {}) ===
JSON.stringify(candidate?.request_example || {}),
JSON.stringify(candidate?.request_example || {}) &&
JSON.stringify(item?.input_schema || null) ===
JSON.stringify(candidate?.input_schema || null),
) === index,
);
}
@ -409,6 +414,14 @@ function hasMultiImageReferenceInput(model: Vip98kCatalogModel) {
return getInvocationRequestKeys(model).some((key) => multiImageKeys.has(key));
}
export function isVip98kMultiImageEditCatalogModel(model: Vip98kCatalogModel) {
return (
resolvePresetType(model) === "image" &&
hasMediaReferenceInput(model, "image") &&
hasMultiImageReferenceInput(model)
);
}
export function isVip98kCatalogModelAvailable(model: Vip98kCatalogModel) {
const status = String(model.status || "").trim().toLowerCase();
if (UNAVAILABLE_STATUS_TOKENS.some((token) => status.includes(token))) {
@ -427,10 +440,7 @@ export function isVip98kCatalogModelEligible(model: Vip98kCatalogModel) {
if (!getModelId(model) || !isVip98kCatalogModelAvailable(model)) return false;
const type = resolvePresetType(model);
if (type === "image") {
return (
hasMediaReferenceInput(model, type) ||
isVip98kTextToImageCatalogModel(model)
);
return isVip98kMultiImageEditCatalogModel(model);
}
if (type === "video") return hasMediaReferenceInput(model, type);
return true;
@ -576,6 +586,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
path: String(value.path || ""),
contentType: String(value.content_type || "application/json"),
requestExample: toRecord(value.request_example) || {},
inputSchema: toRecord(value.input_schema),
resultMethod: String(value.result_method || "GET"),
resultPath: String(value.result_path || ""),
});
@ -592,6 +603,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
path: String(invocation.path || ""),
contentType: String(invocation.content_type || "application/json"),
requestExample: toRecord(invocation.request_example) || {},
inputSchema: toRecord(invocation.input_schema),
resultMethod: String(invocation.result_method || "GET"),
resultPath: String(invocation.result_path || ""),
}
@ -635,7 +647,7 @@ function createImagePreset(
const supportsNativeAspectRatio = [
"ratio",
"aspect-ratio",
].some((key) => invocationKeys.has(key));
].some((key) => invocationKeys.has(key)) || supportsNativeSize;
const supportedModes = [
...(capabilities.includes("text-to-image") || capabilities.length === 0
? ["text-to-image"]

View File

@ -4,12 +4,74 @@ export type Vip98kTaskInvocation = {
path: string;
contentType: string;
requestExample: Record<string, unknown>;
inputSchema?: Record<string, unknown> | null;
resultMethod: string;
resultPath: string;
};
type KnownImageInput = "images" | "video_clips";
const VIP98K_IMAGE_SIZE_LONGEST_EDGE: Record<string, number> = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
};
function normalizeVip98kPixelSize(value: unknown) {
const match = String(value || "")
.trim()
.match(/^(\d+)\s*[x*×]\s*(\d+)$/i);
if (!match?.[1] || !match[2]) return null;
const sourceWidth = Number(match[1]);
const sourceHeight = Number(match[2]);
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth < 1 ||
sourceHeight < 1
) {
return null;
}
const scale = Math.min(1, 4096 / Math.max(sourceWidth, sourceHeight));
return `${Math.max(1, Math.round(sourceWidth * scale))}x${Math.max(
1,
Math.round(sourceHeight * scale),
)}`;
}
export function resolveVip98kImageTaskSize(params: {
imageSize?: unknown;
aspectRatio?: unknown;
fallbackSize?: unknown;
}) {
const explicitSize = normalizeVip98kPixelSize(params.imageSize);
if (explicitSize) return explicitSize;
const ratioMatch = String(params.aspectRatio || "")
.trim()
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket];
if (ratioMatch?.[1] && ratioMatch[2] && longestEdge) {
const ratioWidth = Number(ratioMatch[1]);
const ratioHeight = Number(ratioMatch[2]);
if (ratioWidth > 0 && ratioHeight > 0) {
const scale = longestEdge / Math.max(ratioWidth, ratioHeight);
const roundToEight = (value: number) =>
Math.max(8, Math.min(4096, Math.round(value / 8) * 8));
return `${roundToEight(ratioWidth * scale)}x${roundToEight(
ratioHeight * scale,
)}`;
}
}
const fallbackPixelSize = normalizeVip98kPixelSize(params.fallbackSize);
if (fallbackPixelSize) return fallbackPixelSize;
return String(params.fallbackSize || "").trim().toLowerCase() === "auto"
? "auto"
: undefined;
}
const IMAGE_ARRAY_INPUT_KEYS = [
"file_urls",
"images",
@ -149,7 +211,9 @@ export function selectVip98kTaskInvocation(params: {
(item) =>
item.method === candidate.method &&
item.path === candidate.path &&
JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample),
JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample) &&
JSON.stringify(item.inputSchema || null) ===
JSON.stringify(candidate.inputSchema || null),
) === index,
);

View File

@ -49,6 +49,7 @@ type Invocation = {
path?: string;
content_type?: string;
request_example?: JsonRecord;
input_schema?: JsonRecord;
result_path?: string;
};
@ -75,7 +76,13 @@ async function readJson(response: Response) {
}
function requestKeys(invocation: Invocation) {
return Object.keys(asRecord(invocation.request_example)).map((key) => key.toLowerCase());
const properties = asRecord(asRecord(invocation.input_schema).properties);
return Array.from(
new Set([
...Object.keys(asRecord(invocation.request_example)),
...Object.keys(properties),
].map((key) => key.toLowerCase())),
);
}
function supportsMultipleImages(invocation: Invocation) {
@ -251,6 +258,9 @@ async function testModel(apiKey: string, model: JsonRecord) {
body.model = modelId;
body.file_urls = REFERENCE_URLS;
body.prompt = TEST_PROMPT;
if (keys.includes("aspect_ratio")) body.aspect_ratio = "16:9";
if (keys.includes("resolution")) body.resolution = "1K";
if (keys.includes("size")) body.size = "auto";
const submitted = await readJson(
await fetch(`${BASE_URL}${invocation.path}`, {
method: String(invocation.method || "POST").toUpperCase(),

View File

@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import {
bindVip98kImages,
resolveVip98kTaskInvocation,
resolveVip98kImageTaskSize,
selectVip98kTaskInvocation,
type Vip98kTaskInvocation,
} from "@/lib/ai/vip98k-task-invocation";
@ -304,6 +305,14 @@ test.describe("VIP98K reference-image routing", () => {
model: "nano-banana-2",
prompt: "Edit",
},
input_schema: {
properties: {
aspect_ratio: { type: "string" },
file_urls: { type: "array", maxItems: 8 },
resolution: { type: "string" },
size: { type: "string", enum: ["auto", "1024x1024"] },
},
},
result_method: "GET",
result_path: "/extensions/v1/tasks/{task_id}/result",
},
@ -317,18 +326,34 @@ test.describe("VIP98K reference-image routing", () => {
capabilities: {
supportsMultiImageReference: true,
maxReferenceImages: 8,
supportedImageSizes: [],
supportedAspectRatios: ["auto"],
supportedImageSizes: ["1K", "2K", "4K"],
supportedAspectRatios: [
"auto",
"1:1",
"4:3",
"3:4",
"16:9",
"9:16",
"3:2",
"2:3",
"4:5",
"5:4",
],
},
imageGenConfig: {
supportsSize: false,
supportedImageSizes: [],
supportedAspectRatios: ["auto"],
supportsSize: true,
supportedImageSizes: ["1K", "2K", "4K"],
},
});
expect(
(preset?.imageGenConfig?.vip98k as { invocations?: unknown[] })?.invocations,
).toHaveLength(2);
expect(
(
(preset?.imageGenConfig?.vip98k as { invocations?: Array<any> })
?.invocations?.[1]?.inputSchema as Record<string, unknown>
)?.properties,
).toBeTruthy();
});
test("detects V2 asset-only results that need delivery URL resolution", () => {
@ -355,8 +380,8 @@ test.describe("VIP98K reference-image routing", () => {
).toBe(false);
});
test("publishes text-only VIP98K models without reference-image support", () => {
const [preset] = buildVip98kModelPresets([
test("excludes text-only VIP98K models from the image picker", () => {
const presets = buildVip98kModelPresets([
{
id: "gpt-image-2-vip",
type: "image",
@ -371,17 +396,7 @@ test.describe("VIP98K reference-image routing", () => {
},
]);
expect(preset).toMatchObject({
modelId: "gpt-image-2-vip",
supportsVision: false,
supportsImageGen: true,
isHidden: false,
capabilities: {
supportedModes: ["text-to-image"],
supportsMultiImageReference: false,
maxReferenceImages: 0,
},
});
expect(presets).toEqual([]);
});
test("does not inherit multi-image support from broad authorized metadata", () => {
@ -405,15 +420,30 @@ test.describe("VIP98K reference-image routing", () => {
},
]);
expect(presets).toHaveLength(1);
expect(presets[0]).toMatchObject({
modelId: "nano-banana-2-lite",
supportsVision: false,
capabilities: {
supportedModes: ["text-to-image"],
supportsMultiImageReference: false,
maxReferenceImages: 0,
},
});
expect(presets).toEqual([]);
});
test("maps frontend ratio and resolution to the VIP98K V2 size contract", () => {
expect(
resolveVip98kImageTaskSize({
imageSize: "1K",
aspectRatio: "16:9",
fallbackSize: "auto",
}),
).toBe("1024x576");
expect(
resolveVip98kImageTaskSize({
imageSize: "886*1182",
aspectRatio: "3:4",
fallbackSize: "auto",
}),
).toBe("886x1182");
expect(
resolveVip98kImageTaskSize({
imageSize: "1K",
aspectRatio: "auto",
fallbackSize: "auto",
}),
).toBe("auto");
});
});