Endpoint 抽象:把几十家 provider 收敛成一套接口
30 秒导读: 一个聊天应用要同时接 OpenAI、Anthropic、Google、AWS Bedrock,还要能接 任意「长得像 OpenAI」的第三方服务(Ollama、OpenRouter、DeepSeek、你自建的网关……)。 LibreChat 的做法是把「一家供应商」抽象成一个 endpoint——一个装着「模型列表 + 密钥怎么取 + 参数长什么样 + token/定价怎么算」的盒子,再用一张分发表把请求路由到对应的初始化函数, 最后所有函数都吐出同一种
llmConfig交给运行时。本章讲清这个统一抽象怎么搭起来的。
本章只讲 provider / endpoint 的统一与配置。拿到 llmConfig 之后 agent 怎么跑、怎么流式,
见 03-agent-orchestration.md;一条消息的完整生命周期见
01-request-lifecycle.md。
1. 先说人话:什么是 endpoint
一句话定义: endpoint 是 LibreChat 里对「一家可聊天的后端」的统一封装——不管背后是 Anthropic 官方、还是某个 OpenAI 兼容网关,对上层都表现成同一套接口。
类比: 把 endpoint 想成电源插座的转接头。世界上有各种插头(每家 API 的鉴权、参数、
模型命名 都不一样),LibreChat 内部只认一种「标准插孔」(llmConfig)。每个 endpoint 负责把
自家那套插头,转成标准插孔。上层电器(agent 运行时)永远只面对标准插孔。
一个 endpoint 到底装了什么? 四样东西,缺一不可:
| 组成 | 干什么 | 谁负责 |
|---|---|---|
| 模型列表 | 这家能用哪些 model(静态写死 or 动态拉取) | models.ts 的 fetchModels / getGoogleModels … |
| 密钥解析 | key 从环境变量取、还是让用户自己填、还是走 Vertex/Azure 凭证 | 各 initialize.ts |
| 参数 schema | 这家认哪些参数(max_tokens 还是 maxOutputTokens?能不能 thinking?) | parseCompactConvo + defaultParamsEndpoint |
| token/定价配置 | 每个 model 的上下文窗口多大、每百万 token 多少钱 | pricing.ts / tokenConfig.ts |
用起来什么样(管理员视角): 接一个新服务,通常就是往 librechat.yaml 里写一段:
endpoints:
custom:
- name: "MyGateway" # 这就成了一个 endpoint 的名字
apiKey: "${MY_GATEWAY_KEY}" # 从环境变量取
baseURL: "https://gw.example.com/v1"
models:
default: ["gpt-4o-mini"] # 或 fetch: true 让它自己去拉
写完这段,前端的模型下拉里就多出一个 MyGateway,用户点它发消息,整条链路自动打通。
本章后半会讲清「这段 yaml 是怎么变成一次真实 LLM 调用的」。
2. 两套词汇表:EModelEndpoint vs Providers(先别混淆)
理解这一章,先要分清 LibreChat 里两个都叫「供应商」但含义不同的枚举。
EModelEndpoint——面向用户/配置的「大类」。 只有 9 个,是前端下拉、yaml 配置、路由用的
稳定名字(packages/data-provider/src/schemas.ts:18 EModelEndpoint):
| 值 | 含义 |
|---|---|
openAI / azureOpenAI | OpenAI 官方 / Azure 托管的 OpenAI |
anthropic | Anthropic 官方(含 Vertex 变体) |
google | Google Gemini / Vertex |
bedrock | AWS Bedrock(下面又聚合了 anthropic/meta/… 多家) |
custom | 「万能类」——所有 yaml 里自定义的 OpenAI 兼容服务都归这类 |
agents | LibreChat 自家的 agent 编排层(见 03) |
assistants / azureAssistants | OpenAI Assistants API |
Providers——面向运行时的「LLM 客户端家族」。 这个枚举镜像 @librechat/agents(运行时依赖)
里真正的客户端实现,粒度更细(schemas.ts:31 Providers,注释原文标注 "Mirrors @librechat/agents providers"):
OPENAI · ANTHROPIC · AZURE · GOOGLE · VERTEXAI · BEDROCK
MISTRALAI · MISTRAL · DEEPSEEK · MOONSHOT · OPENROUTER · XAI
两者的关系,一句话: EModelEndpoint 是「用户选了哪一格」,Providers 是「最终该 new 哪个
SDK 客户端」。一个 custom 大类,运行时可能被解析成 openai、deepseek、openrouter 等多个
Providers。
为什么 custom 是整个抽象的关键: 它是一个开放集合。前四类是写死的品牌,custom 却
允许任意字符串命名的 endpoint。这正是「几十家 provider 收敛成一套接口」的落点——绝大多数
第三方,LibreChat 一行专用代码都不写,全靠 custom 这一类兜住(下详 §4.4)。
两个补充词表(都在 data-provider),知道存在即可:
KnownEndpoints(config.ts:1807):一串已知的 OpenAI 兼容服务名(ollama、openrouter、deepseek、groq、mistral、xai…)。它们仍走custom机制,但代码在个别地方会按名字 认出它们做微调(比如 OpenRouter 要加特定 header)。BedrockProviders(schemas.ts:123):Bedrock 内部又聚合了 anthropic/meta/cohere/mistral 等家。getModelKey(schemas.ts:138)靠拆 model id(如anthropic.claude-...)反查出是哪家, 好去查对应的定价表。
3. 顶层全景:一次请求怎么找到「该用哪套 endpoint」
先看这张图怎么读: 从左到右是一次聊天请求的处理顺序;上半是解析请求(把 body 变成
结构化的 endpointOption),下半是解析 endpoint(把「用哪家」变成一个可执行的 llmConfig)。
关键分水岭是中间的 getProviderConfig——那张分发表。
┌─────────────────────────────────────────────┐
HTTP 请求 body │ 第一步:请求 → endpointOption(解析请求体) │
{endpoint, model, │ middleware/buildEndpointOption.js │
messages, params...} │ · parseCompactConvo 按 endpoint 校验参数 │
│ │ · 套用 modelSpec 预设 │
▼ └───────────────────────┬─────────────────────┘
│ req.body.endpointOption
▼
┌─────────────────────────────────────────────┐
│ 第二步:endpoint → llmConfig(解析供应商) │
│ agents/initialize.ts 调: │
│ │
provider 名字 ──────► │ getProviderConfig(provider) 【分发表】 │
(openAI/anthropic/ │ │ 查 providerConfigMap │
bedrock/MyGateway…) │ ▼ │
│ { getOptions, overrideProvider, │
│ customEndpointConfig } │
│ │ │
│ ▼ await getOptions({req, endpoint…}) │
│ ┌──────────────┬──────────────┬─────────┐ │
│ │initializeOpenAI│initializeAnthropic│… │ │
│ │initializeCustom│initializeBedrock │ │ │
│ └──────┬───────┴──────┬───────┴───────┘ │
│ ▼ ▼ │
│ getOpenAIConfig (原生 /v1/messages 等) │
│ 【收敛漏斗:多家 → 一种形状】 │
└──────────────────────┬──────────────────────┘
▼
InitializeResultBase
{ llmConfig, configOptions,
provider, tools,
endpointTokenConfig } ──► 交给 03 的运行时
各部件一句话职责:
| 部件 | 干什么 | 文件 · 符号 |
|---|---|---|
buildEndpointOption | 把原始请求体解析/校验成 endpointOption | api/server/middleware/buildEndpointOption.js:28 |
getProviderConfig | 分发表:provider 名 → 对应初始化函数 + 归一化后的 provider | packages/api/src/endpoints/config/providers.ts:136 |
providerConfigMap | 那张表本身(常量) | providers.ts:39 |
initializeX 家族 | 各家的密钥解析 + 参数组装,产出 llmConfig | endpoints/{openai,anthropic,custom,bedrock,google}/initialize.ts |
getOpenAIConfig | 收敛漏斗:连 anthropic/google 也能塞进 OpenAI 客户端形状 | endpoints/openai/config.ts:93 |
InitializeResultBase | 所有初始化函数的统一产物类型 | packages/api/src/types/endpoints.ts:57 |
主线走一遍(高层,不进代码): 请求带着 endpoint: "MyGateway" 进来 → buildEndpointOption
校验参数、拼出 endpointOption → agent 初始化时拿 provider 名去查 getProviderConfig → 表里没有
MyGateway 这个内置项,于是判定它是个 custom endpoint,返回 initializeCustom 和
overrideProvider = openAI → 调 initializeCustom 解析出 key/baseURL,再进 getOpenAIConfig
拼出标准 llmConfig → 交给运行时发起真实调用。
4. 核心机制(逐个拆)
4.1 分发表:getProviderConfig——整套抽象的心脏
它要解决的小问题: 给我一个字符串 provider,告诉我「该用哪个初始化函数、最终算作哪个
运行时 provider、如果是自定义的话它的 yaml 配置是什么」。
那张表本身很短(providers.ts:39 providerConfigMap):内置品牌各指一个初始化函数,几个
知名第三方直接复用 initializeCustom:
XAI / DEEPSEEK / MOONSHOT / OPENROUTER ─► initializeCustom
VERTEXAI ─► initializeGoogle (只是鉴权不同,复用)
openAI / azureOpenAI ─► initializeOpenAI
anthropic ─► initializeAnthropic
google ─► initializeGoogle
bedrock ─► initializeBedrock
思路/直觉——查不到怎么办? 这才是精华。getProviderConfig(providers.ts:136)按三级降级:
provider 直接命中表? ── 是 ─► 用它
│否
provider.toLowerCase() 命中表? ── 是 ─► 用小写版(归一化大小写)
│否
当成 custom endpoint 名字去 yaml 里找
getCustomEndpointConfig(provider)
│找到 ─► getOptions = initializeCustom
│ overrideProvider = openAI ← 默认当 OpenAI 兼容处理
│找不到 ─► 抛 "Provider X not supported"
一句话:表里没有的,一律先当「OpenAI 兼容的自定义服务」兜底。 这就是为什么接一个新服务
往 往零代码——它会自动落进 initializeCustom 这条路。
一个精妙的边界:大小写歧义。 自定义 endpoint 的名字大小写敏感(允许 OpenRouter 和
openrouter-staging 并存)。但下游(摘要、标题生成)会拿已归一化成小写的 provider 名重新
进来查。于是 getProviderConfig 对已知第三方做一次大小写不敏感的兜底匹配;而当出现多个
只是大小写不同的匹配(OpenRouter vs OPENROUTER)时,它拒绝随便选一个、直接报歧义错——
因为两个条目可能指向不同的 baseURL/apiKey,猜错就把请求发错地方(providers.ts:159-199)。
另一个覆盖点:custom 里声明 provider: anthropic。 即使一个自定义 endpoint 名字撞上了某个
已知第三方,只要它 yaml 里写了 provider: anthropic,最终 overrideProvider 也会被强制改成
ANTHROPIC,好让 token/上下文预算去查 Anthropic 的表(providers.ts:209-211)。
4.2 每个 endpoint 的初始化:密钥解析 + 参数组装
统一契约: 所有初始化函数签名一致(入 BaseInitializeParams,出 InitializeResultBase,
types/endpoints.ts:42,57)——这正是「一套接口」在类型层面的体现。
它们内部各自处理这家特有的密钥怎么取。看三种典型差异:
| endpoint | key 来源(择要) | 特殊处 |
|---|---|---|
initializeOpenAI | 环境变量 OPENAI_API_KEY,或用户自填,或 Azure 凭证 | Azure 还要按 model 映射到部署组(mapModelToAzureConfig) |
initializeAnthropic | ANTHROPIC_API_KEY,或用户自填,或 Google Vertex 凭证 | Vertex 走完全不同的服务账号鉴权(anthropic/initialize.ts:37-48) |
initializeCustom | yaml 里的 ${VAR} 展开,或用户自填 key/URL | 用户自填 URL 时要过 SSRF 校验、且不转发内部 header |
一个安全细节值得记: 当 baseURL 是用户自己填的,initializeOpenAI / initializeCustom
会故意不下发管理员配置的 header(openai/initialize.ts:81 的 trustedURL)。因为那些 header
里可能有 ${SECRET} 网关密钥或 {{LIBRECHAT_OPENID_ID_TOKEN}} 用户身份令牌,不能泄露给用户
可控的地址。这条「可信 URL 才转发密文 header」的红线,在模型拉取、custom 初始化等多处重复出现。
产物长什么样(InitializeResultBase,types/endpoints.ts:57):
{ llmConfig, // 标准插孔:交给运行时的核心配置
configOptions, // baseURL / defaultHeaders / fetch 等传输层选项
provider, // 运行时最终认的 provider(可能被覆写)
tools, // 供应商内建工具(如 web_search)
endpointTokenConfig, // 这家的 token/定价覆盖
useLegacyContent } // 是否走旧版内容格式(见 §5)
4.3 收敛漏斗:getOpenAIConfig——把多家硬塞进「OpenAI 形状」
它要解决的小问题: 运行时里,OpenAI 兼容客户端是「最大公约数」。能不能让 Anthropic、 Google 的参数,也走这条最成熟的客户端路径?
思路: getOpenAIConfig(openai/config.ts:93)里有三条分支,但殊途同归——都产出一个
OpenAI 形状的 llmConfig:
customParams.defaultParamsEndpoint == anthropic ?
── 是 ─► 先用 getAnthropicLLMConfig 拼 Anthropic 配置
再 transformToOpenAIConfig 转成 OpenAI 形状 (config.ts:135-158)
== google ?
── 是 ─► getGoogleConfig → transformToOpenAIConfig (config.ts:159-182)
否则(纯 OpenAI 兼容)
─► getOpenAILLMConfig 直接拼 (config.ts:184-203)
换句话说: defaultParamsEndpoint 这个字段决定「用哪家的参数词汇」,但客户端载体始终
可以是 OpenAI 兼容那套。这就是「几十家收敛成一套」在参数层的实现——transformToOpenAIConfig
负责把 addParams/dropParams(增删参数)在不同家之间翻译对齐。
顺带认出的两家特例: 同一函数里,它还会按 baseURL/endpoint 名认出 OpenRouter(自动加
HTTP-Referer/X-Title 等 header,config.ts:222-231)和 Vercel AI Gateway(自动选
reasoningObject 推理格式)——都是「知道名字就微调」的典型。
4.4 custom endpoint 即插即用:一段 yaml 顶一个集成
它要解决的小问题: 每接一家新服务都改一遍 TypeScript,不可持续。
思路: 只要一个服务长得像 OpenAI(有 /v1/chat/completions 和 /v1/models),就不该需要
专门代码。loadCustomEndpointsConfig(endpoints/custom/config.ts)把 yaml 里每个 custom 条目
过一遍,门槛只有四样:
保留该 endpoint 的条件(缺一即丢弃):
baseURL 有 ∧ apiKey 有 ∧ name 有 ∧ (models.fetch 或 models.default 至少一个)
满足就登记成一个 type: custom 的 endpoint。运行时它落进 initializeCustom,默认走
getOpenAIConfig 的纯 OpenAI 分支——于是 Ollama、LM Studio、LiteLLM、vLLM、你自建的任何网关,
改 yaml 不改码就能上线。
留给「不太一样」的服务一个后门: 如果某个 custom 服务其实是原生 Anthropic
(/v1/messages,不是 OpenAI 那套),在 yaml 里写 provider: anthropic 即可。
initializeCustom 会走 buildAnthropicCustomConfig(custom/initialize.ts:132),把原生 Anthropic
客户端指向你的自定义 baseURL/apiKey,并回报 provider: ANTHROPIC 让运行时用原生客户端。
同时 loadCustomEndpointsConfig 会顺手把 defaultParamsEndpoint 也设成 anthropic,让前端参数
面板显示 Anthropic 的字段(maxOutputTokens/thinking)而不是 OpenAI 的 max_tokens。
4.5 模型列表:静态写死 vs 动态拉取
两种来源,由 models.ts 统一处理:
| 方式 | 怎么来 | 典型 |
|---|---|---|
| 静态 | 代码里写死的常量数组 | getGoogleModels(models.ts:516)、getBedrockModels(models.ts:528) |
| 动态 | 运行时 HTTP 拉 /v1/models | fetchModels(models.ts:153),custom 设 models.fetch: true 时 |
fetchModels 里藏着两条精华:
- Ollama 优先走原生接口:名字以
ollama开头的 endpoint,先试 Ollama 原生/api/tags, 失败再退回 OpenAI 兼容的/models(models.ts:213-234)。 - 缓存要防串号:模型列表默认按
baseURL+apiKey缓存 2 分钟。但如果调用方转发了会解析成 用户身份的 header(如Authorization: Bearer {{...ID_TOKEN}},给 LiteLLM 这类按用户过滤模型 的网关),就跳过缓存——否则甲用户的过滤结果会被喂给乙用户(models.ts:185-194)。
4.6 token / pricing:上下文窗口与花多少钱
它要解决的小问题: 前端要显示「这个 model 上下文多大、大概多少钱」,但不该在客户端重写 一套模式匹配。
思路: 服务端一次算好。buildTokenConfigMap(endpoints/pricing.ts:26)遍历每个 endpoint 的
每个 model,解析出上下文窗口(context),并在开了 interface.contextCost 时附上定价
(prompt/completion/cacheWrite/cacheRead 每百万 token 费率)。
覆盖优先级清晰:管理员在 yaml 里写的 tokenConfig 覆盖 > 动态 fetch 到的配置 > 内置 map 兜底
(pricing.ts:37-97)。custom endpoint 的这套解析入口在 resolveTokenConfigMap
(endpoints/tokenConfig.ts:24)。
一个隐蔽的账务陷阱(值得单独记): 不同家对「缓存 token 是否已计入 input」处理不一样。
cacheSubsetProviders(schemas.ts:93)列出「input_tokens 已经包含缓存 token」的一批
(OpenAI/Google/Anthropic…);不在表里的(如 Bedrock)是另加的。这张表是账单路径和前端用量
归一化的单一事实源——漏了它,缓存部分会被重复计费。
5. 新老两条路径:BaseClient vs AgentClient(以及 OllamaClient 样例)
LibreChat 有一段架构演进:老的「每家一个具体客户端」正在让位给「统一 agent 运行时 + 上面那套 endpoint 配置」。这条边界必须讲清,否则读源码会迷路。
老路径:BaseClient 是一个抽象基类。 它定义了一堆必须被子类实现的抽象方法
(api/app/clients/BaseClient.js:145):
setOptions() → throw "must be implemented" // 每家自己解析选项
getCompletion() → throw "must be implemented" // 每家自己调 API
sendCompletion() → throw
buildMessages() → throw "Subclasses must implement" // 每家自己拼消息
它自己只实现通用的重活:消息落库、token 计数、对话保存、标题触发(sendMessage 等)。
真正「怎么调某家 API」留给子类。这是经典的模板方法模式。
新路径:AgentClient 继承了 BaseClient,但换了打法。 关键事实——
AgentClient extends BaseClient(api/server/controllers/agents/client.js:91)。它复用基类那套
持久化/计数机制,但不再自己手写各家 API 调用;而是靠本章讲的 getProviderConfig →
initializeX 拿到 llmConfig,把实际 LLM 通信委托给 @librechat/agents 运行时。
两条路径怎么并存,一句话:
老: BaseClient (抽象)
└─ 具体子类 XxxClient:自己 setOptions/getCompletion/buildMessages
新: BaseClient (抽象,复用其持久化/计数)
└─ AgentClient:LLM 调用改由 endpoint 配置 + @librechat/agents 运行时接管
所以本章的 endpoint 抽象,主要服务于新路径:初始化函数产出的 llmConfig,喂给 agent 运行时
(agents/initialize.ts:982-1000 正是「查表 → 取 options」的落点),而不是喂给某个手写的 XxxClient。
OllamaClient 是老路径的一个「标本」。 它是一个具体客户端实现
(api/app/clients/OllamaClient.js:42):自带 ollamaPayloadSchema、chatCompletion 流式循环、
formatOpenAIMessages 消息转换。但在本 commit 里,没有任何活跃代码 import 这个类作为请求客户端
(全仓搜 new OllamaClient / import 均无命中)。今天的 Ollama 走的是 custom endpoint 那条路:
模型列表由 models.ts 里独立的 fetchOllamaModels(models.ts:83)拉取,聊天则当作 OpenAI 兼容
服务处理。
结论: OllamaClient 更像一份遗留的具体客户端样例——展示了老路径「一家一个类」长什么样,
但已被「custom endpoint + 统一运行时」的新范式架空。读它是为理解历史,不是理解当前主链路。
6. 边界与局限(诚实)
custom假定 OpenAI 兼容。 兜底路径默认服务实现了/v1/chat/completions和/v1/models。 真正「不一样」的协议只有 Anthropic 原生这一条内建后门(provider: anthropic);其它异形协议 仍需专门代码或外部网关抹平。- 大小写歧义会硬报错,不猜。 多个仅大小写不同的 custom endpoint 撞名时,
getProviderConfig直接抛错要求改名(providers.ts:188-193)——这是刻意的安全选择,不是 bug。 - 用户自填 URL 有明确降权。 一旦 baseURL 用户可控,内部密文 header 一律不下发、且强制 SSRF 校验。想让网关拿到用户身份令牌,只能用管理员可信的 baseURL 配 header 模板。
- 两套 provider 词汇易混。
EModelEndpoint(9 个大类)和Providers(运行时客户端家族)不是 一一对应;一个custom可能映射到多个Providers。读代码时先分清当前变量是哪一套。 OllamaClient等老式具体客户端已基本悬空。 别把它当主链路去改。
7. 代码地图(导航索引)
用符号名 grep 定位,比行号抗漂移。
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| endpoint 大类枚举 | packages/data-provider/src/schemas.ts | EModelEndpoint |
| 运行时 provider 家族 | packages/data-provider/src/schemas.ts | Providers |
| 已知第三方名单 | packages/data-provider/src/config.ts | KnownEndpoints · FetchTokenConfig |
| Bedrock 内部聚合家 | packages/data-provider/src/schemas.ts | BedrockProviders · getModelKey |
| OpenAI 兼容判定 | packages/data-provider/src/schemas.ts | isOpenAILikeProvider · openAILikeProviders |
| Azure 模型→部署映射 | packages/data-provider/src/azure.ts | mapModelToAzureConfig |
| 分发表(心脏) | packages/api/src/endpoints/config/providers.ts | getProviderConfig · providerConfigMap |
| 已知第三方判定 | packages/api/src/endpoints/config/providers.ts | isKnownCustomProvider |
| 收敛漏斗 | packages/api/src/endpoints/openai/config.ts | getOpenAIConfig |
| OpenAI 初始化 | packages/api/src/endpoints/openai/initialize.ts | initializeOpenAI |
| Anthropic 初始化(含 Vertex) | packages/api/src/endpoints/anthropic/initialize.ts | initializeAnthropic |
| Bedrock 初始化 | packages/api/src/endpoints/bedrock/initialize.ts | initializeBedrock |
| custom 即插即用 | packages/api/src/endpoints/custom/initialize.ts | initializeCustom · buildAnthropicCustomConfig |
| custom yaml 装载 | packages/api/src/endpoints/custom/config.ts | loadCustomEndpointsConfig |
| 模型列表(动态/静态) | packages/api/src/endpoints/models.ts | fetchModels · getGoogleModels · getBedrockModels |
| 上下文/定价解析 | packages/api/src/endpoints/pricing.ts | buildTokenConfigMap |
| custom token 配置入口 | packages/api/src/endpoints/tokenConfig.ts | resolveTokenConfigMap |
| 统一入参/产物类型 | packages/api/src/types/endpoints.ts | BaseInitializeParams · InitializeResultBase |
| 请求体→endpointOption | api/server/middleware/buildEndpointOption.js | buildEndpointOption · buildFunction |
| 分发表→取 options 落点 | packages/api/src/agents/initialize.ts | getProviderConfig → getOptions |
| 老式抽象客户端基类 | api/app/clients/BaseClient.js | BaseClient(setOptions/getCompletion/buildMessages 抽象) |
| 新路径客户端 | api/server/controllers/agents/client.js | AgentClient extends BaseClient |
| 老式具体客户端样例 | api/app/clients/OllamaClient.js | OllamaClient(现已无活跃 import) |
相邻章节:总览与阅读地图见 index.md;消息生命周期见 01-request-lifecycle.md;拿到
llmConfig之后的 agent 编排与流式见 03-agent-orchestration.md。