98 lines
6.0 KiB
TypeScript
98 lines
6.0 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";
|
|
import { createDeepSeekCompatibleFetch, isDeepSeekOfficialUrl } from "@/lib/ai/deepseek-compatible-fetch";
|
|
|
|
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 providerFetch = isDeepSeekOfficialUrl(target.endpoint.href) ? createDeepSeekCompatibleFetch() : fetch;
|
|
const response = await providerFetch(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 });
|
|
}
|
|
},
|
|
};
|
|
}
|