ai-chatbot/lib/ai/zcode-agent/model-gateway.ts

96 lines
5.8 KiB
TypeScript

import "server-only";
import { AgentModelProtocolError, createAgentModelFailover, type AgentCircuitStore, type AgentModelEntry } from "@/lib/ai/runtime/agent-model-failover";
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { toResponsesRequest, responsesCompletion, completionResponse } from "./model-gateway-protocol";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { selectVip98kChatInvocation } from "@/lib/ai/vip98k-chat-contract";
import { normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
type ConfiguredModel = AgentModelEntry;
const OPENAI_COMPATIBLE = new Set(["openai", "openai-compatible", "alibaba", "qwen", "local-qwen", "local", "zhipu", "moonshot", "deepseek", "xai", "siliconflow", "custom"]);
const catalogCache = new Map<string, { expires: number; value: unknown[] }>();
async function resolveGateway(configured: ConfiguredModel) {
const { provider, model } = configured;
const modelId = resolveModelRuntimeId(model, { providerType: provider.type });
let base = String(provider.baseUrl || "").replace(/\/+$/, "");
let path = "/v1/chat/completions";
let apiType = "openai-chat-completions";
let contractHeaders: Record<string, string> = {};
if (provider.type === "vip98k") {
base = normalizeVip98kBaseUrl(base);
const capabilities = model.capabilities as Record<string, unknown> | null;
const imageConfig = model.imageGenConfig as Record<string, unknown> | null;
let contract = selectVip98kChatInvocation(capabilities?.vip98k || imageConfig?.vip98k);
if (!contract?.headers["X-Relay-Route-Option"]) {
let catalog = catalogCache.get(base);
if (!catalog || catalog.expires < Date.now()) {
const response = await fetch(`${base}/api/v2/catalog/models`, {
redirect: "error", signal: AbortSignal.timeout(12000), headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
});
if (!response.ok) throw Object.assign(new Error("Agent catalog unavailable"), { status: response.status });
const payload = await response.json();
const entries = (payload?.data?.sections || []).flatMap((section: { models?: unknown[] }) => section.models || []);
catalog = { expires: Date.now() + 5 * 60_000, value: entries };
if (catalogCache.size >= 16) catalogCache.delete(catalogCache.keys().next().value!);
catalogCache.set(base, catalog);
}
const entry = catalog.value.find(entry => (entry as { id?: string }).id === modelId);
contract = selectVip98kChatInvocation(entry) || contract;
}
if (!contract) throw new AgentModelProtocolError();
path = contract.path; contractHeaders = contract.headers;
apiType = contract.protocol === "openai-responses" ? "openai-responses" : "openai-chat-completions";
} else {
if (!OPENAI_COMPATIBLE.has(provider.type)) throw new Error("当前 Agent 服务商尚未提供原生核心可用的对话协议");
base ||= provider.type === "openai" ? "https://api.openai.com" : "";
if (!base) throw new Error("Agent 服务商缺少接口地址");
base = base.replace(/\/v1$/, "");
}
const endpoint = new URL(base + path);
if (!["https:", "http:"].includes(endpoint.protocol)) throw new Error("Agent 服务商地址无效");
return { modelId, apiType, endpoint, contractHeaders, apiKey: provider.apiKey };
}
export async function createZcodeModelGateway(configured: ConfiguredModel, onError?: (message: string) => void,
candidates: AgentModelEntry[] = [configured], circuit: AgentCircuitStore = agentModelCircuitStore) {
const run = createAgentModelFailover(candidates, circuit);
// The native core sees one stable chat protocol. Responses-only provider
// routes are translated behind this boundary, including tool history.
return {
modelId: resolveModelRuntimeId(configured.model, { providerType: configured.provider.type }),
apiType: "openai-chat-completions",
forward: async (body: Record<string, unknown>, requestedPath: string, signal: AbortSignal) => {
if (requestedPath !== "/v1/chat/completions") return Response.json({ error: { message: "此任务的模型协议不匹配" } }, { status: 400 });
try {
const completion = await run(async (entry, attemptSignal) => {
const target = await resolveGateway(entry);
attemptSignal.throwIfAborted();
const request = target.apiType === "openai-responses" ? toResponsesRequest(body) : { ...body };
delete request.stream_options;
const response = await fetch(target.endpoint, { method: "POST", redirect: "error",
headers: { "content-type": "application/json", ...target.contractHeaders, Authorization: `Bearer ${target.apiKey}` },
body: JSON.stringify({ ...request, model: target.modelId, stream: false }), signal: attemptSignal });
if (!response.ok) {
// Use the body only to distinguish input/policy rejection from a
// provider outage. It is never persisted, logged or sent to clients.
const text = (await response.text()).slice(0, 4096);
throw Object.assign(new Error(text || "upstream request failed"), { status: response.status });
}
let payload: Record<string, any>;
try { payload = await response.json(); } catch { throw new AgentModelProtocolError(); }
if (target.apiType === "openai-responses") payload = responsesCompletion(payload);
return completionResponse(payload, body);
}, signal);
return completion;
} catch (error) {
if (signal.aborted) throw error;
const message = "Agent 模型通道暂不可用,已保留执行记录,请稍后重试";
onError?.(message);
return Response.json({ error: { message, type: "canvas_model_gateway_error" } }, { status: 503 });
}
},
};
}