fix: honor vip98k image contracts

This commit is contained in:
134 2026-09-10 16:19:49 +08:00
parent f9d1b46d81
commit a1973ae202
7 changed files with 200 additions and 5 deletions

View File

@ -197,6 +197,7 @@ import {
recordAiModelHealthFailure,
recordAiModelHealthSuccess,
shouldFailoverAiModelError,
shouldFailoverVip98kRequestRejection,
} from "@/lib/ai/runtime/model-health";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
@ -9945,7 +9946,13 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError);
});
if (!shouldFailoverAiModelError(primaryError)) {
if (
!shouldFailoverAiModelError(primaryError) &&
!shouldFailoverVip98kRequestRejection(
primaryError,
(languageModel as any).provider,
)
) {
chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
throw primaryError;
}

View File

@ -11,6 +11,7 @@ import sharp from "sharp";
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
import {
bindVip98kImages,
normalizeVip98kInvocationHeaders,
resolveVip98kImageTaskSize,
selectVip98kTaskInvocation,
type Vip98kTaskInvocation,
@ -160,6 +161,7 @@ function parseVip98kInvocation(value: unknown): Vip98kInvocation | null {
method: String(invocation.method || "POST").trim().toUpperCase(),
path,
contentType: String(invocation.contentType || "application/json").trim(),
headers: normalizeVip98kInvocationHeaders(invocation.headers),
requestExample: { ...(toRecord(invocation.requestExample) || {}) },
inputSchema:
toRecord(invocation.inputSchema) || toRecord(invocation.input_schema),
@ -1015,6 +1017,7 @@ async function submitTask(params: {
path?: string | null;
method?: string | null;
contentType?: string | null;
headers?: Record<string, string> | null;
body: Record<string, unknown>;
submissionTimeoutMs: number;
maxSubmissionAttempts: number;
@ -1073,6 +1076,7 @@ async function submitTask(params: {
response = await fetch(`${baseURL}${path}`, {
method: String(params.method || "POST").toUpperCase(),
headers: {
...normalizeVip98kInvocationHeaders(params.headers),
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": params.contentType || "application/json",
"Idempotency-Key": idempotencyKey,
@ -1258,6 +1262,7 @@ export async function submitVip98kExternalTask(params: {
imageCount: imageUrls.length,
videoCount: videoUrls.length,
imageBindingKeys,
invocationHeaderKeys: Object.keys(invocation?.headers || {}).sort(),
outputConstraintFallbacks,
submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs(
params.modelId,
@ -1273,6 +1278,7 @@ export async function submitVip98kExternalTask(params: {
path: invocation?.path,
method: invocation?.method,
contentType: invocation?.contentType,
headers: invocation?.headers,
body,
submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs(
params.modelId,
@ -1463,18 +1469,23 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
}
if (
invocationSupportsBodyField(invocation, "size") &&
typeof body.size === "string" &&
!invocationSupportsBodyField(invocation, "resolution")
) {
const preserveResolutionBucket =
invocation?.path === "/v1/images/generations";
const resolvedTaskSize = resolveVip98kImageTaskSize({
imageSize: resolution,
aspectRatio,
fallbackSize: body.size,
preserveResolutionBucket,
});
if (resolvedTaskSize) {
body.size = resolvedTaskSize;
hasNativeResolution = resolvedTaskSize !== "auto";
hasNativeAspectRatio = Boolean(aspectRatio) && resolvedTaskSize !== "auto";
hasNativeAspectRatio =
Boolean(aspectRatio) &&
resolvedTaskSize !== "auto" &&
!preserveResolutionBucket;
}
}
}

View File

@ -424,6 +424,22 @@ export function shouldFailoverAiModelError(error: unknown) {
return /(?:^|\D)[45]\d\d(?:\D|$)/.test(normalized);
}
export function shouldFailoverVip98kRequestRejection(
error: unknown,
providerType: unknown,
) {
if (String(providerType || "").trim().toLowerCase() !== "vip98k") {
return false;
}
const normalized = getErrorMessage(error).trim().toLowerCase();
return (
/(?:^|\D)400(?:\D|$)/.test(normalized) &&
(normalized.includes("the request is invalid") ||
normalized.includes("invalid request"))
);
}
function buildMetadata(value?: Record<string, unknown>) {
if (!value || typeof value !== "object") return null;
try {

View File

@ -1,3 +1,5 @@
import { normalizeVip98kInvocationHeaders } from "@/lib/ai/vip98k-task-invocation";
export const VIP98K_BASE_URL = "https://api.vip98k.com";
export type Vip98kCatalogModelType =
@ -15,6 +17,7 @@ export interface Vip98kInvocationContract {
method?: string;
path?: string;
content_type?: string;
headers?: Record<string, unknown>;
request_example?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
result_method?: string;
@ -352,7 +355,9 @@ function normalizeInvocationRequestKey(key: string) {
function getInvocationCandidates(model: Vip98kCatalogModel) {
const configured = Array.isArray(model.invocations) ? model.invocations : [];
const candidates = [...configured, ...(model.invocation ? [model.invocation] : [])];
// The singular invocation is the provider-selected route and may carry an
// offer-specific X-Relay header. Keep it ahead of generic alternatives.
const candidates = [...(model.invocation ? [model.invocation] : []), ...configured];
return candidates.filter(
(candidate, index, items) =>
Boolean(candidate && typeof candidate === "object") &&
@ -628,6 +633,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
method: String(value.method || "POST"),
path: String(value.path || ""),
contentType: String(value.content_type || "application/json"),
headers: normalizeVip98kInvocationHeaders(value.headers),
requestExample: toRecord(value.request_example) || {},
inputSchema: toRecord(value.input_schema),
resultMethod: String(value.result_method || "GET"),
@ -645,6 +651,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
method: String(invocation.method || "POST"),
path: String(invocation.path || ""),
contentType: String(invocation.content_type || "application/json"),
headers: normalizeVip98kInvocationHeaders(invocation.headers),
requestExample: toRecord(invocation.request_example) || {},
inputSchema: toRecord(invocation.input_schema),
resultMethod: String(invocation.result_method || "GET"),
@ -690,7 +697,7 @@ function createImagePreset(
const supportsNativeAspectRatio = [
"ratio",
"aspect-ratio",
].some((key) => invocationKeys.has(key)) || supportsNativeSize;
].some((key) => invocationKeys.has(key));
const supportsQuality = invocationKeys.has("quality");
const supportedModes = [
...(capabilities.includes("text-to-image") || capabilities.length === 0
@ -1160,6 +1167,8 @@ export async function fetchVip98kCatalog(params: {
return authorizedModels.map((model) => {
const publicModel = publicById.get(getModelId(model).toLowerCase());
if (!publicModel) return model;
const authorizedResolutions = getSupportedResolutions(model);
const publicResolutions = getSupportedResolutions(publicModel);
return {
...publicModel,
...model,
@ -1168,6 +1177,10 @@ export async function fetchVip98kCatalog(params: {
serviceOffers: publicModel.serviceOffers || publicModel.service_offers,
taskTypes: model.taskTypes || model.task_types || publicModel.taskTypes || publicModel.task_types,
capabilities: model.capabilities || publicModel.capabilities,
supportedResolutions:
authorizedResolutions.length > 0
? authorizedResolutions
: publicResolutions,
};
});
}

View File

@ -3,6 +3,7 @@ export type Vip98kTaskInvocation = {
method: string;
path: string;
contentType: string;
headers?: Record<string, string>;
requestExample: Record<string, unknown>;
inputSchema?: Record<string, unknown> | null;
resultMethod: string;
@ -43,6 +44,7 @@ export function resolveVip98kImageTaskSize(params: {
imageSize?: unknown;
aspectRatio?: unknown;
fallbackSize?: unknown;
preserveResolutionBucket?: boolean;
}) {
const explicitSize = normalizeVip98kPixelSize(params.imageSize);
if (explicitSize) return explicitSize;
@ -52,6 +54,7 @@ export function resolveVip98kImageTaskSize(params: {
.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
const sizeBucket = String(params.imageSize || "").trim().toUpperCase();
const longestEdge = VIP98K_IMAGE_SIZE_LONGEST_EDGE[sizeBucket];
if (longestEdge && params.preserveResolutionBucket) return sizeBucket;
if (ratioMatch?.[1] && ratioMatch[2] && longestEdge) {
const ratioWidth = Number(ratioMatch[1]);
const ratioHeight = Number(ratioMatch[2]);
@ -104,6 +107,26 @@ const KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS: Record<
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] || "";

View File

@ -3,6 +3,7 @@ import {
isMediaInputFailure,
isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification";
import { shouldFailoverVip98kRequestRejection } from "@/lib/ai/runtime/model-health";
test.describe("AI model health request classification", () => {
test("does not treat rejected media as a provider outage", () => {
@ -21,4 +22,18 @@ test.describe("AI model health request classification", () => {
);
expect(isNonProviderRequestStatus(503, "service unavailable")).toBe(false);
});
test("fails over generic VIP98K contract rejections without broadening other 400s", () => {
const error = new Error("The request is invalid. (HTTP 400)");
expect(shouldFailoverVip98kRequestRejection(error, "vip98k")).toBe(true);
expect(shouldFailoverVip98kRequestRejection(error, "alibaba-image")).toBe(
false,
);
expect(
shouldFailoverVip98kRequestRejection(
new Error("Unsupported media type (HTTP 400)"),
"vip98k",
),
).toBe(false);
});
});

View File

@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import {
bindVip98kImages,
normalizeVip98kInvocationHeaders,
resolveVip98kTaskInvocation,
resolveVip98kImageTaskSize,
selectVip98kTaskInvocation,
@ -38,6 +39,70 @@ const textToImageInvocation: Vip98kTaskInvocation = {
};
test.describe("VIP98K reference-image routing", () => {
test("keeps the provider-selected relay route header", () => {
const [preset] = buildVip98kModelPresets([
{
id: "gpt-image-2.5-flare",
type: "image",
capabilities: ["image-to-image"],
invocation: {
kind: "tool",
method: "POST",
path: "/v1/images/generations",
content_type: "application/json",
headers: {
"X-Relay-Route-Option": "preferred-route",
Authorization: "must-not-be-forwarded",
},
request_example: {
image: ["https://api.vip98k.com/logo.png"],
model: "gpt-image-2.5-flare",
prompt: "Edit",
},
},
invocations: [
{
kind: "tool",
method: "POST",
path: "/v1/images/generations",
content_type: "application/json",
headers: {},
request_example: {
image: ["https://api.vip98k.com/logo.png"],
model: "gpt-image-2.5-flare",
prompt: "Edit",
},
},
],
},
]);
const metadata = preset?.capabilities?.vip98k as {
invocation?: Vip98kTaskInvocation;
invocations?: Vip98kTaskInvocation[];
};
expect(metadata.invocation?.headers).toEqual({
"X-Relay-Route-Option": "preferred-route",
});
expect(metadata.invocations?.[0]?.headers).toEqual({
"X-Relay-Route-Option": "preferred-route",
});
expect(
selectVip98kTaskInvocation({
modelId: "gpt-image-2.5-flare",
configuredInvocation: metadata.invocation || null,
configuredInvocations: metadata.invocations,
imageCount: 2,
})?.headers,
).toEqual({ "X-Relay-Route-Option": "preferred-route" });
expect(
normalizeVip98kInvocationHeaders({
"X-Relay-Route-Option": "route-2",
Authorization: "blocked",
}),
).toEqual({ "X-Relay-Route-Option": "route-2" });
});
test("keeps every restored BananaImage preset multi-reference capable", () => {
expect(BANANAIMAGE_MODEL_PRESETS.map((preset) => preset.modelId)).toEqual([
"gemini-3-pro-image-preview",
@ -680,5 +745,50 @@ test.describe("VIP98K reference-image routing", () => {
fallbackSize: "auto",
}),
).toBe("auto");
expect(
resolveVip98kImageTaskSize({
imageSize: "4K",
aspectRatio: "1:1",
fallbackSize: "auto",
preserveResolutionBucket: true,
}),
).toBe("4K");
});
test("advertises only catalog resolutions and explicit aspect-ratio controls", () => {
const [preset] = buildVip98kModelPresets([
{
id: "gpt-image-2.5-flare",
type: "image",
capabilities: ["image_to_image"],
supported_resolutions: ["1K", "2K", "4K"],
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.5-flare",
prompt: "Edit",
},
input_schema: {
properties: {
image: { type: "array" },
size: { type: "string" },
},
},
},
},
]);
expect(preset?.imageGenConfig).toMatchObject({
supportsSize: true,
supports1K: true,
supports2K: true,
supports4K: true,
supportedImageSizes: ["1K", "2K", "4K"],
supportedAspectRatios: ["auto"],
});
});
});