fix: enforce vip98k multi-image contracts
This commit is contained in:
parent
2dbb14bb6b
commit
dffea4cdb3
@ -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) {
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user