fix(ai): honor provider chat contracts and prevent unsafe retries
This commit is contained in:
parent
86c565d34c
commit
f0761e7f61
@ -45,7 +45,9 @@ import { isBltcyVideoBaseUrl } from "./bltcy-video-models";
|
||||
import { resolveModelRuntimeId } from "./model-runtime";
|
||||
|
||||
// 缓存已创建的 provider 实例
|
||||
const providerCache = new Map<string, any>();
|
||||
const providerCache = new Map<string, {
|
||||
type: string; baseUrl: string; apiKey: string; instance: any;
|
||||
}>();
|
||||
const logger = createLogger("dynamic-provider");
|
||||
|
||||
function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) {
|
||||
@ -83,10 +85,10 @@ function logChatModelResult(providerType: string, baseModel: unknown) {
|
||||
function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl" | "apiKey">) {
|
||||
const resolvedBaseUrl =
|
||||
provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || "";
|
||||
const cacheKey = `${provider.id}-${resolvedBaseUrl}`;
|
||||
|
||||
if (providerCache.has(cacheKey)) {
|
||||
return providerCache.get(cacheKey);
|
||||
const cached = providerCache.get(provider.id);
|
||||
if (cached && cached.type === provider.type && cached.baseUrl === resolvedBaseUrl &&
|
||||
cached.apiKey === provider.apiKey) {
|
||||
return cached.instance;
|
||||
}
|
||||
|
||||
let providerInstance: ReturnType<typeof createOpenAI>;
|
||||
@ -401,7 +403,9 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
providerCache.set(cacheKey, providerInstance);
|
||||
providerCache.set(provider.id, {
|
||||
type: provider.type, baseUrl: resolvedBaseUrl, apiKey: provider.apiKey, instance: providerInstance,
|
||||
});
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
|
||||
195
lib/ai/providers/vip98k-chat.ts
Normal file
195
lib/ai/providers/vip98k-chat.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { APICallError, type LanguageModelV2, type LanguageModelV2CallOptions } from "@ai-sdk/provider";
|
||||
import { selectVip98kChatInvocation } from "../vip98k-chat-contract";
|
||||
|
||||
const contractCache = new Map<string, {
|
||||
expiresAt: number;
|
||||
promise: Promise<Map<string, unknown>>;
|
||||
}>();
|
||||
|
||||
function discoverContracts(root: string, baseFetch: typeof fetch) {
|
||||
const cached = contractCache.get(root);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.promise;
|
||||
const entry = {
|
||||
expiresAt: Date.now() + 5 * 60_000,
|
||||
promise: (async () => {
|
||||
// This is a public catalog: never attach the model API key to discovery.
|
||||
const response = await baseFetch(`${root}/api/v2/catalog/models`, {
|
||||
headers: { Accept: "application/json" }, redirect: "error",
|
||||
signal: AbortSignal.timeout(12_000), cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("VIP98K 调用规范获取失败,请稍后同步模型目录。");
|
||||
const payload = record(await response.json());
|
||||
const data = record(payload?.data);
|
||||
const sections = Array.isArray(data?.sections) ? data.sections : [];
|
||||
const models = new Map<string, unknown>();
|
||||
for (const section of sections) {
|
||||
const items = record(section)?.models;
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
const model = record(item);
|
||||
if (model?.id) models.set(String(model.id), {
|
||||
chatInvocation: selectVip98kChatInvocation(model),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!models.size) throw new Error("VIP98K 未返回可用的调用规范,请同步模型目录。");
|
||||
return models;
|
||||
})(),
|
||||
};
|
||||
if (contractCache.size >= 32) contractCache.delete(contractCache.keys().next().value!);
|
||||
contractCache.set(root, entry);
|
||||
// Do not cache errors or delete a newer entry when an older fetch fails.
|
||||
entry.promise.catch(() => {
|
||||
if (contractCache.get(root) === entry) contractCache.delete(root);
|
||||
});
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
async function awaitWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
signal.throwIfAborted();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
||||
});
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function emptyResponseError() {
|
||||
return new APICallError({
|
||||
message: "VIP98K 未返回可用的回答或工具调用,请检查模型通道及输出额度;未自动重复提交。",
|
||||
url: "", requestBodyValues: undefined, isRetryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
function requiresToolCall(options: LanguageModelV2CallOptions) {
|
||||
return options.toolChoice?.type === "required" || options.toolChoice?.type === "tool";
|
||||
}
|
||||
|
||||
function missingToolError() {
|
||||
return new APICallError({
|
||||
message: "VIP98K 通道未返回所要求的工具调用,无法执行画布操作;请检查该通道的工具支持。",
|
||||
url: "", requestBodyValues: undefined, isRetryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createChatFetch(baseURL: string, baseFetch: typeof fetch): typeof fetch {
|
||||
const origin = new URL(baseURL).origin;
|
||||
return async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
if (url.origin !== origin) throw new Error("VIP98K 对话请求不能跨服务商发送密钥");
|
||||
const response = await baseFetch(input, { ...init, redirect: "error" });
|
||||
if (response.ok) return response;
|
||||
// Do not pass provider HTML, echoed prompts or credentials to chat/logs.
|
||||
let code = "";
|
||||
let outcomeUnknown = false;
|
||||
try {
|
||||
const body = record(await response.json());
|
||||
const error = record(body?.error);
|
||||
code = String(error?.code || "");
|
||||
outcomeUnknown = code === "request_outcome_unknown" ||
|
||||
/final outcome could not be confirmed/i.test(String(error?.message || ""));
|
||||
} catch { /* A proxy may return an HTML error page. */ }
|
||||
const message = outcomeUnknown
|
||||
? "VIP98K 已接收请求但无法确认结果,已停止自动重试,避免重复调用和扣费。"
|
||||
: response.status === 401
|
||||
? "VIP98K 密钥鉴权失败,请检查服务商密钥。"
|
||||
: code === "model_protocol_unsupported"
|
||||
? "VIP98K 模型调用协议不匹配,请同步该模型的最新接口配置。"
|
||||
: `VIP98K 模型请求失败(HTTP ${response.status}),请检查通道状态。`;
|
||||
throw new APICallError({
|
||||
message, url: `${url.origin}${url.pathname}`,
|
||||
requestBodyValues: undefined, statusCode: response.status,
|
||||
// Paid POSTs may already have reached the upstream. Retrying a 503 with
|
||||
// a new request identity can create duplicate charges.
|
||||
isRetryable: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createVip98kChatModel(params: {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
modelId: string;
|
||||
metadata?: unknown;
|
||||
fetch?: typeof fetch;
|
||||
discoverLegacyContract?: boolean;
|
||||
}): LanguageModelV2 {
|
||||
const metadata = record(params.metadata) || {};
|
||||
const invocation = selectVip98kChatInvocation(metadata);
|
||||
const hasContract = Boolean(metadata.invocation || metadata.chatInvocation || (Array.isArray(metadata.invocations) && metadata.invocations.length));
|
||||
if (hasContract && !invocation && !params.discoverLegacyContract) {
|
||||
throw new Error("VIP98K 此模型未公布兼容的对话接口,请核对模型类型和接口配置。");
|
||||
}
|
||||
const root = params.baseURL.replace(/\/+$/, "").replace(/\/v1$/i, "");
|
||||
const baseFetch = params.fetch || fetch;
|
||||
const createModel = (contract: typeof invocation) => {
|
||||
const provider = createOpenAI({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: `${root}/v1`,
|
||||
headers: contract?.headers,
|
||||
fetch: createChatFetch(root, baseFetch),
|
||||
});
|
||||
return contract?.protocol === "openai-responses"
|
||||
? provider.responses(params.modelId)
|
||||
: provider.chat(params.modelId);
|
||||
};
|
||||
const model = createModel(invocation);
|
||||
const needsDiscovery = params.discoverLegacyContract &&
|
||||
!invocation?.headers["X-Relay-Route-Option"];
|
||||
const resolveModel = async (signal?: AbortSignal) => {
|
||||
signal?.throwIfAborted();
|
||||
if (!needsDiscovery) return model;
|
||||
const models = await awaitWithAbort(discoverContracts(root, baseFetch), signal);
|
||||
const contract = selectVip98kChatInvocation(models.get(params.modelId));
|
||||
if (!contract) throw new Error("VIP98K 此模型未公布兼容的对话接口,请核对模型类型和接口配置。");
|
||||
return createModel(contract);
|
||||
};
|
||||
return {
|
||||
specificationVersion: model.specificationVersion,
|
||||
provider: model.provider,
|
||||
modelId: model.modelId,
|
||||
supportedUrls: model.supportedUrls,
|
||||
async doGenerate(options) {
|
||||
const activeModel = await resolveModel(options.abortSignal);
|
||||
const result = await activeModel.doGenerate(options);
|
||||
if (requiresToolCall(options) && !result.content.some((part) => part.type === "tool-call")) throw missingToolError();
|
||||
if (!result.content.some((part) => part.type === "tool-call" ||
|
||||
(part.type === "text" && part.text.trim()))) throw emptyResponseError();
|
||||
return result;
|
||||
},
|
||||
async doStream(options) {
|
||||
const activeModel = await resolveModel(options.abortSignal);
|
||||
const result = await activeModel.doStream(options);
|
||||
let usable = false;
|
||||
let toolCalled = false;
|
||||
let failed = false;
|
||||
let finished = false;
|
||||
return { ...result, stream: result.stream.pipeThrough(new TransformStream({
|
||||
transform(part, controller) {
|
||||
if (part.type === "tool-call") toolCalled = true;
|
||||
if (part.type === "tool-call" || (part.type === "text-delta" && part.delta.trim())) usable = true;
|
||||
if (part.type === "error") failed = true;
|
||||
if (part.type === "finish") {
|
||||
finished = true;
|
||||
if (!failed && part.finishReason === "unknown") {
|
||||
throw new Error("VIP98K 响应连接提前中断,结果未确认;未自动重复提交。");
|
||||
}
|
||||
if (!failed && requiresToolCall(options) && !toolCalled) throw missingToolError();
|
||||
if (!usable && !failed) throw emptyResponseError();
|
||||
}
|
||||
controller.enqueue(part);
|
||||
},
|
||||
flush() {
|
||||
if (!failed && !finished) {
|
||||
throw new Error("VIP98K 响应连接提前中断,结果未确认;未自动重复提交。");
|
||||
}
|
||||
},
|
||||
})) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -6,7 +6,7 @@ import {
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createVip98kChatModel } from "./vip98k-chat";
|
||||
import sharp from "sharp";
|
||||
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
@ -119,11 +119,6 @@ function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeOpenAIBaseUrl(baseURL?: string | null) {
|
||||
const normalized = normalizeVip98kBaseUrl(baseURL || VIP98K_BASE_URL);
|
||||
return /\/v1$/i.test(normalized) ? normalized : `${normalized}/v1`;
|
||||
}
|
||||
|
||||
function parseRuntimeConfig(value?: unknown): Vip98kModelRuntimeConfig {
|
||||
const record = toRecord(value);
|
||||
if (!record) return {};
|
||||
@ -1641,11 +1636,6 @@ async function runTask(params: Vip98kTaskParams) {
|
||||
|
||||
export function createVip98k({ apiKey, baseURL }: Vip98kConfig) {
|
||||
const rootBaseURL = normalizeVip98kBaseUrl(baseURL);
|
||||
const openAIBaseURL = normalizeOpenAIBaseUrl(rootBaseURL);
|
||||
const openAITextProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: openAIBaseURL,
|
||||
});
|
||||
return {
|
||||
provider: "vip98k",
|
||||
chat: (modelId: string, defaultModelConfig?: unknown): any => {
|
||||
@ -1653,7 +1643,11 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) {
|
||||
const kind = inferKind(modelId, modelConfig);
|
||||
|
||||
if (kind === "chat") {
|
||||
return openAITextProvider.chat(modelId) as any;
|
||||
return createVip98kChatModel({
|
||||
apiKey, baseURL: rootBaseURL, modelId,
|
||||
metadata: getVip98kMetadata(modelConfig),
|
||||
discoverLegacyContract: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
73
lib/ai/vip98k-chat-contract.ts
Normal file
73
lib/ai/vip98k-chat-contract.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { getVip98kInvocationHeaders } from "./vip98k-task-invocation";
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
export type Vip98kChatInvocation = {
|
||||
protocol: "openai-chat" | "openai-responses";
|
||||
path: "/v1/chat/completions" | "/v1/responses";
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
function record(value: unknown): RecordValue | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as RecordValue)
|
||||
: null;
|
||||
}
|
||||
|
||||
function array(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Select an advertised protocol and its own route together, never by model name. */
|
||||
export function selectVip98kChatInvocation(value: unknown): Vip98kChatInvocation | null {
|
||||
const model = record(value) || {};
|
||||
const primary = record(model.invocation);
|
||||
const defaultRoute = String(model.default_route_option_id ||
|
||||
getVip98kInvocationHeaders(primary?.headers)["X-Relay-Route-Option"] || "");
|
||||
const offers = array(model.serviceOffers || model.service_offers).flatMap((value) => {
|
||||
const offer = record(value);
|
||||
return array(offer?.route_options).map((value) => {
|
||||
const route = record(value);
|
||||
const invocation = record(route?.invocation);
|
||||
return { ...invocation, headers: {
|
||||
...getVip98kInvocationHeaders(invocation?.headers),
|
||||
...getVip98kInvocationHeaders({ "X-Relay-Route-Option": route?.id }),
|
||||
} };
|
||||
});
|
||||
});
|
||||
const candidates = [model.chatInvocation, primary, ...offers, ...array(model.invocations)]
|
||||
.map(record)
|
||||
.filter((item): item is RecordValue => Boolean(item))
|
||||
.filter((item) => {
|
||||
const example = record(item.request_example || item.requestExample);
|
||||
const modelId = String(model.modelId || model.id || "");
|
||||
return (!item.method || String(item.method).toUpperCase() === "POST") &&
|
||||
(!item.kind || item.kind === "chat") &&
|
||||
(!modelId || !example?.model || example.model === modelId) &&
|
||||
(item.path === "/v1/chat/completions" || item.path === "/v1/responses");
|
||||
});
|
||||
// Prefer the published default route when it supports this protocol. An
|
||||
// unscoped protocol example must not discard a route's execution contract.
|
||||
candidates.sort((a, b) => {
|
||||
const score = (item: RecordValue) => {
|
||||
const route = getVip98kInvocationHeaders(item.headers)["X-Relay-Route-Option"];
|
||||
return route && route === defaultRoute ? 2 : route ? 1 : 0;
|
||||
};
|
||||
return score(b) - score(a);
|
||||
});
|
||||
const selected = candidates[0];
|
||||
if (!selected) return null;
|
||||
return {
|
||||
protocol: selected.path === "/v1/responses" ? "openai-responses" : "openai-chat",
|
||||
path: selected.path as Vip98kChatInvocation["path"],
|
||||
headers: getVip98kInvocationHeaders(selected.headers),
|
||||
};
|
||||
}
|
||||
|
||||
export function vip98kChatSupportsVision(value: unknown): boolean {
|
||||
const model = record(value) || {};
|
||||
const modes = [...array(model.inputModes || model.input_modes), ...array(model.capabilities)]
|
||||
.map((item) => String(item).toLowerCase().replace(/_/g, "-"));
|
||||
// A family name alone is not proof that the purchased route accepts images.
|
||||
return String(model.modality).toLowerCase() === "multimodal" ||
|
||||
modes.some((mode) => ["image", "image-url", "image-input", "image-understanding", "vision", "multimodal"].includes(mode));
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { selectVip98kChatInvocation, vip98kChatSupportsVision } from "./vip98k-chat-contract";
|
||||
|
||||
export const VIP98K_BASE_URL = "https://api.vip98k.com";
|
||||
|
||||
export type Vip98kCatalogModelType =
|
||||
@ -288,7 +290,7 @@ function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
||||
return "model_3d";
|
||||
}
|
||||
|
||||
if (catalogType === "world") return "world";
|
||||
if (catalogType === "world" || capabilities.includes("world-generation")) return "world";
|
||||
|
||||
if (catalogType === "image_tool" || IMAGE_TOOL_MODEL_IDS.has(modelId)) {
|
||||
return "image_tool";
|
||||
@ -377,10 +379,13 @@ function hasMediaReferenceInput(
|
||||
const mediaInputModes = new Set([
|
||||
"image",
|
||||
"images",
|
||||
"image-url",
|
||||
"url",
|
||||
"file",
|
||||
"video",
|
||||
"video-url",
|
||||
"audio",
|
||||
"audio-url",
|
||||
"base64",
|
||||
]);
|
||||
const hasMediaCapability = capabilities.some((capability) => {
|
||||
@ -633,6 +638,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
|
||||
});
|
||||
return {
|
||||
modelType: type,
|
||||
chatInvocation: type === "model" ? selectVip98kChatInvocation(model) : null,
|
||||
taskApi:
|
||||
String(invocation?.kind || "").toLowerCase() === "tool"
|
||||
? "external-tool"
|
||||
@ -936,16 +942,10 @@ function createChatPreset(
|
||||
): Vip98kModelPreset {
|
||||
const modelId = getModelId(model);
|
||||
const displayName = getDisplayName(model, modelId);
|
||||
const catalogCapabilities = getCapabilities(model);
|
||||
const inputModes = getInputModes(model);
|
||||
const supportsVision =
|
||||
catalogCapabilities.some((capability) =>
|
||||
["vision", "multimodal", "image-input", "image-understanding"].includes(
|
||||
capability,
|
||||
),
|
||||
) ||
|
||||
inputModes.some((mode) => ["image", "url", "file"].includes(mode)) ||
|
||||
/^(?:gpt-|claude-|qwen)/i.test(modelId);
|
||||
const supportsVision = vip98kChatSupportsVision(model);
|
||||
const chatInvocation = selectVip98kChatInvocation(model);
|
||||
const apiFormat = chatInvocation?.protocol || "openai-compatible";
|
||||
return {
|
||||
modelId,
|
||||
displayName,
|
||||
@ -960,7 +960,7 @@ function createChatPreset(
|
||||
mediaType: "text",
|
||||
modelType: "model",
|
||||
outputType: "text",
|
||||
apiFormat: "openai-compatible",
|
||||
apiFormat,
|
||||
inputModes,
|
||||
supportedModes: supportsVision ? ["text", "vision"] : ["text"],
|
||||
vip98k: getVip98kMetadata(model, "model"),
|
||||
@ -969,7 +969,7 @@ function createChatPreset(
|
||||
provider: "vip98k",
|
||||
kind: "chat",
|
||||
endpoint: modelId,
|
||||
apiFormat: "openai-compatible",
|
||||
apiFormat,
|
||||
},
|
||||
imageGenConfig: null,
|
||||
sortOrder: TYPE_SORT_ORDER.model + index,
|
||||
@ -1158,6 +1158,10 @@ export async function fetchVip98kCatalog(params: {
|
||||
return {
|
||||
...publicModel,
|
||||
...model,
|
||||
// Authorized discovery can contain a generic "model" placeholder for
|
||||
// video/3D tasks. The published execution contract defines their kind.
|
||||
type: publicModel.type || model.type,
|
||||
modality: publicModel.modality ?? model.modality,
|
||||
invocation: publicModel.invocation,
|
||||
invocations: publicModel.invocations,
|
||||
serviceOffers: publicModel.serviceOffers || publicModel.service_offers,
|
||||
|
||||
@ -183,6 +183,20 @@ export function bindVip98kImages(
|
||||
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;
|
||||
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/provider-call-compatibility\.test\.ts/,
|
||||
/routes\/canvas-chat-helpers\.test\.ts/,
|
||||
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
||||
/routes\/canvas-agent-operations\.test\.ts/,
|
||||
|
||||
311
tests/routes/provider-call-compatibility.test.ts
Normal file
311
tests/routes/provider-call-compatibility.test.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { APICallError, type LanguageModelV2CallOptions } from "@ai-sdk/provider";
|
||||
import { generateText } from "ai";
|
||||
import { createVip98kChatModel } from "@/lib/ai/providers/vip98k-chat";
|
||||
import { selectVip98kChatInvocation, vip98kChatSupportsVision } from "@/lib/ai/vip98k-chat-contract";
|
||||
import { buildVip98kModelPresets, fetchVip98kCatalog } from "@/lib/ai/vip98k-models";
|
||||
|
||||
const compatible = {
|
||||
kind: "chat", method: "POST", path: "/v1/chat/completions",
|
||||
headers: { "X-Relay-Route-Option": "route_chat", Authorization: "untrusted" },
|
||||
};
|
||||
const native = { kind: "chat", method: "POST", path: "/v1/messages", headers: { "X-Relay-Route-Option": "route_native" } };
|
||||
const catalogModel = {
|
||||
id: "test-vision", type: "model", modality: "multimodal",
|
||||
default_route_option_id: "route_native", invocation: native,
|
||||
invocations: [native, { ...compatible, headers: {} }],
|
||||
service_offers: [{
|
||||
public_operation_id: "openai.chat_completions.create",
|
||||
route_options: [{ id: "route_native", invocation: { ...compatible, headers: {} } }],
|
||||
}],
|
||||
};
|
||||
const options: LanguageModelV2CallOptions = {
|
||||
prompt: [{ role: "user", content: [
|
||||
{ type: "text", text: "查看图片并报告颜色" },
|
||||
{ type: "file", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) },
|
||||
] }],
|
||||
tools: [{ type: "function", name: "report_colors", inputSchema: {
|
||||
type: "object", properties: { color: { type: "string" } }, required: ["color"],
|
||||
} }],
|
||||
toolChoice: { type: "required" },
|
||||
};
|
||||
function response(message: Record<string, unknown>) {
|
||||
return Response.json({
|
||||
id: "chat_test", model: "test-vision", created: 1,
|
||||
choices: [{ index: 0, finish_reason: message.tool_calls ? "tool_calls" : "stop", message: { role: "assistant", ...message } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
||||
});
|
||||
}
|
||||
function toolResponse() {
|
||||
return response({ content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "report_colors", arguments: '{"color":"red"}' } }] });
|
||||
}
|
||||
function model(baseFetch: typeof fetch, metadata: unknown = { invocation: compatible }) {
|
||||
return createVip98kChatModel({ apiKey: "test-only-key", baseURL: "https://relay.example/v1/", modelId: "test-vision", metadata, fetch: baseFetch });
|
||||
}
|
||||
function streamResponse(chunks: unknown[]) {
|
||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
async function readStream<T>(stream: ReadableStream<T>) {
|
||||
const reader = stream.getReader();
|
||||
const parts: T[] = [];
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) return parts;
|
||||
parts.push(next.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
}
|
||||
const streamChunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
|
||||
id: "chat_test", model: "test-vision", created: 1,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
});
|
||||
|
||||
test("uses the documented compatible protocol with the matching default route", () => {
|
||||
expect(selectVip98kChatInvocation(catalogModel)).toEqual({
|
||||
protocol: "openai-chat", path: "/v1/chat/completions", headers: { "X-Relay-Route-Option": "route_native" },
|
||||
});
|
||||
expect(selectVip98kChatInvocation({ invocation: compatible })?.headers).toEqual({ "X-Relay-Route-Option": "route_chat" });
|
||||
expect(selectVip98kChatInvocation({ invocation: { ...compatible, path: "//attacker.example/v1/chat/completions" } })).toBeNull();
|
||||
expect(selectVip98kChatInvocation({ id: "model_a", invocation: { ...compatible, request_example: { model: "model_b" } } })).toBeNull();
|
||||
});
|
||||
|
||||
test("retains protocol and route during catalog-to-runtime conversion", () => {
|
||||
const preset = buildVip98kModelPresets([catalogModel])[0];
|
||||
const metadata = (preset.capabilities as any).vip98k;
|
||||
expect(selectVip98kChatInvocation(metadata)).toEqual(selectVip98kChatInvocation(catalogModel));
|
||||
expect(preset.supportsVision).toBe(true);
|
||||
expect(preset.defaultParams.apiFormat).toBe("openai-chat");
|
||||
});
|
||||
|
||||
test("classifies vision from the purchased capability, not a family name", () => {
|
||||
expect(vip98kChatSupportsVision({ id: "gpt-vision-looking-name", modality: "text" })).toBe(false);
|
||||
expect(vip98kChatSupportsVision({ id: "gemini-test", input_modes: ["image_url"] })).toBe(true);
|
||||
expect(vip98kChatSupportsVision({ capabilities: ["image-understanding"] })).toBe(true);
|
||||
const presets = buildVip98kModelPresets([
|
||||
{ id: "minimax-h3", type: "video", input_modes: ["image_url"], output_modes: ["video"] },
|
||||
{ id: "world-generation", type: "model", capabilities: ["world_generation"], output_modes: ["interactive_3d"] },
|
||||
]);
|
||||
expect(presets.map((p) => p.type)).toEqual(["video", "world"]);
|
||||
expect(() => model(fetch, { invocation: { kind: "task", path: "/extensions/v1/tasks" } })).toThrow("未公布兼容的对话接口");
|
||||
});
|
||||
|
||||
test("preserves images, tools, cancellation and route-scoped authentication in the actual SDK request", async () => {
|
||||
const abort = new AbortController();
|
||||
let calls = 0;
|
||||
const mock: typeof fetch = async (input, init) => {
|
||||
calls++;
|
||||
expect(String(input)).toBe("https://relay.example/v1/chat/completions");
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer test-only-key");
|
||||
expect(headers.get("X-Relay-Route-Option")).toBe("route_native");
|
||||
expect(init?.signal).toBe(abort.signal);
|
||||
expect(init?.redirect).toBe("error");
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(body.messages[0].content[1]).toEqual({ type: "image_url", image_url: { url: "data:image/png;base64,AQID" } });
|
||||
expect(body.tools[0].function.name).toBe("report_colors");
|
||||
expect(body.tool_choice).toBe("required");
|
||||
return toolResponse();
|
||||
};
|
||||
const result = await model(mock, catalogModel).doGenerate({ ...options, abortSignal: abort.signal });
|
||||
expect(calls).toBe(1);
|
||||
expect(result.content).toContainEqual(expect.objectContaining({ type: "tool-call", toolName: "report_colors", input: '{"color":"red"}' }));
|
||||
});
|
||||
|
||||
test("keeps route headers isolated across two models on the same provider", async () => {
|
||||
const routes: string[] = [];
|
||||
const mock: typeof fetch = async (_url, init) => {
|
||||
routes.push(new Headers(init?.headers).get("X-Relay-Route-Option") || "");
|
||||
return toolResponse();
|
||||
};
|
||||
await Promise.all([
|
||||
model(mock).doGenerate(options),
|
||||
model(mock, { invocation: { ...compatible, headers: { "X-Relay-Route-Option": "other_route" } } }).doGenerate(options),
|
||||
]);
|
||||
expect(routes.sort()).toEqual(["other_route", "route_chat"]);
|
||||
});
|
||||
|
||||
test("uses Responses only when that protocol is actually published", async () => {
|
||||
const mock: typeof fetch = async (url, init) => {
|
||||
expect(String(url)).toBe("https://relay.example/v1/responses");
|
||||
expect(JSON.parse(String(init?.body))).toHaveProperty("input");
|
||||
return Response.json({ id: "resp_test", created_at: 1, model: "test-vision", incomplete_details: null, output: [
|
||||
{ type: "message", id: "msg_test", role: "assistant", content: [{ type: "output_text", text: "已完成", annotations: [] }] },
|
||||
], usage: { input_tokens: 1, output_tokens: 1 } });
|
||||
};
|
||||
const result = await model(mock, { invocation: { ...compatible, path: "/v1/responses" } }).doGenerate({ prompt: options.prompt });
|
||||
expect(result.content).toContainEqual(expect.objectContaining({ type: "text", text: "已完成" }));
|
||||
});
|
||||
|
||||
test("does not automatically repeat a paid request whose outcome is unknown", async () => {
|
||||
let calls = 0;
|
||||
const mock: typeof fetch = async () => {
|
||||
calls++;
|
||||
return new Response(JSON.stringify({ error: { code: "request_outcome_unknown", message: "The request was sent, but its final outcome could not be confirmed." } }), { status: 503 });
|
||||
};
|
||||
await expect(generateText({ model: model(mock), prompt: "hello", maxRetries: 2 })).rejects.toThrow("避免重复调用和扣费");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects silent empty success without retrying and redacts upstream errors", async () => {
|
||||
let calls = 0;
|
||||
const empty: typeof fetch = async () => { calls++; return response({ content: "" }); };
|
||||
await expect(generateText({ model: model(empty), prompt: "hello", maxRetries: 2 })).rejects.toThrow("未返回可用");
|
||||
expect(calls).toBe(1);
|
||||
const failing: typeof fetch = async () => new Response('<html>secret-key prompt private</html>', { status: 524 });
|
||||
try { await model(failing).doGenerate(options); throw new Error("expected failure"); }
|
||||
catch (error) {
|
||||
expect(APICallError.isInstance(error)).toBe(true);
|
||||
expect(String(error)).not.toMatch(/secret-key|prompt private|<html>/);
|
||||
expect((error as APICallError).isRetryable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("streams complete tool calls without waiting for a text answer", async () => {
|
||||
const mock: typeof fetch = async () => streamResponse([
|
||||
streamChunk({ role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "report_colors", arguments: '{"color":' } }] }),
|
||||
streamChunk({ tool_calls: [{ index: 0, function: { arguments: '"red"}' } }] }),
|
||||
streamChunk({}, "tool_calls"),
|
||||
]);
|
||||
const { stream } = await model(mock).doStream(options);
|
||||
const parts = await readStream(stream);
|
||||
expect(parts).toContainEqual(expect.objectContaining({ type: "tool-call", toolName: "report_colors", input: '{"color":"red"}' }));
|
||||
expect(parts.at(-1)?.type).toBe("finish");
|
||||
});
|
||||
|
||||
test("reports reasoning-only and disconnected streams as failures instead of success", async () => {
|
||||
const mock: typeof fetch = async () => streamResponse([streamChunk({}, "stop")]);
|
||||
const empty = await model(mock).doStream({ ...options, toolChoice: { type: "auto" } });
|
||||
await expect(readStream(empty.stream)).rejects.toThrow("未返回可用");
|
||||
const cut: typeof fetch = async () => streamResponse([streamChunk({ content: "部分回答" })]);
|
||||
const interrupted = await model(cut).doStream(options);
|
||||
await expect(readStream(interrupted.stream)).rejects.toThrow("提前中断");
|
||||
});
|
||||
|
||||
test("does not claim success when a route silently ignores required tools", async () => {
|
||||
const mock: typeof fetch = async () => response({ content: "当前环境中未提供工具" });
|
||||
await expect(model(mock).doGenerate(options)).rejects.toThrow("未返回所要求的工具调用");
|
||||
const streamMock: typeof fetch = async () => streamResponse([
|
||||
streamChunk({ content: "当前环境中未提供工具" }), streamChunk({}, "stop"),
|
||||
]);
|
||||
const { stream } = await model(streamMock).doStream(options);
|
||||
await expect(readStream(stream)).rejects.toThrow("未返回所要求的工具调用");
|
||||
});
|
||||
|
||||
test("cancelling a streamed response cancels the underlying connection", async () => {
|
||||
let cancelled = false;
|
||||
const mock: typeof fetch = async () => new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(streamChunk({ content: "hello" }))}\n\n`));
|
||||
},
|
||||
cancel() { cancelled = true; },
|
||||
}), { headers: { "Content-Type": "text/event-stream" } });
|
||||
const { stream } = await model(mock).doStream(options);
|
||||
const reader = stream.getReader();
|
||||
await reader.read();
|
||||
await reader.cancel();
|
||||
await expect.poll(() => cancelled).toBe(true);
|
||||
});
|
||||
|
||||
test("public executable catalog wins over generic authorized type placeholders", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
seen.push(String(url));
|
||||
if (String(url).endsWith("/api/v2/catalog/models")) {
|
||||
expect(new Headers(init?.headers).has("Authorization")).toBe(false);
|
||||
return Response.json({ data: { discovery: { authorized_models_path: "//attacker.example/models" }, sections: [{ models: [
|
||||
{ id: "minimax-h3", type: "video", input_modes: ["image_url"], output_modes: ["video"] }, catalogModel,
|
||||
] }] } });
|
||||
}
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-only-key");
|
||||
return Response.json({ data: [{ id: "minimax-h3", type: "model" }, { id: "test-vision" }] });
|
||||
};
|
||||
try {
|
||||
const catalog = await fetchVip98kCatalog({ baseURL: "https://relay.example/v1/", apiKey: "test-only-key" });
|
||||
expect(buildVip98kModelPresets(catalog).map((p) => p.type)).toEqual(["video", "model"]);
|
||||
expect(seen).toEqual(["https://relay.example/api/v2/catalog/models", "https://relay.example/v1/models"]);
|
||||
expect((buildVip98kModelPresets(catalog)[1].capabilities as any).vip98k.chatInvocation.headers).toEqual({ "X-Relay-Route-Option": "route_native" });
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test("legacy models discover the public contract once for concurrent calls without disclosing the key", async () => {
|
||||
let catalogs = 0;
|
||||
let posts = 0;
|
||||
const mock: typeof fetch = async (url, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (String(url).endsWith("/catalog/models")) {
|
||||
catalogs++;
|
||||
expect(headers.has("Authorization")).toBe(false);
|
||||
return Response.json({ data: { sections: [{ models: [catalogModel] }] } });
|
||||
}
|
||||
posts++;
|
||||
expect(headers.get("Authorization")).toBe("Bearer test-only-key");
|
||||
expect(headers.get("X-Relay-Route-Option")).toBe("route_native");
|
||||
return toolResponse();
|
||||
};
|
||||
const legacy = () => createVip98kChatModel({
|
||||
apiKey: "test-only-key", baseURL: "https://legacy-test.example", modelId: "test-vision",
|
||||
fetch: mock, discoverLegacyContract: true,
|
||||
});
|
||||
await Promise.all([legacy().doGenerate(options), legacy().doGenerate(options)]);
|
||||
await legacy().doGenerate(options);
|
||||
expect(catalogs).toBe(1);
|
||||
expect(posts).toBe(3);
|
||||
});
|
||||
|
||||
test("legacy discovery prevents an incorrectly classified video model from being charged as chat", async () => {
|
||||
let posts = 0;
|
||||
const mock: typeof fetch = async (url) => {
|
||||
if (!String(url).endsWith("/catalog/models")) posts++;
|
||||
return Response.json({ data: { sections: [{ models: [{
|
||||
id: "minimax-h3", type: "video", invocation: { kind: "task", path: "/extensions/v1/tasks" },
|
||||
}] }] } });
|
||||
};
|
||||
const legacy = createVip98kChatModel({
|
||||
apiKey: "test-only-key", baseURL: "https://legacy-video.example", modelId: "minimax-h3",
|
||||
fetch: mock, discoverLegacyContract: true,
|
||||
});
|
||||
await expect(legacy.doGenerate(options)).rejects.toThrow("未公布兼容的对话接口");
|
||||
expect(posts).toBe(0);
|
||||
});
|
||||
|
||||
test("a legacy native-only snapshot can discover its newly advertised compatible route", async () => {
|
||||
let posts = 0;
|
||||
const mock: typeof fetch = async (url, init) => {
|
||||
if (String(url).endsWith("/catalog/models")) {
|
||||
return Response.json({ data: { sections: [{ models: [catalogModel] }] } });
|
||||
}
|
||||
expect(String(url)).toBe("https://legacy-native.example/v1/chat/completions");
|
||||
expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("route_native");
|
||||
posts++;
|
||||
return toolResponse();
|
||||
};
|
||||
const legacy = createVip98kChatModel({
|
||||
apiKey: "test-only-key", baseURL: "https://legacy-native.example", modelId: "test-vision",
|
||||
metadata: { invocation: native }, fetch: mock, discoverLegacyContract: true,
|
||||
});
|
||||
await legacy.doGenerate(options);
|
||||
expect(posts).toBe(1);
|
||||
});
|
||||
|
||||
test("a cancelled request can leave shared discovery without submitting a paid POST", async () => {
|
||||
const abort = new AbortController();
|
||||
let finishCatalog: (response: Response) => void = () => {};
|
||||
let posts = 0;
|
||||
const mock: typeof fetch = async (url) => {
|
||||
if (!String(url).endsWith("/catalog/models")) { posts++; return toolResponse(); }
|
||||
return new Promise<Response>((resolve) => { finishCatalog = resolve; });
|
||||
};
|
||||
const legacy = createVip98kChatModel({
|
||||
apiKey: "test-only-key", baseURL: "https://legacy-cancel.example", modelId: "test-vision",
|
||||
fetch: mock, discoverLegacyContract: true,
|
||||
});
|
||||
const result = Promise.resolve(legacy.doGenerate({ ...options, abortSignal: abort.signal }));
|
||||
abort.abort(new Error("cancelled by user"));
|
||||
await expect(result).rejects.toThrow("cancelled by user");
|
||||
finishCatalog(Response.json({ data: { sections: [{ models: [catalogModel] }] } }));
|
||||
expect(posts).toBe(0);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user