diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index bd5fd50b7..39df4a7b1 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -832,31 +832,47 @@ async function submitTask(params: { const baseURL = normalizeVip98kBaseUrl(params.baseURL); const path = String(params.path || "/api/external/task/generate").trim(); if (!path.startsWith("/")) throw new Error("VIP98K invocation path is invalid"); - const stagedReferences = Array.isArray(params.body.file_urls) - ? params.body.file_urls.filter( - (value): value is string => - typeof value === "string" && isStagedReferenceUrl(value), - ) - : []; + const stagedReferenceKeys = [ + "file_urls", + "images", + "image", + "imageUrls", + "referenceImages", + "reference_images", + ] as const; + const stagedReferences = stagedReferenceKeys.flatMap((key) => { + const value = params.body[key]; + const candidates = Array.isArray(value) ? value : [value]; + return candidates.filter( + (candidate): candidate is string => + typeof candidate === "string" && isStagedReferenceUrl(candidate), + ); + }); const maxAttempts = MAX_SUBMISSION_ATTEMPTS; let refreshStagedReferences = false; let idempotencyKey = randomUUID(); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const requestBody = - !refreshStagedReferences || !Array.isArray(params.body.file_urls) - ? params.body - : { - ...params.body, - file_urls: params.body.file_urls.map((value) => - typeof value === "string" && isStagedReferenceUrl(value) + const requestBody = refreshStagedReferences + ? Object.fromEntries( + Object.entries(params.body).map(([key, value]) => { + if (!stagedReferenceKeys.includes(key as (typeof stagedReferenceKeys)[number])) { + return [key, value]; + } + const addRetryToken = (candidate: unknown) => + typeof candidate === "string" && isStagedReferenceUrl(candidate) ? addVip98kReferenceRetryToken( - value, + candidate, `${Date.now()}-${attempt}`, ) - : value, - ), - }; + : candidate; + return [ + key, + Array.isArray(value) ? value.map(addRetryToken) : addRetryToken(value), + ]; + }), + ) + : params.body; let response: Response; try { response = await fetch(`${baseURL}${path}`, { diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index ded8532e4..5cf231de7 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -328,10 +328,18 @@ function getInvocationRequestKeys(model: Vip98kCatalogModel) { return [ ...Object.keys(toRecord(invocation.request_example) || {}), ...Object.keys(schemaProperties || {}), - ].map((key) => key.trim().toLowerCase().replace(/_/g, "-")); + ].map(normalizeInvocationRequestKey); }); } +function normalizeInvocationRequestKey(key: string) { + return key + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase() + .replace(/_/g, "-"); +} + function getInvocationCandidates(model: Vip98kCatalogModel) { const configured = Array.isArray(model.invocations) ? model.invocations : []; const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])]; @@ -411,7 +419,27 @@ function hasMultiImageReferenceInput(model: Vip98kCatalogModel) { "reference-images", "video-clips", ]); - return getInvocationRequestKeys(model).some((key) => multiImageKeys.has(key)); + return getInvocationCandidates(model).some((invocation) => { + const requestExample = toRecord(invocation.request_example) || {}; + const schemaProperties = + toRecord(toRecord(invocation.input_schema)?.properties) || {}; + const keys = new Set([ + ...Object.keys(requestExample), + ...Object.keys(schemaProperties), + ]); + + return Array.from(keys).some((key) => { + const normalizedKey = normalizeInvocationRequestKey(key); + if (normalizedKey !== "image" && !multiImageKeys.has(normalizedKey)) { + return false; + } + const schema = toRecord(schemaProperties[key]); + return ( + Array.isArray(requestExample[key]) || + String(schema?.type || "").trim().toLowerCase() === "array" + ); + }); + }); } export function isVip98kMultiImageEditCatalogModel(model: Vip98kCatalogModel) { diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index b9f651571..5b6c87a8d 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -154,7 +154,7 @@ export function bindVip98kImages( } for (const key of IMAGE_SINGLE_INPUT_KEYS) { if (!(key in body)) continue; - body[key] = imageUrls[0]; + body[key] = Array.isArray(body[key]) ? imageUrls : imageUrls[0]; bindingKeys.push(key); } if (Array.isArray(body.video_clips)) { @@ -188,6 +188,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) { let score = 0; if ("file_urls" in body) score += 60; if (IMAGE_ARRAY_INPUT_KEYS.some((key) => key in body)) score += 40; + if (IMAGE_SINGLE_INPUT_KEYS.some((key) => Array.isArray(body[key]))) score += 40; if (Array.isArray(body.video_clips)) score += 35; if (IMAGE_SINGLE_INPUT_KEYS.some((key) => key in body)) score += 15; if (invocation.kind === "task") score += 10; diff --git a/scripts/configure-image-provider-allowlist.ts b/scripts/configure-image-provider-allowlist.ts index 9b0bfb550..58ddab507 100644 --- a/scripts/configure-image-provider-allowlist.ts +++ b/scripts/configure-image-provider-allowlist.ts @@ -25,24 +25,20 @@ function isQualifiedVip98kImagePreset(preset: Vip98kModelPreset) { async function main() { const apply = hasFlag("--apply"); const databaseUrl = String(process.env.POSTGRES_URL || "").trim(); - const vip98kApiKey = String(process.env.VIP98K_API_KEY || "").trim(); if (!databaseUrl) throw new Error("POSTGRES_URL is required"); - if (!vip98kApiKey) throw new Error("VIP98K_API_KEY is required"); - - const presets = await fetchVip98kModelPresets({ - baseURL: process.env.VIP98K_BASE_URL || "https://api.vip98k.com", - apiKey: vip98kApiKey, - }); - const qualifiedVip98kPresets = presets.filter(isQualifiedVip98kImagePreset); - if (qualifiedVip98kPresets.length === 0) { - throw new Error("VIP98K catalog contains no qualified image model"); - } const sql = postgres(databaseUrl, { prepare: false, max: 1 }); try { const providers = await sql< - Array<{ id: string; type: string; name: string; isEnabled: boolean }> - >`select id, type, name, "isEnabled" from "AiProvider" order by "createdAt"`; + Array<{ + id: string; + type: string; + name: string; + apiKey: string | null; + baseUrl: string | null; + isEnabled: boolean; + }> + >`select id, type, name, "apiKey", "baseUrl", "isEnabled" from "AiProvider" order by "createdAt"`; const vip98kProviders = providers.filter((provider) => provider.type === "vip98k"); const alibabaProviders = providers.filter( (provider) => provider.type === "alibaba-image", @@ -55,6 +51,23 @@ async function main() { } const vip98kProvider = vip98kProviders[0]; if (!vip98kProvider) throw new Error("VIP98K provider is missing"); + const vip98kApiKey = String( + process.env.VIP98K_API_KEY || vip98kProvider.apiKey || "", + ).trim(); + if (!vip98kApiKey) throw new Error("VIP98K API key is not configured"); + const vip98kBaseUrl = String( + process.env.VIP98K_BASE_URL || + vip98kProvider.baseUrl || + "https://api.vip98k.com", + ).trim(); + const presets = await fetchVip98kModelPresets({ + baseURL: vip98kBaseUrl, + apiKey: vip98kApiKey, + }); + const qualifiedVip98kPresets = presets.filter(isQualifiedVip98kImagePreset); + if (qualifiedVip98kPresets.length === 0) { + throw new Error("VIP98K catalog contains no qualified image model"); + } const existingVip98kModels = await sql< Array<{ id: string; diff --git a/scripts/test-vip98k-image-models.ts b/scripts/test-vip98k-image-models.ts index 29666540f..9f2fd03a3 100644 --- a/scripts/test-vip98k-image-models.ts +++ b/scripts/test-vip98k-image-models.ts @@ -85,10 +85,41 @@ function requestKeys(invocation: Invocation) { ); } +function getMultiImageInputKey(invocation: Invocation) { + const requestExample = asRecord(invocation.request_example); + const properties = asRecord(asRecord(invocation.input_schema).properties); + const keys = new Set([ + ...Object.keys(requestExample), + ...Object.keys(properties), + ]); + + for (const key of keys) { + const normalized = key + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); + if ( + ![ + "file_urls", + "images", + "image", + "image_urls", + "reference_images", + ].includes(normalized) + ) { + continue; + } + if ( + Array.isArray(requestExample[key]) || + String(asRecord(properties[key]).type || "").toLowerCase() === "array" + ) { + return key; + } + } + return null; +} + function supportsMultipleImages(invocation: Invocation) { - return requestKeys(invocation).some((key) => - ["file_urls", "images", "image_urls", "reference_images"].includes(key), - ); + return Boolean(getMultiImageInputKey(invocation)); } function extractStatus(payload: unknown) { @@ -248,6 +279,10 @@ async function testModel(apiKey: string, model: JsonRecord) { reason: "The published V2 contract exposes text-to-image only", }; } + const multiImageInputKey = getMultiImageInputKey(invocation); + if (!multiImageInputKey) { + throw new Error(`${modelId} publishes no multi-image input field`); + } const keys = requestKeys(invocation); const nativeAspectRatio = keys.some((key) => ["ratio", "aspect_ratio"].includes(key)); @@ -256,7 +291,7 @@ async function testModel(apiKey: string, model: JsonRecord) { ); const body = { ...asRecord(invocation.request_example) }; body.model = modelId; - body.file_urls = REFERENCE_URLS; + body[multiImageInputKey] = REFERENCE_URLS; body.prompt = TEST_PROMPT; if (keys.includes("aspect_ratio")) body.aspect_ratio = "16:9"; if (keys.includes("resolution")) body.resolution = "1K"; diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index a1a5d6ea6..97b2a4b1d 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -257,6 +257,25 @@ test.describe("VIP98K reference-image routing", () => { ]); }); + test("binds every reference image to an array-shaped image field", () => { + const body: Record = { + image: ["https://api.vip98k.com/logo.png"], + model: "gpt-image-2-vip", + prompt: "Edit", + }; + + expect( + bindVip98kImages(body, [ + "https://example.com/a.png", + "https://example.com/b.png", + ]), + ).toEqual(["image"]); + expect(body.image).toEqual([ + "https://example.com/a.png", + "https://example.com/b.png", + ]); + }); + test("keeps V2 processing-task contracts instead of rewriting them to retired routes", () => { const invocation: Vip98kTaskInvocation = { kind: "task", @@ -399,6 +418,43 @@ test.describe("VIP98K reference-image routing", () => { expect(presets).toEqual([]); }); + test("keeps gpt-image-2-vip when its published image field is an array", () => { + const presets = buildVip98kModelPresets([ + { + id: "gpt-image-2-vip", + type: "image", + capabilities: [ + "image_to_image", + "openai.images.generations", + "text_to_image", + ], + invocation: { + kind: "tool", + method: "POST", + path: "/v1/images/generations", + content_type: "application/json", + request_example: { + image: ["https://api.vip98k.com/logo.png"], + model: "gpt-image-2-vip", + n: 1, + prompt: "Generate", + }, + }, + }, + ]); + + expect(presets).toHaveLength(1); + expect(presets[0]).toMatchObject({ + modelId: "gpt-image-2-vip", + supportsVision: true, + isHidden: false, + capabilities: { + supportsMultiImageReference: true, + maxReferenceImages: 8, + }, + }); + }); + test("does not inherit multi-image support from broad authorized metadata", () => { const presets = buildVip98kModelPresets([ {