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, recordAiModelHealthFailure,
recordAiModelHealthSuccess, recordAiModelHealthSuccess,
shouldFailoverAiModelError, shouldFailoverAiModelError,
shouldFailoverVip98kRequestRejection,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task"; import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence"; 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); 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."); chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
throw primaryError; throw primaryError;
} }

View File

@ -11,6 +11,7 @@ import sharp from "sharp";
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries"; import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
import { import {
bindVip98kImages, bindVip98kImages,
normalizeVip98kInvocationHeaders,
resolveVip98kImageTaskSize, resolveVip98kImageTaskSize,
selectVip98kTaskInvocation, selectVip98kTaskInvocation,
type Vip98kTaskInvocation, type Vip98kTaskInvocation,
@ -160,6 +161,7 @@ function parseVip98kInvocation(value: unknown): Vip98kInvocation | null {
method: String(invocation.method || "POST").trim().toUpperCase(), method: String(invocation.method || "POST").trim().toUpperCase(),
path, path,
contentType: String(invocation.contentType || "application/json").trim(), contentType: String(invocation.contentType || "application/json").trim(),
headers: normalizeVip98kInvocationHeaders(invocation.headers),
requestExample: { ...(toRecord(invocation.requestExample) || {}) }, requestExample: { ...(toRecord(invocation.requestExample) || {}) },
inputSchema: inputSchema:
toRecord(invocation.inputSchema) || toRecord(invocation.input_schema), toRecord(invocation.inputSchema) || toRecord(invocation.input_schema),
@ -1015,6 +1017,7 @@ async function submitTask(params: {
path?: string | null; path?: string | null;
method?: string | null; method?: string | null;
contentType?: string | null; contentType?: string | null;
headers?: Record<string, string> | null;
body: Record<string, unknown>; body: Record<string, unknown>;
submissionTimeoutMs: number; submissionTimeoutMs: number;
maxSubmissionAttempts: number; maxSubmissionAttempts: number;
@ -1073,6 +1076,7 @@ async function submitTask(params: {
response = await fetch(`${baseURL}${path}`, { response = await fetch(`${baseURL}${path}`, {
method: String(params.method || "POST").toUpperCase(), method: String(params.method || "POST").toUpperCase(),
headers: { headers: {
...normalizeVip98kInvocationHeaders(params.headers),
Authorization: `Bearer ${params.apiKey}`, Authorization: `Bearer ${params.apiKey}`,
"Content-Type": params.contentType || "application/json", "Content-Type": params.contentType || "application/json",
"Idempotency-Key": idempotencyKey, "Idempotency-Key": idempotencyKey,
@ -1258,6 +1262,7 @@ export async function submitVip98kExternalTask(params: {
imageCount: imageUrls.length, imageCount: imageUrls.length,
videoCount: videoUrls.length, videoCount: videoUrls.length,
imageBindingKeys, imageBindingKeys,
invocationHeaderKeys: Object.keys(invocation?.headers || {}).sort(),
outputConstraintFallbacks, outputConstraintFallbacks,
submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs( submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs(
params.modelId, params.modelId,
@ -1273,6 +1278,7 @@ export async function submitVip98kExternalTask(params: {
path: invocation?.path, path: invocation?.path,
method: invocation?.method, method: invocation?.method,
contentType: invocation?.contentType, contentType: invocation?.contentType,
headers: invocation?.headers,
body, body,
submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs( submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs(
params.modelId, params.modelId,
@ -1463,18 +1469,23 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
} }
if ( if (
invocationSupportsBodyField(invocation, "size") && invocationSupportsBodyField(invocation, "size") &&
typeof body.size === "string" &&
!invocationSupportsBodyField(invocation, "resolution") !invocationSupportsBodyField(invocation, "resolution")
) { ) {
const preserveResolutionBucket =
invocation?.path === "/v1/images/generations";
const resolvedTaskSize = resolveVip98kImageTaskSize({ const resolvedTaskSize = resolveVip98kImageTaskSize({
imageSize: resolution, imageSize: resolution,
aspectRatio, aspectRatio,
fallbackSize: body.size, fallbackSize: body.size,
preserveResolutionBucket,
}); });
if (resolvedTaskSize) { if (resolvedTaskSize) {
body.size = resolvedTaskSize; body.size = resolvedTaskSize;
hasNativeResolution = resolvedTaskSize !== "auto"; 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); 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>) { function buildMetadata(value?: Record<string, unknown>) {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
try { 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 const VIP98K_BASE_URL = "https://api.vip98k.com";
export type Vip98kCatalogModelType = export type Vip98kCatalogModelType =
@ -15,6 +17,7 @@ export interface Vip98kInvocationContract {
method?: string; method?: string;
path?: string; path?: string;
content_type?: string; content_type?: string;
headers?: Record<string, unknown>;
request_example?: Record<string, unknown>; request_example?: Record<string, unknown>;
input_schema?: Record<string, unknown>; input_schema?: Record<string, unknown>;
result_method?: string; result_method?: string;
@ -352,7 +355,9 @@ function normalizeInvocationRequestKey(key: string) {
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] : [])]; // 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( return candidates.filter(
(candidate, index, items) => (candidate, index, items) =>
Boolean(candidate && typeof candidate === "object") && Boolean(candidate && typeof candidate === "object") &&
@ -628,6 +633,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
method: String(value.method || "POST"), method: String(value.method || "POST"),
path: String(value.path || ""), path: String(value.path || ""),
contentType: String(value.content_type || "application/json"), contentType: String(value.content_type || "application/json"),
headers: normalizeVip98kInvocationHeaders(value.headers),
requestExample: toRecord(value.request_example) || {}, requestExample: toRecord(value.request_example) || {},
inputSchema: toRecord(value.input_schema), inputSchema: toRecord(value.input_schema),
resultMethod: String(value.result_method || "GET"), resultMethod: String(value.result_method || "GET"),
@ -645,6 +651,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
method: String(invocation.method || "POST"), method: String(invocation.method || "POST"),
path: String(invocation.path || ""), path: String(invocation.path || ""),
contentType: String(invocation.content_type || "application/json"), contentType: String(invocation.content_type || "application/json"),
headers: normalizeVip98kInvocationHeaders(invocation.headers),
requestExample: toRecord(invocation.request_example) || {}, requestExample: toRecord(invocation.request_example) || {},
inputSchema: toRecord(invocation.input_schema), inputSchema: toRecord(invocation.input_schema),
resultMethod: String(invocation.result_method || "GET"), resultMethod: String(invocation.result_method || "GET"),
@ -690,7 +697,7 @@ function createImagePreset(
const supportsNativeAspectRatio = [ const supportsNativeAspectRatio = [
"ratio", "ratio",
"aspect-ratio", "aspect-ratio",
].some((key) => invocationKeys.has(key)) || supportsNativeSize; ].some((key) => invocationKeys.has(key));
const supportsQuality = invocationKeys.has("quality"); const supportsQuality = invocationKeys.has("quality");
const supportedModes = [ const supportedModes = [
...(capabilities.includes("text-to-image") || capabilities.length === 0 ...(capabilities.includes("text-to-image") || capabilities.length === 0
@ -1160,6 +1167,8 @@ export async function fetchVip98kCatalog(params: {
return authorizedModels.map((model) => { return authorizedModels.map((model) => {
const publicModel = publicById.get(getModelId(model).toLowerCase()); const publicModel = publicById.get(getModelId(model).toLowerCase());
if (!publicModel) return model; if (!publicModel) return model;
const authorizedResolutions = getSupportedResolutions(model);
const publicResolutions = getSupportedResolutions(publicModel);
return { return {
...publicModel, ...publicModel,
...model, ...model,
@ -1168,6 +1177,10 @@ export async function fetchVip98kCatalog(params: {
serviceOffers: publicModel.serviceOffers || publicModel.service_offers, serviceOffers: publicModel.serviceOffers || publicModel.service_offers,
taskTypes: model.taskTypes || model.task_types || publicModel.taskTypes || publicModel.task_types, taskTypes: model.taskTypes || model.task_types || publicModel.taskTypes || publicModel.task_types,
capabilities: model.capabilities || publicModel.capabilities, capabilities: model.capabilities || publicModel.capabilities,
supportedResolutions:
authorizedResolutions.length > 0
? authorizedResolutions
: publicResolutions,
}; };
}); });
} }

View File

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

View File

@ -3,6 +3,7 @@ import {
isMediaInputFailure, isMediaInputFailure,
isNonProviderRequestStatus, isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification"; } from "@/lib/ai/runtime/request-failure-classification";
import { shouldFailoverVip98kRequestRejection } from "@/lib/ai/runtime/model-health";
test.describe("AI model health request classification", () => { test.describe("AI model health request classification", () => {
test("does not treat rejected media as a provider outage", () => { 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); 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 { expect, test } from "@playwright/test";
import { import {
bindVip98kImages, bindVip98kImages,
normalizeVip98kInvocationHeaders,
resolveVip98kTaskInvocation, resolveVip98kTaskInvocation,
resolveVip98kImageTaskSize, resolveVip98kImageTaskSize,
selectVip98kTaskInvocation, selectVip98kTaskInvocation,
@ -38,6 +39,70 @@ const textToImageInvocation: Vip98kTaskInvocation = {
}; };
test.describe("VIP98K reference-image routing", () => { 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", () => { test("keeps every restored BananaImage preset multi-reference capable", () => {
expect(BANANAIMAGE_MODEL_PRESETS.map((preset) => preset.modelId)).toEqual([ expect(BANANAIMAGE_MODEL_PRESETS.map((preset) => preset.modelId)).toEqual([
"gemini-3-pro-image-preview", "gemini-3-pro-image-preview",
@ -680,5 +745,50 @@ test.describe("VIP98K reference-image routing", () => {
fallbackSize: "auto", fallbackSize: "auto",
}), }),
).toBe("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"],
});
}); });
}); });