fix(ai): honor provider chat contracts and prevent unsafe retries

This commit is contained in:
134 2026-10-15 16:00:45 +08:00
parent 86c565d34c
commit f0761e7f61
8 changed files with 626 additions and 30 deletions

View File

@ -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;
}

View 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 响应连接提前中断,结果未确认;未自动重复提交。");
}
},
})) };
},
};
}

View File

@ -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 {

View 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));
}

View File

@ -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,

View File

@ -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;

View File

@ -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/,

View 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);
});