fix: update vip98k image task integration

This commit is contained in:
134 2026-09-07 00:21:03 +08:00
parent 3c7e561c24
commit fc553ffea3
9 changed files with 1123 additions and 131 deletions

View File

@ -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<string, unknown> = { ...(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) {

View File

@ -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<Vip98kCatalogModel[]> {
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,
};

View File

@ -0,0 +1,31 @@
type JsonRecord = Record<string, unknown>;
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;
}

View File

@ -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<string, KnownImageInput> = {
"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<string, unknown>,
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<string, unknown>)
: {};
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;
}

View File

@ -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",

View File

@ -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<string, unknown>;
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<Array<{ id: string; modelId: string }>>`
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<Array<{ id: string; type: string; name: string }>>`
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<Array<{ id: string }>>`
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<Array<{ modelId: string }>>`
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);
});

View File

@ -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) ||

View File

@ -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<string, any>;
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);
});

View File

@ -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<string, unknown> = {
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();
});
});