diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts index 79916875d..87adec6ec 100644 --- a/lib/ai/dynamic-provider.ts +++ b/lib/ai/dynamic-provider.ts @@ -45,7 +45,9 @@ import { isBltcyVideoBaseUrl } from "./bltcy-video-models"; import { resolveModelRuntimeId } from "./model-runtime"; // 缓存已创建的 provider 实例 -const providerCache = new Map(); +const providerCache = new Map(); const logger = createLogger("dynamic-provider"); function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) { @@ -83,10 +85,10 @@ function logChatModelResult(providerType: string, baseModel: unknown) { function getOrCreateProvider(provider: Pick) { 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; @@ -401,7 +403,9 @@ function getOrCreateProvider(provider: Pick>; +}>(); + +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(); + 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(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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 响应连接提前中断,结果未确认;未自动重复提交。"); + } + }, + })) }; + }, + }; +} diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index a8d26c2d5..f34cab27a 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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 | null { return value as Record; } -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 { diff --git a/lib/ai/vip98k-chat-contract.ts b/lib/ai/vip98k-chat-contract.ts new file mode 100644 index 000000000..87fbc14e1 --- /dev/null +++ b/lib/ai/vip98k-chat-contract.ts @@ -0,0 +1,73 @@ +import { getVip98kInvocationHeaders } from "./vip98k-task-invocation"; + +type RecordValue = Record; +export type Vip98kChatInvocation = { + protocol: "openai-chat" | "openai-responses"; + path: "/v1/chat/completions" | "/v1/responses"; + headers: Record; +}; + +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)); +} diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index b267c158e..3b5716df1 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -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, diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 5b6c87a8d..20f4d1c73 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -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 { + 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; diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 6b26eb94d..1b3edb439 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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/, diff --git a/tests/routes/provider-call-compatibility.test.ts b/tests/routes/provider-call-compatibility.test.ts new file mode 100644 index 000000000..1792f140d --- /dev/null +++ b/tests/routes/provider-call-compatibility.test.ts @@ -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) { + 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(stream: ReadableStream) { + 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, 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('secret-key prompt private', { 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|/); + 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((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); +});