ai-chatbot/lib/ai/providers/vip98k-chat.ts

196 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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