fix: honor vip98k image contracts
This commit is contained in:
parent
f9d1b46d81
commit
a1973ae202
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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] || "";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user