fix: support vip98k image array contracts
This commit is contained in:
parent
6ce7c31a1e
commit
42359fbf0f
@ -832,31 +832,47 @@ async function submitTask(params: {
|
|||||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||||
const path = String(params.path || "/api/external/task/generate").trim();
|
const path = String(params.path || "/api/external/task/generate").trim();
|
||||||
if (!path.startsWith("/")) throw new Error("VIP98K invocation path is invalid");
|
if (!path.startsWith("/")) throw new Error("VIP98K invocation path is invalid");
|
||||||
const stagedReferences = Array.isArray(params.body.file_urls)
|
const stagedReferenceKeys = [
|
||||||
? params.body.file_urls.filter(
|
"file_urls",
|
||||||
(value): value is string =>
|
"images",
|
||||||
typeof value === "string" && isStagedReferenceUrl(value),
|
"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;
|
const maxAttempts = MAX_SUBMISSION_ATTEMPTS;
|
||||||
let refreshStagedReferences = false;
|
let refreshStagedReferences = false;
|
||||||
let idempotencyKey = randomUUID();
|
let idempotencyKey = randomUUID();
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
const requestBody =
|
const requestBody = refreshStagedReferences
|
||||||
!refreshStagedReferences || !Array.isArray(params.body.file_urls)
|
? Object.fromEntries(
|
||||||
? params.body
|
Object.entries(params.body).map(([key, value]) => {
|
||||||
: {
|
if (!stagedReferenceKeys.includes(key as (typeof stagedReferenceKeys)[number])) {
|
||||||
...params.body,
|
return [key, value];
|
||||||
file_urls: params.body.file_urls.map((value) =>
|
}
|
||||||
typeof value === "string" && isStagedReferenceUrl(value)
|
const addRetryToken = (candidate: unknown) =>
|
||||||
|
typeof candidate === "string" && isStagedReferenceUrl(candidate)
|
||||||
? addVip98kReferenceRetryToken(
|
? addVip98kReferenceRetryToken(
|
||||||
value,
|
candidate,
|
||||||
`${Date.now()}-${attempt}`,
|
`${Date.now()}-${attempt}`,
|
||||||
)
|
)
|
||||||
: value,
|
: candidate;
|
||||||
),
|
return [
|
||||||
};
|
key,
|
||||||
|
Array.isArray(value) ? value.map(addRetryToken) : addRetryToken(value),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: params.body;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${baseURL}${path}`, {
|
response = await fetch(`${baseURL}${path}`, {
|
||||||
|
|||||||
@ -328,10 +328,18 @@ function getInvocationRequestKeys(model: Vip98kCatalogModel) {
|
|||||||
return [
|
return [
|
||||||
...Object.keys(toRecord(invocation.request_example) || {}),
|
...Object.keys(toRecord(invocation.request_example) || {}),
|
||||||
...Object.keys(schemaProperties || {}),
|
...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) {
|
function getInvocationCandidates(model: Vip98kCatalogModel) {
|
||||||
const configured = Array.isArray(model.invocations) ? model.invocations : [];
|
const configured = Array.isArray(model.invocations) ? model.invocations : [];
|
||||||
const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])];
|
const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])];
|
||||||
@ -411,7 +419,27 @@ function hasMultiImageReferenceInput(model: Vip98kCatalogModel) {
|
|||||||
"reference-images",
|
"reference-images",
|
||||||
"video-clips",
|
"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) {
|
export function isVip98kMultiImageEditCatalogModel(model: Vip98kCatalogModel) {
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export function bindVip98kImages(
|
|||||||
}
|
}
|
||||||
for (const key of IMAGE_SINGLE_INPUT_KEYS) {
|
for (const key of IMAGE_SINGLE_INPUT_KEYS) {
|
||||||
if (!(key in body)) continue;
|
if (!(key in body)) continue;
|
||||||
body[key] = imageUrls[0];
|
body[key] = Array.isArray(body[key]) ? imageUrls : imageUrls[0];
|
||||||
bindingKeys.push(key);
|
bindingKeys.push(key);
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.video_clips)) {
|
if (Array.isArray(body.video_clips)) {
|
||||||
@ -188,6 +188,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
|
|||||||
let score = 0;
|
let score = 0;
|
||||||
if ("file_urls" in body) score += 60;
|
if ("file_urls" in body) score += 60;
|
||||||
if (IMAGE_ARRAY_INPUT_KEYS.some((key) => key in body)) score += 40;
|
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 (Array.isArray(body.video_clips)) score += 35;
|
||||||
if (IMAGE_SINGLE_INPUT_KEYS.some((key) => key in body)) score += 15;
|
if (IMAGE_SINGLE_INPUT_KEYS.some((key) => key in body)) score += 15;
|
||||||
if (invocation.kind === "task") score += 10;
|
if (invocation.kind === "task") score += 10;
|
||||||
|
|||||||
@ -25,24 +25,20 @@ function isQualifiedVip98kImagePreset(preset: Vip98kModelPreset) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const apply = hasFlag("--apply");
|
const apply = hasFlag("--apply");
|
||||||
const databaseUrl = String(process.env.POSTGRES_URL || "").trim();
|
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 (!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 });
|
const sql = postgres(databaseUrl, { prepare: false, max: 1 });
|
||||||
try {
|
try {
|
||||||
const providers = await sql<
|
const providers = await sql<
|
||||||
Array<{ id: string; type: string; name: string; isEnabled: boolean }>
|
Array<{
|
||||||
>`select id, type, name, "isEnabled" from "AiProvider" order by "createdAt"`;
|
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 vip98kProviders = providers.filter((provider) => provider.type === "vip98k");
|
||||||
const alibabaProviders = providers.filter(
|
const alibabaProviders = providers.filter(
|
||||||
(provider) => provider.type === "alibaba-image",
|
(provider) => provider.type === "alibaba-image",
|
||||||
@ -55,6 +51,23 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const vip98kProvider = vip98kProviders[0];
|
const vip98kProvider = vip98kProviders[0];
|
||||||
if (!vip98kProvider) throw new Error("VIP98K provider is missing");
|
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<
|
const existingVip98kModels = await sql<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
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) {
|
function supportsMultipleImages(invocation: Invocation) {
|
||||||
return requestKeys(invocation).some((key) =>
|
return Boolean(getMultiImageInputKey(invocation));
|
||||||
["file_urls", "images", "image_urls", "reference_images"].includes(key),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractStatus(payload: unknown) {
|
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",
|
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 keys = requestKeys(invocation);
|
||||||
const nativeAspectRatio = keys.some((key) => ["ratio", "aspect_ratio"].includes(key));
|
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) };
|
const body = { ...asRecord(invocation.request_example) };
|
||||||
body.model = modelId;
|
body.model = modelId;
|
||||||
body.file_urls = REFERENCE_URLS;
|
body[multiImageInputKey] = REFERENCE_URLS;
|
||||||
body.prompt = TEST_PROMPT;
|
body.prompt = TEST_PROMPT;
|
||||||
if (keys.includes("aspect_ratio")) body.aspect_ratio = "16:9";
|
if (keys.includes("aspect_ratio")) body.aspect_ratio = "16:9";
|
||||||
if (keys.includes("resolution")) body.resolution = "1K";
|
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", () => {
|
test("keeps V2 processing-task contracts instead of rewriting them to retired routes", () => {
|
||||||
const invocation: Vip98kTaskInvocation = {
|
const invocation: Vip98kTaskInvocation = {
|
||||||
kind: "task",
|
kind: "task",
|
||||||
@ -399,6 +418,43 @@ test.describe("VIP98K reference-image routing", () => {
|
|||||||
expect(presets).toEqual([]);
|
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", () => {
|
test("does not inherit multi-image support from broad authorized metadata", () => {
|
||||||
const presets = buildVip98kModelPresets([
|
const presets = buildVip98kModelPresets([
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user