diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 9560cdd7a..bd5fd50b7 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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) { diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 6fc9c19f7..ded8532e4 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -16,6 +16,7 @@ export interface Vip98kInvocationContract { path?: string; content_type?: string; request_example?: Record; + input_schema?: Record; 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"] diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 5fc4bcbfb..b9f651571 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -4,12 +4,74 @@ export type Vip98kTaskInvocation = { path: string; contentType: string; requestExample: Record; + inputSchema?: Record | null; resultMethod: string; resultPath: string; }; type KnownImageInput = "images" | "video_clips"; +const VIP98K_IMAGE_SIZE_LONGEST_EDGE: Record = { + "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, ); diff --git a/scripts/test-vip98k-image-models.ts b/scripts/test-vip98k-image-models.ts index de5313a88..29666540f 100644 --- a/scripts/test-vip98k-image-models.ts +++ b/scripts/test-vip98k-image-models.ts @@ -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(), diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index de58a98b6..a1a5d6ea6 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -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 }) + ?.invocations?.[1]?.inputSchema as Record + )?.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"); }); });