跳到主要内容

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.tsfetchModels / 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 / azureOpenAIOpenAI 官方 / Azure 托管的 OpenAI
anthropicAnthropic 官方(含 Vertex 变体)
googleGoogle Gemini / Vertex
bedrockAWS Bedrock(下面又聚合了 anthropic/meta/… 多家)
custom「万能类」——所有 yaml 里自定义的 OpenAI 兼容服务都归这类
agentsLibreChat 自家的 agent 编排层(见 03)
assistants / azureAssistantsOpenAI 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 大类,运行时可能被解析成 openaideepseekopenrouter 等多个 Providers

为什么 custom 是整个抽象的关键: 它是一个开放集合。前四类是写死的品牌,custom 却 允许任意字符串命名的 endpoint。这正是「几十家 provider 收敛成一套接口」的落点——绝大多数 第三方,LibreChat 一行专用代码都不写,全靠 custom 这一类兜住(下详 §4.4)。

两个补充词表(都在 data-provider),知道存在即可:

  • KnownEndpoints(config.ts:1807):一串已知的 OpenAI 兼容服务名(ollamaopenrouterdeepseekgroqmistralxai…)。它们仍走 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把原始请求体解析/校验成 endpointOptionapi/server/middleware/buildEndpointOption.js:28
getProviderConfig分发表:provider 名 → 对应初始化函数 + 归一化后的 providerpackages/api/src/endpoints/config/providers.ts:136
providerConfigMap那张表本身(常量)providers.ts:39
initializeX 家族各家的密钥解析 + 参数组装,产出 llmConfigendpoints/{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,返回 initializeCustomoverrideProvider = 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 的名字大小写敏感(允许 OpenRouteropenrouter-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)——这正是「一套接口」在类型层面的体现。

它们内部各自处理这家特有的密钥怎么取。看三种典型差异:

endpointkey 来源(择要)特殊处
initializeOpenAI环境变量 OPENAI_API_KEY,或用户自填,或 Azure 凭证Azure 还要按 model 映射到部署组(mapModelToAzureConfig)
initializeAnthropicANTHROPIC_API_KEY,或用户自填,或 Google Vertex 凭证Vertex 走完全不同的服务账号鉴权(anthropic/initialize.ts:37-48)
initializeCustomyaml 里的 ${VAR} 展开,或用户自填 key/URL用户自填 URL 时要过 SSRF 校验、且不转发内部 header

一个安全细节值得记: 当 baseURL 是用户自己填的,initializeOpenAI / initializeCustom故意不下发管理员配置的 header(openai/initialize.ts:81trustedURL)。因为那些 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/modelsfetchModels(models.ts:153),custom 设 models.fetch: true

fetchModels 里藏着两条精华:

  1. Ollama 优先走原生接口:名字以 ollama 开头的 endpoint,先试 Ollama 原生 /api/tags, 失败再退回 OpenAI 兼容的 /models(models.ts:213-234)。
  2. 缓存要防串号:模型列表默认按 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 调用;而是靠本章讲的 getProviderConfiginitializeX 拿到 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):自带 ollamaPayloadSchemachatCompletion 流式循环、 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.tsEModelEndpoint
运行时 provider 家族packages/data-provider/src/schemas.tsProviders
已知第三方名单packages/data-provider/src/config.tsKnownEndpoints · FetchTokenConfig
Bedrock 内部聚合家packages/data-provider/src/schemas.tsBedrockProviders · getModelKey
OpenAI 兼容判定packages/data-provider/src/schemas.tsisOpenAILikeProvider · openAILikeProviders
Azure 模型→部署映射packages/data-provider/src/azure.tsmapModelToAzureConfig
分发表(心脏)packages/api/src/endpoints/config/providers.tsgetProviderConfig · providerConfigMap
已知第三方判定packages/api/src/endpoints/config/providers.tsisKnownCustomProvider
收敛漏斗packages/api/src/endpoints/openai/config.tsgetOpenAIConfig
OpenAI 初始化packages/api/src/endpoints/openai/initialize.tsinitializeOpenAI
Anthropic 初始化(含 Vertex)packages/api/src/endpoints/anthropic/initialize.tsinitializeAnthropic
Bedrock 初始化packages/api/src/endpoints/bedrock/initialize.tsinitializeBedrock
custom 即插即用packages/api/src/endpoints/custom/initialize.tsinitializeCustom · buildAnthropicCustomConfig
custom yaml 装载packages/api/src/endpoints/custom/config.tsloadCustomEndpointsConfig
模型列表(动态/静态)packages/api/src/endpoints/models.tsfetchModels · getGoogleModels · getBedrockModels
上下文/定价解析packages/api/src/endpoints/pricing.tsbuildTokenConfigMap
custom token 配置入口packages/api/src/endpoints/tokenConfig.tsresolveTokenConfigMap
统一入参/产物类型packages/api/src/types/endpoints.tsBaseInitializeParams · InitializeResultBase
请求体→endpointOptionapi/server/middleware/buildEndpointOption.jsbuildEndpointOption · buildFunction
分发表→取 options 落点packages/api/src/agents/initialize.tsgetProviderConfiggetOptions
老式抽象客户端基类api/app/clients/BaseClient.jsBaseClient(setOptions/getCompletion/buildMessages 抽象)
新路径客户端api/server/controllers/agents/client.jsAgentClient extends BaseClient
老式具体客户端样例api/app/clients/OllamaClient.jsOllamaClient(现已无活跃 import)

相邻章节:总览与阅读地图见 index.md;消息生命周期见 01-request-lifecycle.md;拿到 llmConfig 之后的 agent 编排与流式见 03-agent-orchestration.md