fix: update vip98k image task integration
This commit is contained in:
parent
3c7e561c24
commit
fc553ffea3
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
31
lib/ai/vip98k-result-delivery.ts
Normal file
31
lib/ai/vip98k-result-delivery.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
230
scripts/configure-image-provider-allowlist.ts
Normal file
230
scripts/configure-image-provider-allowlist.ts
Normal 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);
|
||||
});
|
||||
@ -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) ||
|
||||
|
||||
289
scripts/test-vip98k-image-models.ts
Normal file
289
scripts/test-vip98k-image-models.ts
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user