From a1973ae202a1fbe9e29e109acfc4bef954757e9b Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 10 Sep 2026 16:19:49 +0800 Subject: [PATCH] fix: honor vip98k image contracts --- app/(chat)/api/chat/route.ts | 9 +- lib/ai/providers/vip98k.ts | 15 ++- lib/ai/runtime/model-health.ts | 16 +++ lib/ai/vip98k-models.ts | 17 ++- lib/ai/vip98k-task-invocation.ts | 23 ++++ .../model-health-classification.test.ts | 15 +++ tests/routes/vip98k-reference-routing.test.ts | 110 ++++++++++++++++++ 7 files changed, 200 insertions(+), 5 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 5b36060c8..69cb10969 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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; } diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 87ae6f5bd..05af412db 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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 | null; body: Record; 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; } } } diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index 0eb4a02c1..a88920a92 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -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) { if (!value || typeof value !== "object") return null; try { diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index c1374ebbb..106cb3654 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -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; request_example?: Record; input_schema?: Record; 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, }; }); } diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 5b6c87a8d..2a868cb3a 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -3,6 +3,7 @@ export type Vip98kTaskInvocation = { method: string; path: string; contentType: string; + headers?: Record; requestExample: Record; inputSchema?: Record | 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).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] || ""; diff --git a/tests/routes/model-health-classification.test.ts b/tests/routes/model-health-classification.test.ts index 6435a7073..31b9b881f 100644 --- a/tests/routes/model-health-classification.test.ts +++ b/tests/routes/model-health-classification.test.ts @@ -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); + }); }); diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 2a92a2b5d..00a67610a 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -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"], + }); }); });