fix: support vip98k image array contracts
This commit is contained in:
parent
dffea4cdb3
commit
4ae446fc1f
@ -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}`, {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user