ai-chatbot/lib/ai/vip98k-task-invocation.ts
WuYongJie 00cef71be7 暂停欠费生图线路并修正 98K 文生图尺寸传递
## Summary

生图线路确认欠费后立即停止新请求,跳过同一上游账户的其他模型,保留已生成结果和已提交任务。欠费状态须经管理员恢复,不再定时自动解封,也不会被旧任务的成功回执清除。旧模型选择仅使用明确配置且尺寸、比例和参考图数量兼容的备用模型;固定模型模式仍要求用户自行选择。

98K 文生图优先使用其公开声明的任务接口,原生传递分辨率和比例,清除文生图请求中的示例参考图片。

## Scope

- [x] API / backend logic
- [x] Settings / admin configuration

## Required Verification

验证普通 Pro、Banana 2、两个 Plus 和 GPT Image 2 共 10 个真实文生图及参考图编辑用例,均获得可下载的 2048×2048 图片。625 项 helper 回归通过,1 项跳过;TypeScript、日志/品牌/CI/环境/凭据/元数据/测试/产物检查通过。全仓 Biome 仍有历史格式和 lint 诊断(1005 项)。构建机执行生产构建及路由烟测;生产切换前核对任务与请求,保留旧构建和模型配置回滚记录。

## Release Readiness

仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
2026-11-03 19:20:18 +08:00

534 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
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;
}
/** Only relay routing metadata may accompany our own authorization headers. */
export function getVip98kInvocationHeaders(
value: unknown
): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const entry = Object.entries(value).find(
([key]) => key.toLowerCase() === "x-relay-route-option"
);
const route = entry?.[1];
return typeof route === "string" && /^[a-zA-Z0-9_-]{1,160}$/.test(route)
? { "X-Relay-Route-Option": route }
: {};
}
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;
kind?: 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);
}
}
if (params.kind === "image" && !params.imageCount) {
const textImageTask = candidates.find(candidate => {
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
const required = candidate.inputSchema?.required as string[] | undefined;
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
});
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
}
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);
}
/** Convert final canvas multipliers to the provider's per-side expansion ratios. */
export function bindVip98kExpansionParams(
body: Record<string, unknown>,
params: { xScale?: unknown; yScale?: unknown }
) {
if (params.xScale === undefined && params.yScale === undefined) return;
const ratio = (value: unknown) => {
const scale = Number(value ?? 1.5);
if (!Number.isFinite(scale) || scale <= 1 || scale > 3)
throw new Error("VIP98K 扩图倍数必须大于 1 且不超过 3");
return Number(((scale - 1) / 2).toFixed(8));
};
const directions = {
left: ratio(params.xScale),
right: ratio(params.xScale),
top: ratio(params.yScale),
bottom: ratio(params.yScale),
};
const metadata =
body.metadata &&
typeof body.metadata === "object" &&
!Array.isArray(body.metadata)
? { ...(body.metadata as Record<string, unknown>) }
: {};
const rootDirections = Object.keys(directions).some((key) => key in body);
for (const key of Object.keys(directions)) {
delete body[key];
delete metadata[key];
}
if (rootDirections) {
Object.assign(body, directions);
if (Object.keys(metadata).length) body.metadata = metadata;
else delete body.metadata;
} else {
body.metadata = { ...metadata, ...directions };
}
}