466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
export type Vip98kTaskInvocation = {
|
||
kind: string;
|
||
method: string;
|
||
path: string;
|
||
contentType: string;
|
||
headers?: Record<string, string>;
|
||
requestExample: Record<string, unknown>;
|
||
inputSchema?: Record<string, unknown> | null;
|
||
resultMethod: string;
|
||
resultPath: string;
|
||
};
|
||
|
||
type KnownImageInput = "images" | "video_clips";
|
||
|
||
const VIP98K_IMAGE_SIZE_LONGEST_EDGE: Record<string, number> = {
|
||
"1K": 1024,
|
||
"2K": 2048,
|
||
"4K": 4096,
|
||
};
|
||
|
||
const GPT_IMAGE_25_TARGET_PIXELS: Record<string, number> = {
|
||
"1K": 1024 * 1024,
|
||
"2K": 2048 * 2048,
|
||
"4K": 2880 * 2880,
|
||
};
|
||
|
||
function normalizeVip98kPixelSize(value: unknown) {
|
||
const match = String(value || "")
|
||
.trim()
|
||
.match(/^(\d+)\s*[x*×]\s*(\d+)$/i);
|
||
if (!match?.[1] || !match[2]) return null;
|
||
const sourceWidth = Number(match[1]);
|
||
const sourceHeight = Number(match[2]);
|
||
if (
|
||
!Number.isFinite(sourceWidth) ||
|
||
!Number.isFinite(sourceHeight) ||
|
||
sourceWidth < 1 ||
|
||
sourceHeight < 1
|
||
) {
|
||
return null;
|
||
}
|
||
const scale = Math.min(1, 4096 / Math.max(sourceWidth, sourceHeight));
|
||
return `${Math.max(1, Math.round(sourceWidth * scale))}x${Math.max(
|
||
1,
|
||
Math.round(sourceHeight * scale),
|
||
)}`;
|
||
}
|
||
|
||
export function resolveVip98kImageTaskSize(params: {
|
||
imageSize?: unknown;
|
||
aspectRatio?: unknown;
|
||
fallbackSize?: unknown;
|
||
preserveResolutionBucket?: boolean;
|
||
useGptImage25AspectSizing?: boolean;
|
||
}) {
|
||
const explicitSize = normalizeVip98kPixelSize(params.imageSize);
|
||
if (explicitSize) return explicitSize;
|
||
|
||
const ratioMatch = String(params.aspectRatio || "")
|
||
.trim()
|
||
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
|
||
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
|
||
const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket];
|
||
if (params.useGptImage25AspectSizing && sizeBucket in GPT_IMAGE_25_TARGET_PIXELS && (!ratioMatch || Number(ratioMatch[1]) === Number(ratioMatch[2]))) {
|
||
const edge = Math.sqrt(GPT_IMAGE_25_TARGET_PIXELS[sizeBucket]);
|
||
return `${edge}x${edge}`;
|
||
}
|
||
if (
|
||
params.useGptImage25AspectSizing &&
|
||
ratioMatch?.[1] &&
|
||
ratioMatch[2] &&
|
||
sizeBucket in GPT_IMAGE_25_TARGET_PIXELS
|
||
) {
|
||
const ratioWidth = Number(ratioMatch[1]);
|
||
const ratioHeight = Number(ratioMatch[2]);
|
||
if (ratioWidth > 0 && ratioHeight > 0 && ratioWidth !== ratioHeight) {
|
||
const ratio = ratioWidth / ratioHeight;
|
||
const targetPixels = GPT_IMAGE_25_TARGET_PIXELS[sizeBucket] || 0;
|
||
let width = Math.sqrt(targetPixels * ratio);
|
||
let height = Math.sqrt(targetPixels / ratio);
|
||
const maxEdgeScale = Math.min(1, 3840 / Math.max(width, height));
|
||
width *= maxEdgeScale;
|
||
height *= maxEdgeScale;
|
||
const clampDimension = (value: number) =>
|
||
Math.max(16, Math.min(3840, value));
|
||
const roundToSixteen = (value: number) =>
|
||
clampDimension(Math.round(value / 16) * 16);
|
||
const floorToSixteen = (value: number) =>
|
||
clampDimension(Math.floor(value / 16) * 16);
|
||
const shouldFloorWidth = sizeBucket !== "4K" && width < height;
|
||
const shouldFloorHeight = sizeBucket !== "4K" && height < width;
|
||
let outputWidth = shouldFloorWidth ? floorToSixteen(width) : roundToSixteen(width);
|
||
let outputHeight = shouldFloorHeight ? floorToSixteen(height) : roundToSixteen(height);
|
||
// Flare promotes even a small area overflow to the next tier (2736x1536 -> 4K).
|
||
if (sizeBucket !== "4K" && outputWidth * outputHeight > targetPixels) {
|
||
if (outputWidth >= outputHeight) outputWidth -= 16;
|
||
else outputHeight -= 16;
|
||
}
|
||
return `${outputWidth}x${outputHeight}`;
|
||
}
|
||
}
|
||
if (longestEdge && params.preserveResolutionBucket) return sizeBucket;
|
||
if (ratioMatch?.[1] && ratioMatch[2] && longestEdge) {
|
||
const ratioWidth = Number(ratioMatch[1]);
|
||
const ratioHeight = Number(ratioMatch[2]);
|
||
if (ratioWidth > 0 && ratioHeight > 0) {
|
||
const scale = longestEdge / Math.max(ratioWidth, ratioHeight);
|
||
const roundToEight = (value: number) =>
|
||
Math.max(8, Math.min(4096, Math.round(value / 8) * 8));
|
||
return `${roundToEight(ratioWidth * scale)}x${roundToEight(
|
||
ratioHeight * scale,
|
||
)}`;
|
||
}
|
||
}
|
||
|
||
const fallbackPixelSize = normalizeVip98kPixelSize(params.fallbackSize);
|
||
if (fallbackPixelSize) return fallbackPixelSize;
|
||
return String(params.fallbackSize || "").trim().toLowerCase() === "auto"
|
||
? "auto"
|
||
: undefined;
|
||
}
|
||
|
||
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",
|
||
"openai/gpt-image-2/edit": "images",
|
||
};
|
||
|
||
const VERIFIED_OPENAI_IMAGE_ARRAY_MODEL_IDS = new Set([
|
||
"gpt-image-2-vip",
|
||
"gpt-image-2.5",
|
||
"gpt-image-2.5-flare",
|
||
]);
|
||
|
||
const VERIFIED_GPT_IMAGE_25_RESOLUTION_MODEL_IDS = new Set([
|
||
// Paid two-reference probes on 2026-09-11; 4K square is 2880px, not 4096px.
|
||
"gpt-image-2-vip",
|
||
"gpt-image-2.5-flare",
|
||
]);
|
||
|
||
export function hasVerifiedVip98kOpenAiImageArrayContract(modelId: string) {
|
||
return VERIFIED_OPENAI_IMAGE_ARRAY_MODEL_IDS.has(
|
||
modelId.trim().toLowerCase(),
|
||
);
|
||
}
|
||
|
||
export function hasVerifiedVip98kGptImage25ResolutionTiers(modelId: string) {
|
||
return VERIFIED_GPT_IMAGE_25_RESOLUTION_MODEL_IDS.has(
|
||
modelId.trim().toLowerCase(),
|
||
);
|
||
}
|
||
|
||
const KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS: Record<
|
||
string,
|
||
Record<string, unknown>
|
||
> = {
|
||
"remove-background": { image_url: "" },
|
||
enhance: { image_url: "", scale_factor: 4 },
|
||
};
|
||
|
||
export function normalizeVip98kInvocationHeaders(value: unknown) {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||
|
||
return Object.fromEntries(
|
||
Object.entries(value as Record<string, unknown>).flatMap(([key, headerValue]) => {
|
||
const normalizedKey = key.trim();
|
||
if (!/^x-relay-[a-z0-9-]+$/i.test(normalizedKey)) return [];
|
||
if (
|
||
typeof headerValue !== "string" &&
|
||
typeof headerValue !== "number" &&
|
||
typeof headerValue !== "boolean"
|
||
) {
|
||
return [];
|
||
}
|
||
const normalizedValue = String(headerValue).trim();
|
||
return normalizedValue ? [[normalizedKey, normalizedValue]] : [];
|
||
}),
|
||
);
|
||
}
|
||
|
||
function getInvocationToolId(invocation: Vip98kTaskInvocation | null) {
|
||
if (!invocation || invocation.kind !== "tool") return null;
|
||
const query = invocation.path.split("?", 2)[1] || "";
|
||
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 || {};
|
||
const properties = (invocation.inputSchema?.properties || {}) as Record<string, unknown>;
|
||
return [
|
||
...IMAGE_ARRAY_INPUT_KEYS.filter((key) => key in body || key in properties),
|
||
...IMAGE_SINGLE_INPUT_KEYS.filter((key) => key in body || key in properties),
|
||
...(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] = Array.isArray(body[key]) ? imageUrls : 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 (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;
|
||
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) &&
|
||
JSON.stringify(item.inputSchema || null) ===
|
||
JSON.stringify(candidate.inputSchema || null),
|
||
) === 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
|
||
): Vip98kTaskInvocation {
|
||
const encodedModelId = encodeURIComponent(modelId);
|
||
const requestExample =
|
||
imageInput === "video_clips"
|
||
? {
|
||
prompt: "",
|
||
video_clips: [{ fps: 1, url: "", ends: 0, start: 0 }],
|
||
}
|
||
: { prompt: "", images: [""] };
|
||
|
||
return {
|
||
kind: "tool",
|
||
method: "POST",
|
||
path: `/api/external/tools/generate?tool_id=${encodedModelId}`,
|
||
contentType: "application/json",
|
||
requestExample,
|
||
resultMethod: "GET",
|
||
resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`,
|
||
};
|
||
}
|
||
|
||
function createKnownProcessingToolInvocation(
|
||
modelId: string,
|
||
requestExample: Record<string, unknown>
|
||
): Vip98kTaskInvocation {
|
||
const encodedModelId = encodeURIComponent(modelId);
|
||
return {
|
||
kind: "tool",
|
||
method: "POST",
|
||
path: `/api/external/tools/generate?tool_id=${encodedModelId}`,
|
||
contentType: "application/json",
|
||
requestExample: { ...requestExample },
|
||
resultMethod: "GET",
|
||
resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`,
|
||
};
|
||
}
|
||
|
||
function createVerifiedOpenAiImageInvocation(modelId: string) {
|
||
return {
|
||
kind: "tool",
|
||
method: "POST",
|
||
path: "/v1/images/generations",
|
||
contentType: "application/json",
|
||
requestExample: {
|
||
image: [],
|
||
model: modelId,
|
||
n: 1,
|
||
prompt: "",
|
||
quality: "auto",
|
||
size: "1024x1024",
|
||
stream: false,
|
||
},
|
||
inputSchema: {
|
||
properties: {
|
||
aspect_ratio: { type: "string" },
|
||
},
|
||
type: "object",
|
||
},
|
||
resultMethod: "GET",
|
||
resultPath: "",
|
||
} satisfies Vip98kTaskInvocation;
|
||
}
|
||
|
||
function addVerifiedOpenAiImageFields(
|
||
invocation: Vip98kTaskInvocation,
|
||
modelId: string,
|
||
): Vip98kTaskInvocation {
|
||
const inputSchema = invocation.inputSchema || {};
|
||
const properties =
|
||
inputSchema.properties &&
|
||
typeof inputSchema.properties === "object" &&
|
||
!Array.isArray(inputSchema.properties)
|
||
? (inputSchema.properties as Record<string, unknown>)
|
||
: {};
|
||
return {
|
||
...invocation,
|
||
requestExample: {
|
||
...invocation.requestExample,
|
||
image: [],
|
||
},
|
||
inputSchema: {
|
||
...inputSchema,
|
||
properties: {
|
||
...properties,
|
||
image: { items: { type: "string" }, type: "array" },
|
||
aspect_ratio: { type: "string" },
|
||
...(hasVerifiedVip98kGptImage25ResolutionTiers(modelId)
|
||
? {
|
||
size: {
|
||
enum: ["1024x1024", "2048x2048", "2880x2880"],
|
||
type: "string",
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A frontend alias may resolve to a different VIP98K tool at runtime. In that
|
||
* case the selected model's invocation cannot be reused because its endpoint
|
||
* and reference-image field belong to the alias rather than the resolved tool.
|
||
*/
|
||
export function resolveVip98kTaskInvocation(
|
||
modelId: string,
|
||
configuredInvocation: Vip98kTaskInvocation | null
|
||
) {
|
||
const normalizedModelId = modelId.trim().toLowerCase();
|
||
if (hasVerifiedVip98kOpenAiImageArrayContract(normalizedModelId)) {
|
||
const invocation =
|
||
configuredInvocation &&
|
||
invocationTargetsModel(configuredInvocation, normalizedModelId)
|
||
? configuredInvocation
|
||
: createVerifiedOpenAiImageInvocation(modelId);
|
||
return addVerifiedOpenAiImageFields(invocation, normalizedModelId);
|
||
}
|
||
const processingRequest = KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS[normalizedModelId];
|
||
if (processingRequest) {
|
||
if (invocationTargetsModel(configuredInvocation, normalizedModelId)) {
|
||
return configuredInvocation;
|
||
}
|
||
return createKnownProcessingToolInvocation(modelId, processingRequest);
|
||
}
|
||
|
||
const knownImageInput = KNOWN_IMAGE_TOOL_INPUTS[normalizedModelId];
|
||
if (!knownImageInput) return configuredInvocation;
|
||
|
||
if (invocationTargetsModel(configuredInvocation, normalizedModelId)) {
|
||
return configuredInvocation;
|
||
}
|
||
|
||
return createKnownImageToolInvocation(modelId, knownImageInput);
|
||
}
|