fix: support vip98k image array contracts

This commit is contained in:
134 2026-09-07 20:17:40 +08:00 committed by root
parent 6ce7c31a1e
commit 42359fbf0f
6 changed files with 186 additions and 37 deletions

View File

@ -832,31 +832,47 @@ async function submitTask(params: {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const path = String(params.path || "/api/external/task/generate").trim();
if (!path.startsWith("/")) throw new Error("VIP98K invocation path is invalid");
const stagedReferences = Array.isArray(params.body.file_urls)
? params.body.file_urls.filter(
(value): value is string =>
typeof value === "string" && isStagedReferenceUrl(value),
)
: [];
const stagedReferenceKeys = [
"file_urls",
"images",
"image",
"imageUrls",
"referenceImages",
"reference_images",
] as const;
const stagedReferences = stagedReferenceKeys.flatMap((key) => {
const value = params.body[key];
const candidates = Array.isArray(value) ? value : [value];
return candidates.filter(
(candidate): candidate is string =>
typeof candidate === "string" && isStagedReferenceUrl(candidate),
);
});
const maxAttempts = MAX_SUBMISSION_ATTEMPTS;
let refreshStagedReferences = false;
let idempotencyKey = randomUUID();
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const requestBody =
!refreshStagedReferences || !Array.isArray(params.body.file_urls)
? params.body
: {
...params.body,
file_urls: params.body.file_urls.map((value) =>
typeof value === "string" && isStagedReferenceUrl(value)
const requestBody = refreshStagedReferences
? Object.fromEntries(
Object.entries(params.body).map(([key, value]) => {
if (!stagedReferenceKeys.includes(key as (typeof stagedReferenceKeys)[number])) {
return [key, value];
}
const addRetryToken = (candidate: unknown) =>
typeof candidate === "string" && isStagedReferenceUrl(candidate)
? addVip98kReferenceRetryToken(
value,
candidate,
`${Date.now()}-${attempt}`,
)
: value,
),
};
: candidate;
return [
key,
Array.isArray(value) ? value.map(addRetryToken) : addRetryToken(value),
];
}),
)
: params.body;
let response: Response;
try {
response = await fetch(`${baseURL}${path}`, {

View File

@ -328,10 +328,18 @@ function getInvocationRequestKeys(model: Vip98kCatalogModel) {
return [
...Object.keys(toRecord(invocation.request_example) || {}),
...Object.keys(schemaProperties || {}),
].map((key) => key.trim().toLowerCase().replace(/_/g, "-"));
].map(normalizeInvocationRequestKey);
});
}
function normalizeInvocationRequestKey(key: string) {
return key
.trim()
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()
.replace(/_/g, "-");
}
function getInvocationCandidates(model: Vip98kCatalogModel) {
const configured = Array.isArray(model.invocations) ? model.invocations : [];
const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])];
@ -411,7 +419,27 @@ function hasMultiImageReferenceInput(model: Vip98kCatalogModel) {
"reference-images",
"video-clips",
]);
return getInvocationRequestKeys(model).some((key) => multiImageKeys.has(key));
return getInvocationCandidates(model).some((invocation) => {
const requestExample = toRecord(invocation.request_example) || {};
const schemaProperties =
toRecord(toRecord(invocation.input_schema)?.properties) || {};
const keys = new Set([
...Object.keys(requestExample),
...Object.keys(schemaProperties),
]);
return Array.from(keys).some((key) => {
const normalizedKey = normalizeInvocationRequestKey(key);
if (normalizedKey !== "image" && !multiImageKeys.has(normalizedKey)) {
return false;
}
const schema = toRecord(schemaProperties[key]);
return (
Array.isArray(requestExample[key]) ||
String(schema?.type || "").trim().toLowerCase() === "array"
);
});
});
}
export function isVip98kMultiImageEditCatalogModel(model: Vip98kCatalogModel) {

View File

@ -154,7 +154,7 @@ export function bindVip98kImages(
}
for (const key of IMAGE_SINGLE_INPUT_KEYS) {
if (!(key in body)) continue;
body[key] = imageUrls[0];
body[key] = Array.isArray(body[key]) ? imageUrls : imageUrls[0];
bindingKeys.push(key);
}
if (Array.isArray(body.video_clips)) {
@ -188,6 +188,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
let score = 0;
if ("file_urls" in body) score += 60;
if (IMAGE_ARRAY_INPUT_KEYS.some((key) => key in body)) score += 40;
if (IMAGE_SINGLE_INPUT_KEYS.some((key) => Array.isArray(body[key]))) score += 40;
if (Array.isArray(body.video_clips)) score += 35;
if (IMAGE_SINGLE_INPUT_KEYS.some((key) => key in body)) score += 15;
if (invocation.kind === "task") score += 10;

View File

@ -25,24 +25,20 @@ function isQualifiedVip98kImagePreset(preset: Vip98kModelPreset) {
async function main() {
const apply = hasFlag("--apply");
const databaseUrl = String(process.env.POSTGRES_URL || "").trim();
const vip98kApiKey = String(process.env.VIP98K_API_KEY || "").trim();
if (!databaseUrl) throw new Error("POSTGRES_URL is required");
if (!vip98kApiKey) throw new Error("VIP98K_API_KEY is required");
const presets = await fetchVip98kModelPresets({
baseURL: process.env.VIP98K_BASE_URL || "https://api.vip98k.com",
apiKey: vip98kApiKey,
});
const qualifiedVip98kPresets = presets.filter(isQualifiedVip98kImagePreset);
if (qualifiedVip98kPresets.length === 0) {
throw new Error("VIP98K catalog contains no qualified image model");
}
const sql = postgres(databaseUrl, { prepare: false, max: 1 });
try {
const providers = await sql<
Array<{ id: string; type: string; name: string; isEnabled: boolean }>
>`select id, type, name, "isEnabled" from "AiProvider" order by "createdAt"`;
Array<{
id: string;
type: string;
name: string;
apiKey: string | null;
baseUrl: string | null;
isEnabled: boolean;
}>
>`select id, type, name, "apiKey", "baseUrl", "isEnabled" from "AiProvider" order by "createdAt"`;
const vip98kProviders = providers.filter((provider) => provider.type === "vip98k");
const alibabaProviders = providers.filter(
(provider) => provider.type === "alibaba-image",
@ -55,6 +51,23 @@ async function main() {
}
const vip98kProvider = vip98kProviders[0];
if (!vip98kProvider) throw new Error("VIP98K provider is missing");
const vip98kApiKey = String(
process.env.VIP98K_API_KEY || vip98kProvider.apiKey || "",
).trim();
if (!vip98kApiKey) throw new Error("VIP98K API key is not configured");
const vip98kBaseUrl = String(
process.env.VIP98K_BASE_URL ||
vip98kProvider.baseUrl ||
"https://api.vip98k.com",
).trim();
const presets = await fetchVip98kModelPresets({
baseURL: vip98kBaseUrl,
apiKey: vip98kApiKey,
});
const qualifiedVip98kPresets = presets.filter(isQualifiedVip98kImagePreset);
if (qualifiedVip98kPresets.length === 0) {
throw new Error("VIP98K catalog contains no qualified image model");
}
const existingVip98kModels = await sql<
Array<{
id: string;

View File

@ -85,10 +85,41 @@ function requestKeys(invocation: Invocation) {
);
}
function getMultiImageInputKey(invocation: Invocation) {
const requestExample = asRecord(invocation.request_example);
const properties = asRecord(asRecord(invocation.input_schema).properties);
const keys = new Set([
...Object.keys(requestExample),
...Object.keys(properties),
]);
for (const key of keys) {
const normalized = key
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase();
if (
![
"file_urls",
"images",
"image",
"image_urls",
"reference_images",
].includes(normalized)
) {
continue;
}
if (
Array.isArray(requestExample[key]) ||
String(asRecord(properties[key]).type || "").toLowerCase() === "array"
) {
return key;
}
}
return null;
}
function supportsMultipleImages(invocation: Invocation) {
return requestKeys(invocation).some((key) =>
["file_urls", "images", "image_urls", "reference_images"].includes(key),
);
return Boolean(getMultiImageInputKey(invocation));
}
function extractStatus(payload: unknown) {
@ -248,6 +279,10 @@ async function testModel(apiKey: string, model: JsonRecord) {
reason: "The published V2 contract exposes text-to-image only",
};
}
const multiImageInputKey = getMultiImageInputKey(invocation);
if (!multiImageInputKey) {
throw new Error(`${modelId} publishes no multi-image input field`);
}
const keys = requestKeys(invocation);
const nativeAspectRatio = keys.some((key) => ["ratio", "aspect_ratio"].includes(key));
@ -256,7 +291,7 @@ async function testModel(apiKey: string, model: JsonRecord) {
);
const body = { ...asRecord(invocation.request_example) };
body.model = modelId;
body.file_urls = REFERENCE_URLS;
body[multiImageInputKey] = REFERENCE_URLS;
body.prompt = TEST_PROMPT;
if (keys.includes("aspect_ratio")) body.aspect_ratio = "16:9";
if (keys.includes("resolution")) body.resolution = "1K";

View File

@ -257,6 +257,25 @@ test.describe("VIP98K reference-image routing", () => {
]);
});
test("binds every reference image to an array-shaped image field", () => {
const body: Record<string, unknown> = {
image: ["https://api.vip98k.com/logo.png"],
model: "gpt-image-2-vip",
prompt: "Edit",
};
expect(
bindVip98kImages(body, [
"https://example.com/a.png",
"https://example.com/b.png",
]),
).toEqual(["image"]);
expect(body.image).toEqual([
"https://example.com/a.png",
"https://example.com/b.png",
]);
});
test("keeps V2 processing-task contracts instead of rewriting them to retired routes", () => {
const invocation: Vip98kTaskInvocation = {
kind: "task",
@ -399,6 +418,43 @@ test.describe("VIP98K reference-image routing", () => {
expect(presets).toEqual([]);
});
test("keeps gpt-image-2-vip when its published image field is an array", () => {
const presets = buildVip98kModelPresets([
{
id: "gpt-image-2-vip",
type: "image",
capabilities: [
"image_to_image",
"openai.images.generations",
"text_to_image",
],
invocation: {
kind: "tool",
method: "POST",
path: "/v1/images/generations",
content_type: "application/json",
request_example: {
image: ["https://api.vip98k.com/logo.png"],
model: "gpt-image-2-vip",
n: 1,
prompt: "Generate",
},
},
},
]);
expect(presets).toHaveLength(1);
expect(presets[0]).toMatchObject({
modelId: "gpt-image-2-vip",
supportsVision: true,
isHidden: false,
capabilities: {
supportsMultiImageReference: true,
maxReferenceImages: 8,
},
});
});
test("does not inherit multi-image support from broad authorized metadata", () => {
const presets = buildVip98kModelPresets([
{