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