From fc553ffea37e9b1c0278b00eb49769aa3d0103e4 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 7 Sep 2026 00:21:03 +0800 Subject: [PATCH] fix: update vip98k image task integration --- lib/ai/providers/vip98k.ts | 243 +++++++++------ lib/ai/vip98k-models.ts | 135 +++++--- lib/ai/vip98k-result-delivery.ts | 31 ++ lib/ai/vip98k-task-invocation.ts | 142 ++++++++- package.json | 4 +- scripts/configure-image-provider-allowlist.ts | 230 ++++++++++++++ scripts/test-vip98k-capability.ts | 5 +- scripts/test-vip98k-image-models.ts | 289 ++++++++++++++++++ tests/routes/vip98k-reference-routing.test.ts | 175 +++++++++++ 9 files changed, 1123 insertions(+), 131 deletions(-) create mode 100644 lib/ai/vip98k-result-delivery.ts create mode 100644 scripts/configure-image-provider-allowlist.ts create mode 100644 scripts/test-vip98k-image-models.ts diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 1f091b83e..ba6b5c430 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -3,10 +3,12 @@ import "server-only"; import { createOpenAI } from "@ai-sdk/openai"; import sharp from "sharp"; import { - resolveVip98kTaskInvocation, + bindVip98kImages, + selectVip98kTaskInvocation, type Vip98kTaskInvocation, } from "@/lib/ai/vip98k-task-invocation"; import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models"; +import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; import { createLogger } from "@/lib/observability/logger"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; @@ -83,9 +85,8 @@ function getVip98kMetadata(config: Vip98kModelRuntimeConfig) { return capabilitiesVip98k || imageConfigVip98k || {}; } -function getVip98kInvocation(config: Vip98kModelRuntimeConfig): Vip98kInvocation | null { - const metadata = getVip98kMetadata(config); - const invocation = toRecord(metadata.invocation); +function parseVip98kInvocation(value: unknown): Vip98kInvocation | null { + const invocation = toRecord(value); if (!invocation) return null; const path = String(invocation.path || "").trim(); if (!path.startsWith("/")) return null; @@ -100,6 +101,23 @@ function getVip98kInvocation(config: Vip98kModelRuntimeConfig): Vip98kInvocation }; } +function getVip98kInvocations(config: Vip98kModelRuntimeConfig) { + const metadata = getVip98kMetadata(config); + const configured = Array.isArray(metadata.invocations) ? metadata.invocations : []; + const candidates = [metadata.invocation, ...configured] + .map(parseVip98kInvocation) + .filter((item): item is Vip98kInvocation => Boolean(item)); + return candidates.filter( + (candidate, index, items) => + items.findIndex( + (item) => + item.method === candidate.method && + item.path === candidate.path && + JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample), + ) === index, + ); +} + function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) { const capabilities = config.capabilities; const metadata = getVip98kMetadata(config); @@ -247,6 +265,8 @@ function extractDone(payload: unknown) { if (typeof record?.done === "boolean") return record.done; if (typeof record?.isDone === "boolean") return record.isDone; if (typeof record?.finished === "boolean") return record.finished; + if (typeof record?.completed === "boolean") return record.completed; + if (typeof record?.ready === "boolean") return record.ready; return false; } @@ -296,6 +316,60 @@ function collectOutputUrls(payload: unknown) { return Array.from(urls); } +async function fetchTaskResult(params: { + apiKey: string; + baseURL: string; + taskId: string; + resultUrl: string; +}) { + const response = await fetch(params.resultUrl, { + cache: "no-store", + headers: { Authorization: `Bearer ${params.apiKey}` }, + signal: AbortSignal.timeout(60_000), + }); + const payload = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`); + } + + const status = extractStatus(payload); + let outputUrls = collectOutputUrls(payload); + let deliveryPayload: unknown = null; + // V2 media tasks return opaque asset_id values from their published result path. + // The compatibility result route resolves those IDs into expiring download URLs. + if ( + shouldResolveVip98kResultDelivery({ + payload, + status, + outputUrlCount: outputUrls.length, + }) + ) { + const deliveryUrl = `${params.baseURL}/api/external/task/result?taskId=${encodeURIComponent( + params.taskId, + )}`; + const deliveryResponse = await fetch(deliveryUrl, { + cache: "no-store", + headers: { Authorization: `Bearer ${params.apiKey}` }, + signal: AbortSignal.timeout(60_000), + }); + deliveryPayload = await parseJsonResponse(deliveryResponse); + if (!deliveryResponse.ok) { + throw new Error( + `VIP98K result delivery lookup failed: ${extractErrorMessage( + deliveryPayload, + )} (HTTP ${deliveryResponse.status})`, + ); + } + outputUrls = collectOutputUrls(deliveryPayload); + } + + return { + payload: deliveryPayload ? { result: payload, delivery: deliveryPayload } : payload, + status: extractStatus(deliveryPayload) || status, + outputUrls, + }; +} + function parseDataUrl(value: string) { const match = value.match(/^data:([^;,]+);base64,(.+)$/s); if (!match) throw new Error("VIP98K returned invalid Base64 media"); @@ -518,21 +592,22 @@ export async function submitVip98kExternalTask(params: { imageParams: params.imageParams, modelConfig: params.modelConfig || {}, }; - const invocation = resolveVip98kTaskInvocation( - taskParams.modelId, - getVip98kInvocation(taskParams.modelConfig) + const configuredInvocations = getVip98kInvocations(taskParams.modelConfig); + const invocation = selectVip98kTaskInvocation({ + modelId: taskParams.modelId, + configuredInvocation: configuredInvocations[0] || null, + configuredInvocations, + imageCount: imageUrls.length, + }); + const { body, imageBindingKeys, outputConstraintFallbacks } = buildTaskBody( + taskParams, + invocation, ); - const body = buildTaskBody(taskParams, invocation); - const imageBindingKeys = [ - "images", - "imageUrls", - "referenceImages", - "reference_images", - "image", - "imageUrl", - "image_url", - "video_clips", - ].filter((key) => key in body); + if (imageUrls.length > 0 && imageBindingKeys.length === 0) { + throw new Error( + `VIP98K model does not publish a reference-image request contract: ${taskParams.modelId}`, + ); + } logger.info("task submit audit", { modelId: taskParams.modelId, kind: taskParams.kind, @@ -541,6 +616,7 @@ export async function submitVip98kExternalTask(params: { imageCount: imageUrls.length, videoCount: videoUrls.length, imageBindingKeys, + outputConstraintFallbacks, bodyKeys: Object.keys(body).sort(), }); const submitted = await submitTask({ @@ -574,17 +650,12 @@ export async function getVip98kExternalTaskStatus(params: { ? params.resultPath : `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}` : `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`; - const response = await fetch(resultUrl, { - cache: "no-store", - headers: { Authorization: `Bearer ${params.apiKey}` }, - signal: AbortSignal.timeout(60_000), + const { payload, status, outputUrls } = await fetchTaskResult({ + apiKey: params.apiKey, + baseURL, + taskId: params.taskId, + resultUrl, }); - const payload = await parseJsonResponse(response); - if (!response.ok) { - throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`); - } - const status = extractStatus(payload); - const outputUrls = collectOutputUrls(payload); const failed = ["failed", "error", "timeout", "canceled", "cancelled"].includes(status); const done = ["succeeded", "success", "completed", "done", "finished"].includes(status) || @@ -626,19 +697,13 @@ async function waitForTask(params: { ? params.resultPath : `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}` : `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`; - const response = await fetch(resultUrl, { - cache: "no-store", - headers: { Authorization: `Bearer ${params.apiKey}` }, - signal: AbortSignal.timeout(60_000), + const { payload, status, outputUrls: outputs } = await fetchTaskResult({ + apiKey: params.apiKey, + baseURL, + taskId: params.taskId, + resultUrl, }); - const payload = await parseJsonResponse(response); - if (!response.ok) { - throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`); - } - - const status = extractStatus(payload); const done = extractDone(payload); - const outputs = collectOutputUrls(payload); if ( ["succeeded", "success", "completed", "done", "finished"].includes(status) || (done && outputs.length > 0) @@ -680,8 +745,8 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | const imageParams = params.imageParams || {}; const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80); const body: Record = { ...(invocation?.requestExample || {}) }; - if (invocation?.kind !== "tool" || "model" in body) body.model = params.modelId; - if ("displayName" in body || invocation?.kind !== "tool") { + if ("model" in body || !invocation) body.model = params.modelId; + if ("displayName" in body || !invocation) { body.displayName = promptTitle || params.modelId; } if ("prompt" in body || !invocation) body.prompt = params.prompt; @@ -693,18 +758,43 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | const duration = normalizeDuration(videoParams.duration); const resolution = String(videoParams.resolution || 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) body.ratio = aspectRatio; - if ("aspectRatio" in body || !invocation) body.aspectRatio = aspectRatio; - if ("aspect_ratio" in body || !invocation) body.aspect_ratio = aspectRatio; + if ("ratio" in body || !invocation) { + body.ratio = aspectRatio; + hasNativeAspectRatio = true; + } + if ("aspectRatio" in body || !invocation) { + body.aspectRatio = aspectRatio; + hasNativeAspectRatio = true; + } + if ("aspect_ratio" in body || !invocation) { + body.aspect_ratio = aspectRatio; + hasNativeAspectRatio = true; + } } 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) body.resolution = resolution; - if ("imageSize" in body || !invocation) body.imageSize = resolution; - if ("image_size" in body || !invocation) body.image_size = resolution; - if ("size" in body && typeof body.size === "string") body.size = resolution; + if ("resolution" in body || !invocation) { + body.resolution = resolution; + hasNativeResolution = true; + } + if ("imageSize" in body || !invocation) { + body.imageSize = resolution; + hasNativeResolution = true; + } + if ("image_size" in body || !invocation) { + body.image_size = resolution; + hasNativeResolution = true; + } + if ("size" in body && typeof body.size === "string") { + body.size = resolution; + hasNativeResolution = true; + } } if (negativePrompt) { if ("negativePrompt" in body || !invocation) body.negativePrompt = negativePrompt; @@ -724,42 +814,25 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | if ("generate_audio" in body || !invocation) body.generate_audio = videoParams.generateAudio; } - if (params.imageUrls.length > 0) { - let hasImageBinding = false; - const setImageField = (key: string, value: unknown) => { - if (key in body || !invocation) { - body[key] = value; - hasImageBinding = true; - } - }; - setImageField("images", params.imageUrls); - setImageField("imageUrls", params.imageUrls); - setImageField("referenceImages", params.imageUrls); - setImageField("reference_images", params.imageUrls); - setImageField("image", params.imageUrls[0]); - setImageField("imageUrl", params.imageUrls[0]); - setImageField("image_url", params.imageUrls[0]); - setImageField("firstFrameImage", params.imageUrls[0]); - setImageField("first_frame_image", params.imageUrls[0]); - setImageField("firstFrameImageUrl", params.imageUrls[0]); - setImageField("first_frame_image_url", params.imageUrls[0]); - if (Array.isArray(body.video_clips)) { - const template = toRecord(body.video_clips[0]) || {}; - body.video_clips = params.imageUrls.map((url) => ({ ...template, url })); - hasImageBinding = true; - } - if (params.imageUrls[1]) { - setImageField("lastFrameImage", params.imageUrls[1]); - setImageField("last_frame_image", params.imageUrls[1]); - setImageField("endImageUrl", params.imageUrls[1]); - setImageField("end_image_url", params.imageUrls[1]); - } - if ( - !hasImageBinding && - (params.kind === "image" || params.kind === "image-tool") - ) { - body.images = params.imageUrls; - } + const imageBindingKeys = bindVip98kImages(body, params.imageUrls); + if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) { + body.images = params.imageUrls; + imageBindingKeys.push("images"); + } + + const promptConstraints: string[] = []; + if (aspectRatio && !hasNativeAspectRatio) { + promptConstraints.push(`aspect ratio ${aspectRatio}`); + outputConstraintFallbacks.push("aspectRatio:prompt"); + } + if (resolution && !hasNativeResolution) { + promptConstraints.push(`resolution ${resolution}`); + outputConstraintFallbacks.push("imageSize:prompt"); + } + if (promptConstraints.length > 0 && typeof body.prompt === "string") { + body.prompt = `${body.prompt}\n\n[Output constraints] ${promptConstraints.join( + "; ", + )}.`; } if (params.videoUrls.length > 0) { @@ -776,7 +849,7 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | body.mode = params.imageUrls.length > 0 ? "image-to-video" : "text-to-video"; } - return body; + return { body, imageBindingKeys, outputConstraintFallbacks }; } function formatImageMarkdown(images: UploadedImage[], imageSize: string) { diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 6c0755ddb..920735866 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -63,6 +63,9 @@ export interface Vip98kCatalogModel { isEnabled?: boolean; is_active?: boolean; invocation?: Vip98kInvocationContract; + invocations?: Vip98kInvocationContract[]; + serviceOffers?: unknown; + service_offers?: unknown; taskTypes?: unknown; task_types?: unknown; releasedAt?: unknown; @@ -317,9 +320,26 @@ const UNAVAILABLE_STATUS_TOKENS = [ ] as const; function getInvocationRequestKeys(model: Vip98kCatalogModel) { - const invocation = toRecord(model.invocation); - return Object.keys(toRecord(invocation?.request_example) || {}).map((key) => - key.trim().toLowerCase().replace(/_/g, "-"), + return getInvocationCandidates(model).flatMap((invocation) => + Object.keys(toRecord(invocation.request_example) || {}).map((key) => + key.trim().toLowerCase().replace(/_/g, "-"), + ), + ); +} + +function getInvocationCandidates(model: Vip98kCatalogModel) { + const configured = Array.isArray(model.invocations) ? model.invocations : []; + const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])]; + return candidates.filter( + (candidate, index, items) => + Boolean(candidate && typeof candidate === "object") && + items.findIndex( + (item) => + String(item?.method || "POST") === String(candidate?.method || "POST") && + String(item?.path || "") === String(candidate?.path || "") && + JSON.stringify(item?.request_example || {}) === + JSON.stringify(candidate?.request_example || {}), + ) === index, ); } @@ -523,6 +543,16 @@ function getBillingMode(model: Vip98kCatalogModel) { function getVip98kMetadata(model: Vip98kCatalogModel, type: string) { const invocation = toRecord(model.invocation); + const invocations = getInvocationCandidates(model); + const normalizeInvocation = (value: Vip98kInvocationContract) => ({ + kind: String(value.kind || ""), + method: String(value.method || "POST"), + path: String(value.path || ""), + contentType: String(value.content_type || "application/json"), + requestExample: toRecord(value.request_example) || {}, + resultMethod: String(value.result_method || "GET"), + resultPath: String(value.result_path || ""), + }); return { modelType: type, taskApi: @@ -540,6 +570,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) { resultPath: String(invocation.result_path || ""), } : null, + invocations: invocations.map(normalizeInvocation), supportsAsync: Boolean(model.supportsAsync ?? model.supports_async ?? true), pollIntervalSeconds: parseNumber( model.pollIntervalSeconds || model.poll_interval_seconds, @@ -565,10 +596,19 @@ function createImagePreset( const inputModes = getInputModes(model); const outputModes = getOutputModes(model); const supportedImageSizes = getSupportedImageSizes(model); - const supportsReferenceImages = - capabilities.includes("image-to-image") || - inputModes.includes("url") || - inputModes.includes("file"); + const invocationKeys = new Set(getInvocationRequestKeys(model)); + const supportsReferenceImages = hasMediaReferenceInput(model, "image"); + const supportsNativeSize = [ + "size", + "image-size", + "resolution", + "width", + "height", + ].some((key) => invocationKeys.has(key)); + const supportsNativeAspectRatio = [ + "ratio", + "aspect-ratio", + ].some((key) => invocationKeys.has(key)); const supportedModes = [ ...(capabilities.includes("text-to-image") || capabilities.length === 0 ? ["text-to-image"] @@ -588,7 +628,7 @@ function createImagePreset( type: "image", supportsVision: supportsReferenceImages, supportsImageGen: true, - isHidden: !isVip98kImageModelVisible(model), + isHidden: !isVip98kImageModelVisible(model) || !supportsReferenceImages, capabilities: { provider: "vip98k", kind: "image", @@ -598,8 +638,10 @@ function createImagePreset( supportedModes, inputModes, outputModes, - supportedImageSizes, - supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS], + supportedImageSizes: supportsNativeSize ? supportedImageSizes : [], + supportedAspectRatios: supportsNativeAspectRatio + ? [...DEFAULT_ASPECT_RATIOS] + : ["auto"], supportsMultiImageReference: supportsReferenceImages, maxReferenceImages, vip98k: getVip98kMetadata(model, "image"), @@ -611,19 +653,21 @@ function createImagePreset( apiFormat: "vip98k-external-task", taskApi: "external-task", supportedModes, - imageSize: defaultImageSize, + ...(supportsNativeSize ? { imageSize: defaultImageSize } : {}), aspectRatio: "auto", imageCount: 1, }, imageGenConfig: { maxImages: 1, maxReferenceImages, - supportsSize: true, - supports1K: supportedImageSizes.includes("1K"), - supports2K: supportedImageSizes.includes("2K"), - supports4K: supportedImageSizes.includes("4K"), - supportedImageSizes, - supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS], + supportsSize: supportsNativeSize, + supports1K: supportsNativeSize && supportedImageSizes.includes("1K"), + supports2K: supportsNativeSize && supportedImageSizes.includes("2K"), + supports4K: supportsNativeSize && supportedImageSizes.includes("4K"), + supportedImageSizes: supportsNativeSize ? supportedImageSizes : [], + supportedAspectRatios: supportsNativeAspectRatio + ? [...DEFAULT_ASPECT_RATIOS] + : ["auto"], supportsPromptExtend: false, supportsNegativePrompt: false, supportsWatermark: false, @@ -980,35 +1024,42 @@ export async function fetchVip98kCatalog(params: { apiKey: string; }): Promise { const baseURL = normalizeVip98kBaseUrl(params.baseURL); - const [authorizedResponse, publicResponse] = await Promise.all([ - fetch(`${baseURL}/api/external/task/models`, { - cache: "no-store", - headers: { - Authorization: `Bearer ${params.apiKey}`, - Accept: "application/json", - }, - signal: AbortSignal.timeout(60_000), - }), - fetch(`${baseURL}/api/v1/public/models`, { - cache: "no-store", - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(60_000), - }), - ]); - const [authorizedPayload, publicPayload] = await Promise.all([ - parseJsonResponse(authorizedResponse), - parseJsonResponse(publicResponse), - ]); - if (!authorizedResponse.ok) { - throw new Error( - `${extractErrorMessage(authorizedPayload)} (HTTP ${authorizedResponse.status})`, - ); - } + const publicResponse = await fetch(`${baseURL}/api/v2/catalog/models`, { + cache: "no-store", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(60_000), + }); + const publicPayload = await parseJsonResponse(publicResponse); if (!publicResponse.ok) { throw new Error( `VIP98K public invocation catalog failed (HTTP ${publicResponse.status})`, ); } + const publicRecord = toRecord(publicPayload); + const publicData = toRecord(publicRecord?.data); + const discovery = toRecord(publicData?.discovery); + const discoveredAuthorizedPath = String( + discovery?.authorized_models_path || "/v1/models", + ).trim(); + const authorizedPath = + discoveredAuthorizedPath.startsWith("/") && + !discoveredAuthorizedPath.startsWith("//") + ? discoveredAuthorizedPath + : "/v1/models"; + const authorizedResponse = await fetch(`${baseURL}${authorizedPath}`, { + cache: "no-store", + headers: { + Authorization: `Bearer ${params.apiKey}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(60_000), + }); + const authorizedPayload = await parseJsonResponse(authorizedResponse); + if (!authorizedResponse.ok) { + throw new Error( + `${extractErrorMessage(authorizedPayload)} (HTTP ${authorizedResponse.status})`, + ); + } const authorizedModels = extractCatalogModels(authorizedPayload); const publicModels = extractPublicCatalogModels(publicPayload); if (authorizedModels.length === 0) { @@ -1024,6 +1075,8 @@ export async function fetchVip98kCatalog(params: { ...publicModel, ...model, invocation: publicModel.invocation, + invocations: publicModel.invocations, + serviceOffers: publicModel.serviceOffers || publicModel.service_offers, taskTypes: model.taskTypes || model.task_types || publicModel.taskTypes || publicModel.task_types, capabilities: model.capabilities || publicModel.capabilities, }; diff --git a/lib/ai/vip98k-result-delivery.ts b/lib/ai/vip98k-result-delivery.ts new file mode 100644 index 000000000..f3eed0fc0 --- /dev/null +++ b/lib/ai/vip98k-result-delivery.ts @@ -0,0 +1,31 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as JsonRecord; +} + +function unwrapData(payload: unknown) { + const record = toRecord(payload); + return record?.data && typeof record.data === "object" ? record.data : payload; +} + +export function shouldResolveVip98kResultDelivery(params: { + payload: unknown; + status: string; + outputUrlCount: number; +}) { + if (params.outputUrlCount > 0) return false; + const data = toRecord(unwrapData(params.payload)); + const result = toRecord(data?.result); + const resultAvailable = Boolean( + data?.resultAvailable ?? + data?.result_available ?? + result?.resultAvailable ?? + result?.result_available, + ); + const terminal = ["succeeded", "success", "completed", "done", "finished"].includes( + params.status, + ); + return terminal || resultAvailable; +} diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 5e5e27aaa..5fc4bcbfb 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -10,6 +10,24 @@ export type Vip98kTaskInvocation = { type KnownImageInput = "images" | "video_clips"; +const IMAGE_ARRAY_INPUT_KEYS = [ + "file_urls", + "images", + "imageUrls", + "referenceImages", + "reference_images", +] as const; + +const IMAGE_SINGLE_INPUT_KEYS = [ + "image", + "imageUrl", + "image_url", + "firstFrameImage", + "first_frame_image", + "firstFrameImageUrl", + "first_frame_image_url", +] as const; + const KNOWN_IMAGE_TOOL_INPUTS: Record = { "google/nano-banana-2/reference-to-image": "video_clips", "google/nano-banana-pro/edit": "images", @@ -30,6 +48,126 @@ function getInvocationToolId(invocation: Vip98kTaskInvocation | null) { return new URLSearchParams(query).get("tool_id"); } +function invocationTargetsModel( + invocation: Vip98kTaskInvocation | null, + modelId: string, +) { + if (!invocation) return false; + const expected = modelId.trim().toLowerCase(); + if (!expected) return false; + const requestModel = String(invocation.requestExample.model || "") + .trim() + .toLowerCase(); + if (requestModel) return requestModel === expected; + return getInvocationToolId(invocation)?.toLowerCase() === expected; +} + +export function getVip98kImageBindingKeys(invocation: Vip98kTaskInvocation | null) { + if (!invocation) return []; + const body = invocation.requestExample || {}; + return [ + ...IMAGE_ARRAY_INPUT_KEYS.filter((key) => key in body), + ...IMAGE_SINGLE_INPUT_KEYS.filter((key) => key in body), + ...(Array.isArray(body.video_clips) ? ["video_clips" as const] : []), + ]; +} + +export function invocationSupportsVip98kImages( + invocation: Vip98kTaskInvocation | null, +) { + return getVip98kImageBindingKeys(invocation).length > 0; +} + +export function bindVip98kImages( + body: Record, + imageUrls: string[], +) { + const bindingKeys: string[] = []; + if (imageUrls.length === 0) return bindingKeys; + + for (const key of IMAGE_ARRAY_INPUT_KEYS) { + if (!(key in body)) continue; + body[key] = imageUrls; + bindingKeys.push(key); + } + for (const key of IMAGE_SINGLE_INPUT_KEYS) { + if (!(key in body)) continue; + body[key] = imageUrls[0]; + bindingKeys.push(key); + } + if (Array.isArray(body.video_clips)) { + const first = body.video_clips[0]; + const template = + first && typeof first === "object" && !Array.isArray(first) + ? (first as Record) + : {}; + body.video_clips = imageUrls.map((url) => ({ ...template, url })); + bindingKeys.push("video_clips"); + } + + const secondImageBindings = [ + "lastFrameImage", + "last_frame_image", + "endImageUrl", + "end_image_url", + ] as const; + if (imageUrls[1]) { + for (const key of secondImageBindings) { + if (!(key in body)) continue; + body[key] = imageUrls[1]; + bindingKeys.push(key); + } + } + return bindingKeys; +} + +function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) { + const body = invocation.requestExample || {}; + let score = 0; + if ("file_urls" in body) score += 60; + if (IMAGE_ARRAY_INPUT_KEYS.some((key) => key in body)) 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; + if (invocation.path === "/extensions/v1/tasks") score += 10; + return score; +} + +export function selectVip98kTaskInvocation(params: { + modelId: string; + configuredInvocation: Vip98kTaskInvocation | null; + configuredInvocations?: Vip98kTaskInvocation[]; + imageCount?: number; +}) { + const candidates = [ + ...(params.configuredInvocations || []), + ...(params.configuredInvocation ? [params.configuredInvocation] : []), + ].filter( + (candidate, index, items) => + candidate.path.startsWith("/") && + items.findIndex( + (item) => + item.method === candidate.method && + item.path === candidate.path && + JSON.stringify(item.requestExample) === JSON.stringify(candidate.requestExample), + ) === index, + ); + + if ((params.imageCount || 0) > 0) { + const referenceInvocation = candidates + .filter(invocationSupportsVip98kImages) + .sort((a, b) => scoreReferenceInvocation(b) - scoreReferenceInvocation(a))[0]; + if (referenceInvocation) { + return resolveVip98kTaskInvocation(params.modelId, referenceInvocation); + } + } + + return resolveVip98kTaskInvocation( + params.modelId, + params.configuredInvocation || candidates[0] || null, + ); +} + function createKnownImageToolInvocation( modelId: string, imageInput: KnownImageInput @@ -82,7 +220,7 @@ export function resolveVip98kTaskInvocation( const normalizedModelId = modelId.trim().toLowerCase(); const processingRequest = KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS[normalizedModelId]; if (processingRequest) { - if (getInvocationToolId(configuredInvocation)?.toLowerCase() === normalizedModelId) { + if (invocationTargetsModel(configuredInvocation, normalizedModelId)) { return configuredInvocation; } return createKnownProcessingToolInvocation(modelId, processingRequest); @@ -91,7 +229,7 @@ export function resolveVip98kTaskInvocation( const knownImageInput = KNOWN_IMAGE_TOOL_INPUTS[normalizedModelId]; if (!knownImageInput) return configuredInvocation; - if (getInvocationToolId(configuredInvocation)?.toLowerCase() === normalizedModelId) { + if (invocationTargetsModel(configuredInvocation, normalizedModelId)) { return configuredInvocation; } diff --git a/package.json b/package.json index 776f0b8c2..9d14cd89a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,9 @@ "storage:repair-model3d-qiniu": "pnpm exec tsx scripts/storage/repair-model3d-qiniu-assets.ts", "verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts", "providers:consolidate": "pnpm exec tsx scripts/consolidate-ai-providers.ts", - "providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts" + "providers:configure-image-allowlist": "pnpm exec tsx scripts/configure-image-provider-allowlist.ts", + "providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts", + "providers:test-vip98k-images": "pnpm exec tsx scripts/test-vip98k-image-models.ts" }, "dependencies": { "@ai-sdk/gateway": "^1.0.15", diff --git a/scripts/configure-image-provider-allowlist.ts b/scripts/configure-image-provider-allowlist.ts new file mode 100644 index 000000000..745492d15 --- /dev/null +++ b/scripts/configure-image-provider-allowlist.ts @@ -0,0 +1,230 @@ +import dotenv from "dotenv"; +import postgres from "postgres"; +import { + fetchVip98kModelPresets, + type Vip98kModelPreset, +} from "../lib/ai/vip98k-models"; + +dotenv.config({ path: ".env.local" }); +dotenv.config(); + +const ALLOWED_IMAGE_PROVIDER_TYPES = ["alibaba-image", "vip98k"] as const; + +function hasFlag(name: string) { + return process.argv.includes(name); +} + +function toJsonValue(value: unknown): postgres.JSONValue { + return JSON.parse(JSON.stringify(value)) as postgres.JSONValue; +} + +function isQualifiedVip98kImagePreset(preset: Vip98kModelPreset) { + const capabilities = preset.capabilities as Record; + return ( + preset.supportsImageGen && + !preset.isHidden && + capabilities.supportsMultiImageReference === true + ); +} + +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 multi-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"`; + const vip98kProviders = providers.filter((provider) => provider.type === "vip98k"); + const alibabaProviders = providers.filter( + (provider) => provider.type === "alibaba-image", + ); + if (vip98kProviders.length !== 1) { + throw new Error(`Expected one VIP98K provider, found ${vip98kProviders.length}`); + } + if (alibabaProviders.length === 0) { + throw new Error("Alibaba official image provider is missing"); + } + const vip98kProvider = vip98kProviders[0]; + if (!vip98kProvider) throw new Error("VIP98K provider is missing"); + + const disallowedImageModels = await sql< + Array<{ id: string; providerType: string; modelId: string }> + >` + select m.id, p.type as "providerType", m."modelId" + from "AiModel" m join "AiProvider" p on p.id = m."providerId" + where m."supportsImageGen" + and p.type != all(${ALLOWED_IMAGE_PROVIDER_TYPES}::text[]) + order by p.type, m."modelId" + `; + const staleVip98kImageModels = await sql>` + select m.id, m."modelId" + from "AiModel" m + where m."providerId" = ${vip98kProvider.id}::uuid + and m."supportsImageGen" + and m."modelId" != all(${qualifiedVip98kPresets.map((preset) => preset.modelId)}::text[]) + order by m."modelId" + `; + const imageOnlyProviders = await sql>` + select p.id, p.type, p.name + from "AiProvider" p + where p.type != all(${ALLOWED_IMAGE_PROVIDER_TYPES}::text[]) + and ( + p.type like '%-image' + or p.type in ('bananaimage', 'gemini', 'huanke-gemini') + or ( + exists (select 1 from "AiModel" m where m."providerId" = p.id and m."supportsImageGen") + and not exists ( + select 1 from "AiModel" m + where m."providerId" = p.id and not m."supportsImageGen" and m."isEnabled" + ) + ) + ) + order by p.type, p.name + `; + + console.log( + JSON.stringify( + { + mode: apply ? "apply" : "dry-run", + allowedImageProviderTypes: ALLOWED_IMAGE_PROVIDER_TYPES, + qualifiedVip98kModels: qualifiedVip98kPresets.map((preset) => preset.modelId), + disallowedImageModelsToDisable: disallowedImageModels.length, + staleVip98kImageModelsToDisable: staleVip98kImageModels.length, + imageOnlyProvidersToDisable: imageOnlyProviders.map((provider) => ({ + type: provider.type, + name: provider.name, + })), + }, + null, + 2, + ), + ); + if (!apply) return; + + await sql.begin(async (tx) => { + await tx` + update "AiProvider" + set "baseUrl" = 'https://api.vip98k.com', "apiKey" = ${vip98kApiKey}, + "isEnabled" = true, "updatedAt" = now() + where id = ${vip98kProvider.id}::uuid + `; + await tx` + update "AiProvider" set "isEnabled" = true, "updatedAt" = now() + where type = 'alibaba-image' + `; + + for (const preset of qualifiedVip98kPresets) { + 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>` + select id from "AiModel" + where "providerId" = ${vip98kProvider.id}::uuid and "modelId" = ${preset.modelId} + order by "createdAt" limit 1 + `; + if (existing[0]) { + await tx` + update "AiModel" set + "displayName" = ${preset.displayName}, description = ${preset.description}, + "supportsVision" = ${preset.supportsVision}, "supportsImageGen" = true, + "isEnabled" = true, "isHidden" = false, "sortOrder" = ${preset.sortOrder}, + capabilities = ${capabilities}, "defaultParams" = ${defaultParams}, + "imageGenConfig" = ${imageGenConfig}, "fallbackModelId1" = null, + "fallbackModelId2" = null, "updatedAt" = now() + where id = ${existing[0].id}::uuid + `; + } else { + await tx` + insert into "AiModel" ( + "providerId", "modelId", "displayName", description, "supportsVision", + "supportsImageGen", "isEnabled", "isHidden", "sortOrder", capabilities, + "defaultParams", "imageGenConfig", "isDefault", "isDefaultTextEditModel" + ) values ( + ${vip98kProvider.id}::uuid, ${preset.modelId}, ${preset.displayName}, + ${preset.description}, ${preset.supportsVision}, true, true, false, + ${preset.sortOrder}, ${capabilities}, ${defaultParams}, ${imageGenConfig}, false, false + ) + `; + } + } + + await tx` + update "AiModel" m set + "isEnabled" = false, "isHidden" = true, "isDefault" = false, + "isDefaultTextEditModel" = false, "updatedAt" = now() + from "AiProvider" p + where p.id = m."providerId" and m."supportsImageGen" + and p.type != all(${ALLOWED_IMAGE_PROVIDER_TYPES}::text[]) + `; + await tx` + update "AiModel" set + "isEnabled" = false, "isHidden" = true, "isDefault" = false, + "isDefaultTextEditModel" = false, "updatedAt" = now() + where "providerId" = ${vip98kProvider.id}::uuid and "supportsImageGen" + and "modelId" != all(${qualifiedVip98kPresets.map((preset) => preset.modelId)}::text[]) + `; + if (imageOnlyProviders.length > 0) { + await tx` + update "AiProvider" set "isEnabled" = false, "updatedAt" = now() + where id = any(${imageOnlyProviders.map((provider) => provider.id)}::uuid[]) + `; + } + }); + + const invalidModels = await sql< + Array<{ providerType: string; modelId: string }> + >` + select p.type as "providerType", m."modelId" + from "AiModel" m join "AiProvider" p on p.id = m."providerId" + where m."supportsImageGen" and m."isEnabled" + and p.type != all(${ALLOWED_IMAGE_PROVIDER_TYPES}::text[]) + `; + const activeVip98kModels = await sql>` + select m."modelId" + from "AiModel" m join "AiProvider" p on p.id = m."providerId" + where p.type = 'vip98k' and p."isEnabled" and m."supportsImageGen" + and m."isEnabled" and not m."isHidden" + order by m."modelId" + `; + if (invalidModels.length > 0) { + throw new Error("Disallowed image models remain enabled after apply"); + } + if (activeVip98kModels.length !== qualifiedVip98kPresets.length) { + throw new Error("VIP98K active image model count does not match qualified catalog"); + } + console.log( + JSON.stringify( + { + success: true, + activeVip98kModels: activeVip98kModels.map((model) => model.modelId), + disabledImageModelCount: disallowedImageModels.length + staleVip98kImageModels.length, + }, + null, + 2, + ), + ); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/test-vip98k-capability.ts b/scripts/test-vip98k-capability.ts index fea520c39..dc0d00a1f 100644 --- a/scripts/test-vip98k-capability.ts +++ b/scripts/test-vip98k-capability.ts @@ -109,7 +109,7 @@ async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) { const baseUrl = normalizeVip98kBaseUrl(params.baseURL); const [authorizedPayload, publicPayload] = await Promise.all([ parseResponse( - await fetch(`${baseUrl}/api/external/task/models`, { + await fetch(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${params.apiKey}`, Accept: "application/json", @@ -118,7 +118,7 @@ async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) { }), ), parseResponse( - await fetch(`${baseUrl}/api/v1/public/models`, { + await fetch(`${baseUrl}/api/v2/catalog/models`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(60_000), }), @@ -130,6 +130,7 @@ async function fetchVip98kCatalog(params: { baseURL: string; apiKey: string }) { : {}; const data = record.data; const authorized = + (Array.isArray(record.data) && record.data) || (Array.isArray(data?.models) && data.models) || (Array.isArray(data?.items) && data.items) || (Array.isArray(data) && data) || diff --git a/scripts/test-vip98k-image-models.ts b/scripts/test-vip98k-image-models.ts new file mode 100644 index 000000000..d5bca9881 --- /dev/null +++ b/scripts/test-vip98k-image-models.ts @@ -0,0 +1,289 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import dotenv from "dotenv"; +import sharp from "sharp"; + +dotenv.config({ path: ".env.local" }); +dotenv.config(); + +const BASE_URL = String(process.env.VIP98K_BASE_URL || "https://api.vip98k.com") + .trim() + .replace(/\/+$/, "") + .replace(/\/v1$/i, ""); +const REFERENCE_URLS = [ + "https://dummyimage.com/512x512/2563eb/ffffff.png&text=BLUE+A", + "https://dummyimage.com/512x512/dc2626/ffffff.png&text=RED+B", +]; +const TERMINAL_STATUSES = new Set(["succeeded", "success", "completed", "done"]); +const FAILURE_STATUSES = new Set(["failed", "error", "timeout", "canceled", "cancelled"]); + +type JsonRecord = Record; +type Invocation = { + method?: string; + path?: string; + content_type?: string; + request_example?: JsonRecord; + result_path?: string; +}; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +async function readJson(response: Response) { + const text = await response.text(); + let payload: unknown = text; + try { + payload = text ? JSON.parse(text) : null; + } catch { + // Keep the raw provider body for diagnostics. + } + if (!response.ok) { + const record = asRecord(payload); + const message = record.error?.message || record.error || record.message || text; + throw new Error(`${String(message || "VIP98K request failed")} (HTTP ${response.status})`); + } + return payload; +} + +function requestKeys(invocation: Invocation) { + return Object.keys(asRecord(invocation.request_example)).map((key) => key.toLowerCase()); +} + +function supportsMultipleImages(invocation: Invocation) { + return requestKeys(invocation).some((key) => + ["file_urls", "images", "image_urls", "reference_images"].includes(key), + ); +} + +function extractStatus(payload: unknown) { + const root = asRecord(payload); + const data = asRecord(root.data || root); + return String(data.status || data.state || root.status || "").toLowerCase(); +} + +function extractTaskId(payload: unknown) { + const root = asRecord(payload); + const data = asRecord(root.data || root); + return String(data.taskId || data.task_id || data.id || root.id || ""); +} + +async function fetchCatalog(apiKey: string) { + const [catalogPayload, authorizedPayload] = await Promise.all([ + readJson( + await fetch(`${BASE_URL}/api/v2/catalog/models`, { + signal: AbortSignal.timeout(60_000), + }), + ), + readJson( + await fetch(`${BASE_URL}/v1/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(60_000), + }), + ), + ]); + const catalog = asRecord(catalogPayload); + const authorized = asRecord(authorizedPayload); + const authorizedIds = new Set( + (Array.isArray(authorized.data) ? authorized.data : []).map((model) => + String(asRecord(model).id || ""), + ), + ); + const sections = Array.isArray(asRecord(catalog.data).sections) + ? asRecord(catalog.data).sections + : []; + return sections + .flatMap((section: unknown) => { + const record = asRecord(section); + return Array.isArray(record.models) ? record.models : []; + }) + .map(asRecord) + .filter( + (model: JsonRecord) => { + const capabilities = (Array.isArray(model.capabilities) ? model.capabilities : []).map( + (capability: unknown) => String(capability).toLowerCase(), + ); + return ( + authorizedIds.has(String(model.id || "")) && + String(model.type || model.category || "").toLowerCase() === "image" && + capabilities.some((capability: string) => + ["text_to_image", "text-to-image", "openai.images.generations"].includes( + capability, + ), + ) + ); + }, + ); +} + +async function resolveOutputUrl(apiKey: string, taskId: string) { + const payload = await readJson( + await fetch( + `${BASE_URL}/api/external/task/result?taskId=${encodeURIComponent(taskId)}`, + { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(60_000), + }, + ), + ); + const data = asRecord(asRecord(payload).data); + const outputs = asRecord(data.outputs); + const generatedImages = Array.isArray(outputs.generated_image) + ? outputs.generated_image + : []; + const first = asRecord(generatedImages[0]); + const url = String(first.image_url || first.file_download_url || ""); + if (!url.startsWith("http")) throw new Error("VIP98K delivery result has no image URL"); + return url; +} + +async function pollTask(apiKey: string, taskId: string, resultPath: string) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 15 * 60_000) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000)); + const payload = await readJson( + await fetch(`${BASE_URL}${resultPath}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(60_000), + }), + ); + const status = extractStatus(payload); + if (FAILURE_STATUSES.has(status)) throw new Error(`VIP98K task failed: ${status}`); + if (TERMINAL_STATUSES.has(status)) { + return { + durationMs: Date.now() - startedAt, + outputUrl: await resolveOutputUrl(apiKey, taskId), + }; + } + } + throw new Error("VIP98K task did not finish within 15 minutes"); +} + +async function analyzeImage(url: 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 { data, info } = await image.resize(256, 256, { fit: "fill" }).raw().toBuffer({ + resolveWithObject: true, + }); + let bluePixels = 0; + let redPixels = 0; + for (let offset = 0; offset < data.length; offset += info.channels) { + const red = data[offset] || 0; + const green = data[offset + 1] || 0; + const blue = data[offset + 2] || 0; + if (blue > 100 && blue > red * 1.25 && blue > green * 1.1) bluePixels += 1; + if (red > 100 && red > blue * 1.25 && red > green * 1.15) redPixels += 1; + } + const pixels = info.width * info.height; + return { + width: metadata.width || 0, + height: metadata.height || 0, + format: metadata.format || "unknown", + blueShare: Number((bluePixels / pixels).toFixed(4)), + redShare: Number((redPixels / pixels).toFixed(4)), + bothReferencesDetected: bluePixels / pixels > 0.03 && redPixels / pixels > 0.03, + sha256: createHash("sha256").update(buffer).digest("hex"), + }; +} + +async function testModel(apiKey: string, model: JsonRecord) { + const modelId = String(model.id || ""); + const invocations = (Array.isArray(model.invocations) + ? model.invocations + : [model.invocation] + ).map(asRecord) as Invocation[]; + const invocation = invocations.find(supportsMultipleImages); + if (!invocation) { + return { + modelId, + status: "unsupported", + eligibleForMultiImageEditing: false, + reason: "The published V2 contract exposes text-to-image only", + }; + } + + const keys = requestKeys(invocation); + const nativeAspectRatio = keys.some((key) => ["ratio", "aspect_ratio"].includes(key)); + const nativeSize = keys.some((key) => + ["size", "image_size", "resolution", "width", "height"].includes(key), + ); + 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."; + const submitted = await readJson( + await fetch(`${BASE_URL}${invocation.path}`, { + method: String(invocation.method || "POST").toUpperCase(), + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": invocation.content_type || "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }), + ); + const taskId = extractTaskId(submitted); + if (!taskId) throw new Error(`${modelId} returned no task ID`); + const resultPath = String(invocation.result_path || "").replace( + "{task_id}", + encodeURIComponent(taskId), + ); + if (!resultPath) throw new Error(`${modelId} publishes no result path`); + const completed = await pollTask(apiKey, taskId, resultPath); + const image = await analyzeImage(completed.outputUrl); + return { + modelId, + status: image.bothReferencesDetected ? "passed" : "failed", + eligibleForMultiImageEditing: image.bothReferencesDetected, + publishedRequestKeys: keys.sort(), + requested: { referenceImageCount: 2, aspectRatio: "16:9", size: "1024x576" }, + nativeParameterSupport: { aspectRatio: nativeAspectRatio, size: nativeSize }, + actual: image, + durationMs: completed.durationMs, + }; +} + +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 results: unknown[] = []; + for (const model of models) { + try { + results.push(await testModel(apiKey, model)); + } catch (error) { + results.push({ + modelId: String(model.id || ""), + status: "failed", + eligibleForMultiImageEditing: false, + error: error instanceof Error ? error.message.slice(0, 1000) : String(error), + }); + } + } + const report = { + generatedAt: new Date().toISOString(), + baseUrl: BASE_URL, + authorizedImageModelCount: models.length, + results, + }; + const outputPath = resolve( + process.env.VIP98K_IMAGE_TEST_REPORT || ".local/vip98k-image-model-tests.json", + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ ...report, reportPath: outputPath }, null, 2)); + if (!results.some((result) => asRecord(result).status === "passed")) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 818d62706..ac2f0ef92 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -1,8 +1,12 @@ import { expect, test } from "@playwright/test"; import { + bindVip98kImages, resolveVip98kTaskInvocation, + selectVip98kTaskInvocation, type Vip98kTaskInvocation, } from "@/lib/ai/vip98k-task-invocation"; +import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models"; +import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; const textToImageInvocation: Vip98kTaskInvocation = { kind: "tool", @@ -83,4 +87,175 @@ test.describe("VIP98K reference-image routing", () => { scale_factor: 4, }); }); + + test("selects the V2 media-task contract when a model receives reference images", () => { + const generationInvocation: Vip98kTaskInvocation = { + kind: "tool", + method: "POST", + path: "/v1/images/generations", + contentType: "application/json", + requestExample: { model: "nano-banana-2", n: 1, prompt: "Generate" }, + resultMethod: "GET", + resultPath: "", + }; + const editInvocation: Vip98kTaskInvocation = { + kind: "task", + method: "POST", + path: "/extensions/v1/tasks", + contentType: "application/json", + requestExample: { + file_urls: ["https://api.vip98k.com/logo.png"], + model: "nano-banana-2", + prompt: "Edit", + }, + resultMethod: "GET", + resultPath: "/extensions/v1/tasks/{task_id}/result", + }; + + expect( + selectVip98kTaskInvocation({ + modelId: "nano-banana-2", + configuredInvocation: generationInvocation, + configuredInvocations: [generationInvocation, editInvocation], + imageCount: 2, + }), + ).toBe(editInvocation); + }); + + test("binds every reference image to the V2 file_urls field", () => { + const body: Record = { + file_urls: ["https://api.vip98k.com/logo.png"], + model: "nano-banana-2", + prompt: "Edit", + }; + + expect( + bindVip98kImages(body, [ + "https://example.com/a.png", + "https://example.com/b.png", + ]), + ).toEqual(["file_urls"]); + expect(body.file_urls).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", + method: "POST", + path: "/extensions/v1/tasks", + contentType: "application/json", + requestExample: { + file_urls: ["https://api.vip98k.com/logo.png"], + model: "enhance", + }, + resultMethod: "GET", + resultPath: "/extensions/v1/tasks/{task_id}/result", + }; + + expect(resolveVip98kTaskInvocation("enhance", invocation)).toBe(invocation); + }); + + test("derives multi-image support and parameter controls from all V2 invocations", () => { + const [preset] = buildVip98kModelPresets([ + { + id: "nano-banana-2", + type: "image", + capabilities: ["openai.images.generations", "text_to_image"], + invocation: { + kind: "tool", + method: "POST", + path: "/v1/images/generations", + content_type: "application/json", + request_example: { model: "nano-banana-2", n: 1, prompt: "Generate" }, + }, + invocations: [ + { + kind: "tool", + method: "POST", + path: "/v1/images/generations", + content_type: "application/json", + request_example: { model: "nano-banana-2", n: 1, prompt: "Generate" }, + }, + { + kind: "task", + method: "POST", + path: "/extensions/v1/tasks", + content_type: "application/json", + request_example: { + file_urls: ["https://api.vip98k.com/logo.png"], + model: "nano-banana-2", + prompt: "Edit", + }, + result_method: "GET", + result_path: "/extensions/v1/tasks/{task_id}/result", + }, + ], + }, + ]); + + expect(preset).toMatchObject({ + supportsVision: true, + supportsImageGen: true, + capabilities: { + supportsMultiImageReference: true, + maxReferenceImages: 8, + supportedImageSizes: [], + supportedAspectRatios: ["auto"], + }, + imageGenConfig: { + supportsSize: false, + supportedImageSizes: [], + supportedAspectRatios: ["auto"], + }, + }); + expect( + (preset?.imageGenConfig?.vip98k as { invocations?: unknown[] })?.invocations, + ).toHaveLength(2); + }); + + test("detects V2 asset-only results that need delivery URL resolution", () => { + expect( + shouldResolveVip98kResultDelivery({ + payload: { + ready: true, + result: { + result_available: true, + outputs: [{ asset_id: "opaque", type: "image" }], + }, + status: "succeeded", + }, + status: "succeeded", + outputUrlCount: 0, + }), + ).toBe(true); + expect( + shouldResolveVip98kResultDelivery({ + payload: { status: "succeeded" }, + status: "succeeded", + outputUrlCount: 1, + }), + ).toBe(false); + }); + + test("excludes text-only VIP98K image models from editing workflows", () => { + const [preset] = buildVip98kModelPresets([ + { + id: "nano-banana-pro", + type: "image", + capabilities: ["openai.images.generations", "text_to_image"], + invocation: { + kind: "tool", + method: "POST", + path: "/v1/images/generations", + content_type: "application/json", + request_example: { model: "nano-banana-pro", n: 1, prompt: "Generate" }, + }, + }, + ]); + + expect(preset).toBeUndefined(); + }); });