Provider 与 API 的统一、归一化与配置机制
这篇文档解决什么问题
这个项目支持很多模型提供商,例如 OpenAI、Anthropic、Google、Mistral、xAI、Groq、Cerebras、OpenRouter、GitHub Copilot、Amazon Bedrock 等。
这些提供商表面上都在做同一件事:接收一段上下文,返回一段模型输出,有时还会返回工具调用、思考内容、图片输入、用量和费用。
但它们实际上有很多差异:
- 鉴权方式不同,有的用 API Key,有的用 OAuth,有的用云平台默认凭证
- 请求协议不同,有的走 OpenAI Chat Completions,有的走 OpenAI Responses,有的走 Anthropic Messages,有的走 Google Generative AI
- 同样叫“支持 OpenAI 兼容”,字段也可能不完全一样
- 思考能力的开关不同,有的叫
reasoning_effort,有的叫thinking,有的叫enable_thinking - 工具调用格式不同,工具结果回传格式也不同
- 流式输出格式不同,有的按文本分块,有的按 item 分块,有的按 reasoning / text / tool call 分开流出
- tool call ID 的格式约束不同,有的能接受很长很复杂的 ID,有的只能接受短且受限字符集的 ID
- 有的支持图片输入,有的不支持
- 有的支持 prompt cache,有的支持但字段不一样,有的完全不支持
这个项目的核心价值,不是简单地“支持很多 provider”,而是把这些差异收敛成一套统一的内部模型,让上层只用面对一致的概念。
换句话说,这个项目解决的是:
- 如何让多个 provider 共用一套调用入口
- 如何把各家输入格式统一成同一种上下文模型
- 如何把各家输出格式统一成同一种事件流和消息结构
- 如何让配置尽量声明式,而不是到处写特判
- 如何让“新增 provider”变成一种可复制的模式,而不是一次性工程
如果只看一句话,这个项目的设计思想是:
先统一内部语义,再为每种 API 写适配器;provider 只是“模型归属与默认配置”,真正的协议差异由 api adapter 和 compat 层吸收。
一句话总览
这个项目把“模型提供商差异”拆成四层:
- 模型元数据层:描述“这个模型是谁、走什么 API、支持什么能力”
- 统一上下文层:把所有会话都表示成统一的
Context / Message / Tool / AssistantMessage - API 适配层:按 API 类型把统一上下文转换成各家请求,再把原生响应转换回来
- 兼容修补层:对“看起来兼容但其实不完全兼容”的 provider 做小范围修正
这四层叠起来,才能同时做到:
- 上层调用简单
- 配置可扩展
- 差异被控制在局部
- 跨 provider 切换时上下文还能继续使用
先建立最重要的心智模型:provider 不等于 api
很多系统会把 provider 和协议绑死,例如“OpenAI 就是 OpenAI API”,“Anthropic 就是 Anthropic API”。这个项目没有这样做。
这里有两个独立概念:
1. provider
provider 表示“这个模型归属于谁,默认使用哪套认证和配置”。
它更接近业务视角,关心的是:
- 这个模型应该显示在谁的 provider 下面
- 默认应该去哪个 base URL
- 默认用哪种鉴权方式
- 它属于哪组模型
- 在 CLI、TUI、设置面板里怎么被展示和选择
例如:
openaianthropicgooglegithub-copilotopenroutermistralzaiamazon-bedrock
2. api
api 表示“这个模型实际使用哪一种协议适配器”。
它更接近技术视角,关心的是:
- 请求体长什么样
- 响应流怎么解析
- thinking / tool call / image / usage 分别怎么映射
- stop reason 怎么归一化
这个项目内置了若干种 API 适配器,例如:
openai-completionsopenai-responsesazure-openai-responsesopenai-codex-responsesanthropic-messagesgoogle-generative-aigoogle-gemini-cligoogle-vertexbedrock-converse-stream
为什么要拆成两层
因为现实世界不是“一家 provider 对应一种协议”。
典型情况有两类:
一类是“多个 provider 共享同一种 API”
例如很多服务都走 OpenAI 兼容接口,但并不是真正的 OpenAI:
- Mistral
- xAI
- Cerebras
- Groq
- OpenRouter 中的一部分模型
- 一些本地推理服务,例如 Ollama、LM Studio、vLLM
这些服务如果都重写一遍完整 provider 逻辑,代码会非常重复。更好的做法是:
- 复用
openai-completions适配器 - 只在必要处补一个
compat配置
另一类是“同一个 provider 可能同时提供多种协议或多种模型来源”
例如:
github-copilot下面既可能代理 OpenAI 风格的模型,也可能代理 Anthropic 风格的模型openrouter从业务上看是一个 provider,但上游真正跑模型的可能是 OpenAI、Anthropic、Bedrock 等
因此:
provider决定“是谁”api决定“怎么说话”
这就是这个项目能同时支持很多 provider、又不把逻辑写炸的关键。
整体架构:从调用到输出的一条完整链路
可以把整条链路理解为下面这张图:
调用方
|
| 传入 Model + Context + Options
v
统一入口
|
| 根据 model.api 找到对应 API 适配器
v
API 适配器
|
| 1. 把统一上下文转换成 provider 请求
| 2. 发起真实请求
| 3. 解析原生流式响应
v
统一事件流 / 统一 AssistantMessage
|
| 输出 text / thinking / toolCall / usage / stopReason
v
上层应用(CLI / TUI / Agent / Web UI)这条链路里最重要的是“统一入口”和“统一输出”。
因为只要这两端稳定:
- 中间想新增多少 provider 都可以
- 上层应用不用跟着新增条件分支
- 会话可以在 provider 之间迁移
第一层:模型元数据层
模型元数据层负责回答一个问题:
对于一个具体模型,系统最少需要知道哪些信息,才能正确调用它?
这个项目把一个模型抽象成一份标准化元数据。你可以把它理解成“模型注册表中的一行记录”。
一个模型最核心的字段
每个模型至少要有这些信息:
id:模型在远端 API 中使用的真实标识name:展示给用户看的友好名称provider:这个模型属于哪个 providerapi:这个模型走哪种 API 适配器baseUrl:请求默认发到哪里reasoning:是否支持思考/推理能力input:支持哪些输入类型,例如文本、图片contextWindow:上下文窗口大小maxTokens:最大输出 token 数cost:输入、输出、缓存读写的费用信息headers:provider 级别的默认请求头compat:对“半兼容协议”的差异修补配置
为什么模型元数据要这么全
因为它既是“配置”,也是“能力声明”。
例如:
- 如果
reasoning = false,上层就知道不要给这个模型强行传 thinking 配置 - 如果
input不含image,消息转换时就知道要丢掉图片输入 - 如果
api = openai-completions,就知道应该走 OpenAI Chat Completions 适配器 - 如果
compat.maxTokensField = max_tokens,就知道请求里不能发max_completion_tokens
也就是说,很多差异并不是靠 if provider === ... 写死,而是先沉淀为模型元数据和兼容配置。
模型元数据从哪里来
这个项目同时支持两类模型来源:
1. 内置模型
项目自带一份模型清单。用户安装后,不需要手动录入每个模型的能力参数。
这让系统具备两个优势:
- 用户体验好,开箱即用
- 上层可以做更强的能力推断,比如默认模型选择、thinking 能力判断、成本统计
2. 自定义模型
用户也可以通过配置文件增加或覆盖模型,例如:
- 接一个公司内部代理
- 接本地 Ollama / vLLM / LM Studio
- 用 OpenRouter 或其他统一网关
- 覆盖内置 provider 的默认地址和兼容设置
因此模型层本质上是一个“注册表”,而不是写死在代码里的枚举。
第二层:统一上下文层
如果模型元数据回答的是“这个模型是什么”,那么统一上下文层回答的是:
不同 provider 之间,怎样用同一种内部表示来描述一段会话?
这个项目的做法是:不直接把上游 API 的消息结构暴露给上层,而是定义一套内部统一消息模型。
统一上下文结构
一段会话被表示成:
systemPromptmessagestools
其中 messages 只允许三种角色:
userassistanttoolResult
这很重要,因为有些上游协议角色很多,有些角色很少,但内部只保留“真正影响代理推理链”的最小集合。
assistant 消息不是一整段文本,而是内容块数组
assistant 的内容不会被压成一整段字符串,而是拆成内容块列表。块的类型包括:
textthinkingtoolCall
这是一项非常关键的设计。
因为不同 provider 的输出虽然形式不同,但最后都可以投影到这三类语义:
- 普通回答文本
- 模型思考内容
- 工具调用
只要把内部 assistant 消息设计成块数组,就可以保留更多语义,而不是在早期就把信息丢掉。
tool result 也被统一成标准消息
工具返回不会直接拼进 assistant 文本,而是作为独立的 toolResult 消息,包含:
- 对应的
toolCallId - 工具名
- 文本和图片等内容块
- 是否出错
这样做的好处是:
- 续轮对话时可以精确回放工具结果
- 不同 provider 可以按各自协议重新编码
- 图片型工具结果也可以被统一表示
为什么统一上下文层是必要的
如果没有这层,上层应用就必须知道:
- Anthropic 的 tool_result 长什么样
- OpenAI Responses 的 function_call_output 长什么样
- Google 的 functionResponse 长什么样
这会导致业务层和协议层耦合。统一上下文层的意义就是把这种耦合切断。
第三层:API 适配层
统一上下文层解决的是内部表示问题,但真正发请求时,还是必须说各家听得懂的话。
这就是 API 适配层的职责。
API 适配层做什么
每种 API 适配器都要完成三件事:
- 把统一
Context转换成目标 API 的请求格式 - 把统一工具定义转换成目标 API 的工具格式
- 把目标 API 的流式响应转换回统一事件流和统一
AssistantMessage
可以把它理解成“协议翻译器”。
为什么是按 API 写适配器,而不是按 provider 写
因为很多 provider 共享一套协议形状。
例如:
- 大量 provider 都能复用
openai-completions - 所有 Anthropic Messages 兼容服务都可以复用
anthropic-messages - 所有 Google Generative AI 兼容服务都可以复用
google-generative-ai
这样设计的结果是:
- 代码复用率高
- 新接一个 provider 往往只要补配置,不需要重写请求解析器
- 兼容问题被限制在小范围
统一入口为什么能成立
因为入口并不关心 provider,而只关心 model.api。
调用时流程大致是:
- 上层拿到一个
Model - 模型里包含
api - 系统根据
api找到对应适配器 - 适配器负责剩下的全部工作
这意味着:
openai和mistral虽然是不同 provider,但如果都走openai-completions,上层入口是完全一样的github-copilot下面如果某个模型走 Anthropic 风格,入口也不需要特殊处理
第四层:compat 兼容修补层
到这里还差最后一块拼图。
问题是:现实里很多服务宣称兼容某个协议,但只是“大致兼容”,并不是完全一致。
例如:
- 同样走 OpenAI 兼容接口,但有的 provider 不支持
developerrole - 有的不支持
reasoning_effort - 有的要求
max_tokens,有的要求max_completion_tokens - 有的 tool result 必须补
name - 有的对 tool call ID 的字符集和长度有严格要求
- 有的 thinking 不能作为独立块发送,只能降级成普通文本
如果把这些特判都直接写进某个 provider 实现里,代码很快就会失控。
这个项目的做法是:加一个 compat 配置层。
compat 的作用
compat 不是新的协议层,它只做一件事:
在复用某个 API 适配器的前提下,对特定 provider 的小差异做声明式修补。
compat 典型能修什么
以 OpenAI-compatible 体系为例,compat 常见字段包括:
supportsStoresupportsDeveloperRolesupportsReasoningEffortsupportsUsageInStreamingsupportsStrictModemaxTokensFieldrequiresToolResultNamerequiresAssistantAfterToolResultrequiresThinkingAsTextrequiresMistralToolIdsthinkingFormatopenRouterRoutingvercelGatewayRouting
thinkingFormat 是一个非常典型的例子
表面上很多模型都支持“思考”,但不同服务的参数格式不一样:
- OpenAI 风格:
reasoning_effort - z.ai 风格:
thinking: { type: "enabled" } - Qwen 风格:
enable_thinking: true
如果没有 compat,这里就只能写很多 provider if/else。
有了 compat 后,适配器只需要根据 thinkingFormat 选择正确的编码方式。这样:
- 业务语义还是统一的“我要开启 reasoning”
- 协议差异被限制在编码层
compat 的本质
compat 本质上是:
用数据描述小差异,而不是用代码扩散小差异。
这是这个项目可维护性的关键之一。
统一输入:项目如何把不同会话输入归一化
前面讲的是静态设计,下面讲动态过程。
当一段上下文要被发给某个模型时,系统做的不是“直接序列化”,而是一个多步归一化流程。
第一步:从统一上下文出发
调用方只需要提供统一的:
- system prompt
- user / assistant / toolResult 消息
- tools
这一步完全不需要关心目标 provider 的协议细节。
第二步:做跨 provider 兼容转换
这一步非常关键。因为一段会话可能不是在当前 provider 里产生的,而是从别的 provider 切过来的。
例如:
- 先用 Claude 回答
- 再切到 GPT
- 再切到 Gemini 继续
这时旧消息里会带着原 provider 的 thinking signature、tool call ID 格式、工具调用结构等信息。
系统会先做一次“跨 provider 归一化转换”,主要处理这些问题:
1. thinking block 不能盲目原样回放
只有在“同 provider、同 API、同模型”或者明确兼容的场景下,thinking block 才能安全保留。
否则通常要降级成普通文本,原因包括:
- 目标 provider 不理解原来的 thinking signature
- 原 provider 的 reasoning item 无法被目标 provider 验证
- 强行回放会触发协议校验错误
也就是说,系统优先保证“上下文可继续使用”,而不是强行保留原始底层结构。
2. tool call ID 需要规范化
各 provider 对 tool call ID 的要求不同,例如:
- 有的允许超长、带特殊字符的 ID
- 有的只允许字母、数字、下划线、短横线
- 有的长度上限很短
- Mistral 一类 provider 甚至对格式有更严格要求
因此系统会在必要时对 tool call ID 做规范化,并同步修正对应的 tool result 引用。
3. thought signature 只能在兼容场景下保留
有些 provider 会返回“思考签名”或“加密 reasoning 片段”,它们的意义通常只在原 provider 或原模型下成立。
切 provider 时,这些信息经常必须丢弃或转成普通文本,否则会导致 replay 失败。
4. orphaned tool call 需要被补齐
如果历史里出现 assistant 发起了 tool call,但后续没有匹配的 tool result,某些 provider 会把这视为非法上下文。
系统会插入 synthetic tool result 来保持会话结构完整。
这一步的设计哲学很明确:
比起“完全保留底层细节”,更重要的是“让会话可以安全地继续”。
第三步:按目标 API 转换消息格式
完成跨 provider 归一化后,系统再把统一消息转换成目标 API 的格式。
这里每种 API 都有自己的一套映射规则。
OpenAI Chat Completions 风格
会被映射成:
system/developer/user/assistant/tooltool_callstool_call_id- 可选的 reasoning 相关字段
OpenAI Responses 风格
不会直接映射成普通 chat message,而是拆成 item 列表,例如:
- reasoning item
- output message
- function_call
- function_call_output
这套结构比 Chat Completions 更细粒度,因此适配器需要额外维护 item 级语义。
Anthropic Messages 风格
assistant 内容会被组织成 block 列表,例如:
- text block
- thinking block
- tool_use block
tool result 则会被封装进 user 角色里的 tool_result block。
Google Generative AI 风格
消息会被组织成:
ContentPartfunctionCallfunctionResponse
Google 体系尤其强调 thought signature 和 function response 的结构完整性。
第四步:根据模型能力过滤不支持的内容
例如:
- 如果目标模型不支持图片输入,图片块会被过滤掉
- 如果目标模型不支持 reasoning,对应 thinking 配置会被忽略或关闭
- 如果某类 provider 不支持某个工具字段,就在工具转换时去掉
这一步保证“统一输入”不是盲目下发,而是根据模型能力裁剪后的结果。
统一输出:项目如何把不同 provider 的响应归一化
请求发出后,真正复杂的部分才开始。
因为每家 provider 的流式输出协议都不同,甚至同一家不同 API 也完全不同。
这个项目的策略是:
所有 provider 最终都必须产出同一套事件流和同一种 assistant 消息结构。
统一事件流
系统定义了一套标准流式事件:
starttext_starttext_deltatext_endthinking_startthinking_deltathinking_endtoolcall_starttoolcall_deltatoolcall_enddoneerror
这意味着上层 UI 或 Agent 逻辑完全不需要知道:
- OpenAI 的 chunk 长什么样
- Anthropic 的 content block delta 长什么样
- Google 的 candidates / parts 怎么分布
上层只要消费统一事件流即可。
为什么统一事件流很重要
如果没有统一事件流,上层会遇到这些问题:
- TUI 要分别支持每家 provider 的增量文本协议
- thinking 面板要分别适配每家 provider 的格式
- tool call UI 要分别处理每家的 partial JSON 格式
- usage 和 stop reason 无法统一显示
统一事件流之后,这些都变成同一个接口问题。
assistant 最终输出也被统一
流式事件结束后,系统还会得到一个完整的 AssistantMessage。它包含:
- 统一的内容块数组
- 统一的 usage
- 统一的 stop reason
- 统一的 provider / api / model 标识
这样无论上层是:
- CLI
- TUI
- Agent Runtime
- Web UI
都可以用同一种方式持久化和回放 assistant 消息。
stop reason 也被统一
不同 provider 原生返回的停止原因很多种,但内部统一成少数几类:
stoplengthtoolUseerroraborted
统一后的好处是:
- 上层逻辑简单
- 重试逻辑简单
- UI 展示一致
- 统计和测试更容易覆盖
usage 和 cost 也被统一
不同 provider 返回 token usage 的方式不同:
- 有的会把 cache token 单独给出来
- 有的会把 reasoning token 算在 completion 里
- 有的会把 cached token 包在 input 里
- 有的流式期间不返 usage,只在最终事件给
适配器会把这些差异统一映射成标准 usage:
inputoutputcacheReadcacheWritetotalTokenscost
这样上层不需要知道某家 provider 的 usage 统计细节。
reasoning / thinking 是如何被统一的
这是整个项目中最漂亮的一部分之一。
因为“思考能力”几乎是各家差异最大的地方,但这个项目仍然给了一个统一接口。
上层看到的接口
上层不需要直接关心 provider 的原生命名,而是用一个统一概念:
reasoning: minimal | low | medium | high | xhigh
这就是所谓的“简单选项接口”。
为什么这层统一非常重要
如果没有它,上层调用就会变成:
- OpenAI 要传
reasoningEffort - Anthropic 要传
thinkingEnabled + thinkingBudgetTokens或 adaptive effort - Google 要传
thinking.enabled + budgetTokens或thinking.level - z.ai 要传另一种 thinking 结构
这会把业务代码变成一堆 provider 分支。
这个项目怎么做
系统先定义统一 reasoning 级别,再在各 API 适配器里做映射。
对 OpenAI 风格模型
通常映射成 reasoning_effort 或同义字段。
对 Anthropic
会根据模型能力决定:
- 新模型走 adaptive thinking
- 旧模型走 budget-based thinking
也就是说,对调用方来说只是“我要 high”,但系统会自动决定应该发:
- effort 型参数
- 还是 thinking budget 型参数
对 Google
会根据模型家族决定:
- 有的模型走
thinkingLevel - 有的模型走
thinkingBudget
对 z.ai / Qwen 这类 OpenAI 兼容变体
会通过 thinkingFormat 兼容配置,把统一 reasoning 语义改写成 provider 需要的字段格式。
这件事背后的思想
reasoning 的统一并不是“所有 provider 真有同一个参数”,而是:
先统一用户想表达的意图,再由适配器把意图翻译成各家最接近的实现方式。
这才是归一化的正确方式。
tools 是如何被统一的
工具调用是另一个差异很大的领域。
统一工具支持,至少要解决四个问题:
- 工具定义格式不同
- 工具调用流式增量格式不同
- 工具结果回传格式不同
- tool call ID 兼容性不同
统一的工具定义
内部工具定义只保留最核心的三部分:
- 工具名
- 描述
- 参数 schema
然后在发请求时,再由各 API 适配器转换成:
- OpenAI function tool
- Anthropic tool schema
- Google function declaration
统一的工具调用输出
无论底层 provider 怎么表达,assistant 最终都统一产出:
type: toolCallidnamearguments
这就让工具执行器可以完全脱离 provider 工作。
统一的工具结果输入
工具执行完成后,统一塞回一条 toolResult 消息。
适配器再根据目标 API 把它翻译成:
- OpenAI 的
tool消息 - OpenAI Responses 的
function_call_output - Anthropic 的
tool_result - Google 的
functionResponse
tool call ID 为什么要特别强调
因为这是跨 provider handoff 最容易炸的地方之一。
原因很简单:
- 某个 provider 生成的 ID,另一个 provider 可能根本不接受
- 但 tool result 又必须引用这个 ID
- 一旦 ID 规则不兼容,会话就无法继续
所以系统会把“ID 归一化”和“toolResult 引用重写”当成一套联动操作处理。
这也是它能做跨 provider 会话迁移的关键能力之一。
图片输入与图片型工具结果是如何被统一的
不同 provider 对多模态支持差异很大。
这个项目没有把图片单独搞成另一套会话系统,而是直接纳入统一消息块模型。
用户输入中的图片
用户消息内部可以包含:
- 文本块
- 图片块
然后在消息转换时:
- 支持图片的模型按目标协议编码
- 不支持图片的模型过滤图片块
工具结果中的图片
工具结果也允许包含图片块。
这样系统就可以支持例如:
- 代码执行后产出图表截图
- OCR 或图像分析工具返回图像
- 浏览器工具返回页面截图
不同 provider 会用不同方式接收这些图像结果:
- 有的把它们作为 tool result 的内嵌部分
- 有的必须拆成额外的用户消息
这种差异依然由适配器吸收,上层只管写统一的 toolResult。
prompt cache / session 这类差异是如何被统一的
有些 provider 支持 prompt cache,但实现方式并不一致。
项目没有强行把所有细节都暴露给上层,而是提供一套比较保守的统一选项:
cacheRetentionsessionId
统一语义是什么
上层只表达两件事:
- 我希望缓存保留多久
- 我是否希望同一个 session 尽可能复用缓存
底层怎么做
不同适配器会根据 provider 能力,映射到各自支持的字段:
- OpenAI 系体系使用它能识别的 prompt cache 机制
- Anthropic 体系使用它自己的 cache control
- 不支持缓存的 provider 直接忽略这些字段
这是一种非常实用的“能力上收”方式:
- 对上层来说,只有统一的缓存意图
- 对底层来说,尽量向 provider 的真实能力对齐
认证是如何被统一的
认证是 provider 差异最明显的地方之一,但这个项目把它抽成了几类统一来源。
上层并不直接关心认证细节
调用适配器时,通常只需要一个 apiKey 概念,或者让系统自行解析。
真正的认证来源可以是:
- CLI 显式传入
- 本地 auth 文件
- 环境变量
- OAuth 登录结果
- 云平台默认凭证
- 自定义 provider 配置里的 key
为什么要统一成“解析顺序”
因为不同 provider 虽然底层认证方式不同,但对上层来说,本质问题只是:
我现在要用这个 provider,请帮我找到当前最合适的凭证。
项目把这个问题变成“统一的 credential resolution”流程,而不是让每个调用方分别实现一遍。
特殊认证也被收敛进统一模型
例如:
- GitHub Copilot、OpenAI Codex、Google 系列某些 provider 使用 OAuth
- Google Vertex 依赖 Application Default Credentials
- Amazon Bedrock 依赖 AWS 多来源凭证体系
这些 provider 最终仍然表现为“这个模型可以被成功调用”,而不是把复杂的认证细节暴露到上层会话逻辑里。
配置是如何做到“方便改、方便扩展、尽量不改代码”的
如果一个系统支持很多 provider,但每加一个 provider 都要改很多代码,那么它仍然不算易配置。
这个项目在配置层做了三件很有价值的事:
- 让内置 provider 开箱即用
- 让常见自定义 provider 只靠配置就能接入
- 让真正不兼容的 provider 才需要写扩展代码
配置入口分三档
第一档:直接使用内置 provider
这是最简单的方式。
系统已经知道:
- provider 列表
- 模型列表
- 默认 API
- 默认能力声明
- 默认 URL
- 默认 compat
用户只要提供认证信息即可。
第二档:通过配置文件覆盖或新增 provider
适用于这些场景:
- 把现有 provider 改走自己的代理地址
- 给某个 provider 增加自定义模型
- 接一个 OpenAI-compatible / Anthropic-compatible / Google-compatible 的内部服务
- 覆盖特定模型的 compat、headers、价格、上下文窗口
这时通常只要写配置文件,不需要改源码。
第三档:通过扩展注册自定义 provider
只有当 provider 不属于现有协议家族时,才需要自己实现新的流式适配器。
也就是说,项目把扩展门槛压缩到了:
- 大多数“兼容某现有协议”的 provider,只需配置
- 只有“真正非标的 provider”,才需要写代码
这就是“方便配置”的真正含义。
配置为什么能这么灵活
因为系统在设计上明确区分了三类东西:
1. provider 级配置
例如:
baseUrlapiapiKeyheadersauthHeader
它们是“这个 provider 下所有模型默认共享”的配置。
2. model 级配置
例如:
idnamereasoninginputcontextWindowmaxTokenscostcompat
它们是“某个具体模型自己的能力声明”。
3. override 级配置
例如:
- provider 的模型覆盖
- 内置模型的局部覆盖
- compat 的局部改写
这样用户可以非常精细地改动,而不需要复制整套 provider 定义。
这套配置模型的优势
- 能覆盖 80% 以上的接入需求
- 最小改动原则明显
- 内置 provider 和自定义 provider 共用同一套心智模型
- 用户能理解“我是改 provider 级,还是改 model 级”
为什么 streamSimple / completeSimple 很重要
很多人第一次看这个项目,会以为“统一只是模型清单统一了”。其实不是。
真正让上层舒服的是:项目不只统一了模型注册,还统一了最常用的调用接口。
两套接口
项目实际上提供两层调用 API:
1. 简单接口
例如:
streamSimplecompleteSimple
特点是:
- 参数最少
- reasoning 用统一语义
- 适合大多数业务调用
2. provider-specific 接口
例如:
- 直接调用某个 provider 适配器
- 使用各家专属选项
特点是:
- 更细粒度
- 能控制特定 provider 的专属行为
为什么需要简单接口
因为绝大多数业务并不想知道:
- Anthropic 的 thinking budget 该怎么调
- Google 的 thinking level 该怎么映射
- OpenAI Responses 的 reasoning summary 该怎么开
业务层真正关心的是:
- 我想要普通回答还是更强推理
- 我需不需要工具调用
- 我需不需要流式输出
简单接口的价值,就是把这些“通用意图”从 provider-specific 参数里提出来。
为什么还保留 provider-specific 接口
因为完全抽象也会损失能力。
有些高级场景必须直达 provider 选项,例如:
- OpenAI Responses 的 reasoning summary
- Anthropic adaptive thinking 的 effort
- 某些 provider 的特有 headers 或 tool choice
因此这个项目不是“强行只有统一接口”,而是:
- 默认走统一接口
- 需要时允许下钻到 provider-specific 层
这是一个很实际的折中。
跨 provider handoff 为什么能成立
很多多模型系统都支持“切换模型”,但并不真正支持“把已有会话安全迁移到另一家 provider”。
这个项目专门解决了这个问题。
问题本质
如果一段会话原本是由某个 provider 生成的,那么历史消息里可能包含:
- 这个 provider 独有的 thinking signature
- 这个 provider 独有的 tool call ID 格式
- 这个 provider 独有的 message item 结构
直接把这些历史原封不动丢给另一个 provider,很容易失败。
系统的解决方法
在真正发送给目标模型之前,先做一次“跨 provider 兼容变换”。
核心策略是:
- 用户消息尽量原样保留
- 工具结果尽量原样保留
- assistant 的 thinking 在不兼容时降级成普通文本
- tool call 的 ID 在必要时规范化
- 无法安全 replay 的内部签名被抹平
- 不完整的工具链条被补齐
这意味着什么
这意味着系统优先保证两件事:
- 会话语义连续
- 目标 provider 能合法接受
它不追求保留所有底层技术细节,因为那会导致 handoff 不稳定。
这是归一化设计成熟的标志
一个系统如果只能“同 provider 内续聊”,那只能算 provider 封装。
一个系统如果能“跨 provider 续聊”,才说明它真的建立了独立于 provider 的内部会话模型。
这也是这个项目最值得学习的地方之一。
新增 provider 时,为什么不容易失控
支持很多 provider 的系统,最怕“每新增一个 provider,都需要改 10 个地方,而且每个地方都要加 if/else”。
这个项目通过分层设计,把新增 provider 变成了一套固定套路。
新增 provider 有三种路径
路径一:复用现有 API 适配器,只加模型配置
适用于:
- OpenAI-compatible
- Anthropic-compatible
- Google-compatible
这是最轻的一种。
只要模型元数据和 compat 写对,就能接入。
路径二:复用现有 API 适配器,再加少量 provider 特殊逻辑
适用于:
- 协议基本兼容,但认证、默认头、thinking 字段或工具字段有点不同
这时通常只需要:
- 配 compat
- 配 headers
- 配 baseUrl
- 必要时在适配器中补一个小分支
路径三:实现一个全新的 API 适配器
适用于:
- 请求协议完全不属于现有家族
- 响应流结构完全不同
- 认证和模型发现也高度定制
这时才需要:
- 新增 API 类型
- 实现新的适配器
- 注册到 API registry
- 加入模型元数据和文档
为什么不会失控
因为新增 provider 时,系统会强制你回答几个稳定问题:
- 它属于哪个 provider
- 它走哪种 api
- 它的模型元数据是什么
- 它有没有 compat 差异
- 它能否复用现有输入转换和输出归一化逻辑
这些问题一旦稳定,新增 provider 就变成“填空题”而不是“重构题”。
这个项目的设计优势总结
如果把整个设计压缩成几条原则,可以总结为下面几点。
1. 先统一内部语义,再适配外部协议
不是直接围绕 OpenAI、Anthropic、Google 的原生格式做业务,而是先抽出:
- 统一模型
- 统一消息
- 统一工具
- 统一事件流
- 统一 stop reason
- 统一 usage
这样外部协议才能成为“边缘适配层”。
2. provider 和 api 解耦
这让系统同时获得:
- provider 维度的业务组织能力
- api 维度的协议复用能力
这是整个架构最核心的抽象之一。
3. compat 用数据描述小差异
这让 OpenAI-compatible 这类复杂生态不会把主逻辑污染成大量 provider 分支。
4. simple interface 和 provider-specific interface 并存
这避免了两个常见极端:
- 只做统一接口,导致丢失高级能力
- 只暴露 provider 原生接口,导致上层过于复杂
5. handoff 设计优先保证可继续,而不是保留所有底层细节
这是一种非常现实而成熟的设计选择。
6. 配置优先,代码兜底
能用模型元数据和 compat 表达的差异,尽量不写死进代码。
7. 上层永远只消费统一事件流和统一消息结构
这保证了 CLI、TUI、Agent、Web UI 不会因为 provider 增多而指数级复杂化。
如果你要把这套思路迁移到自己的项目,最值得抄的不是代码,而是方法
你真正应该学的,不是某个具体字段名,而是这套抽象顺序。
推荐照抄的顺序
第一步:定义统一会话模型
先定义:
- user / assistant / toolResult
- text / thinking / toolCall / image
- usage / stopReason
如果这一步做不好,后面所有 provider 适配都会变脏。
第二步:把 provider 和 api 分开
不要把“谁提供模型”和“走什么协议”写成一个概念。
第三步:为协议家族写适配器,而不是为每个 provider 重写一遍
例如:
- OpenAI-compatible 一套
- Anthropic-compatible 一套
- Google-compatible 一套
第四步:用 compat 吞掉半兼容差异
不要让小差异扩散成业务层的条件分支。
第五步:统一事件流
如果上层仍然要知道每家 provider 的 chunk 长什么样,那你的统一还没有真正完成。
第六步:把跨 provider handoff 当成一等公民设计
只有当系统能安全地 replay 跨 provider 会话,你的内部抽象才算真正独立于 provider。
最后给一个最简化的理解模型
如果你想把整套架构压缩成最短的几句话,可以这样记:
- Model 负责声明“这个模型是谁、支持什么、走哪个 API”
- Context 负责声明“会话内部统一长什么样”
- API Adapter 负责把统一上下文翻译成某家协议,再把响应翻译回来
- Compat 负责修补“兼容但不完全兼容”的小差异
- Event Stream 负责把所有 provider 的流式输出变成同一种消费方式
所以这个项目不是在做“多 provider hardcode”,而是在做:
一套稳定的内部语言,加若干个对外翻译器。
这就是它能同时支持很多 provider、又能保持配置简单和会话连续性的根本原因。
附:用一句话区分几个最容易混淆的概念
provider
模型属于谁,从业务上如何归类,默认如何鉴权和展示。
api
这个模型实际使用哪种协议适配器,决定请求和响应的编码方式。
model metadata
某个具体模型的能力声明和默认配置。
compat
对某种 API 家族中的小型不兼容差异进行声明式修补。
normalization
把 provider 特有结构转换成项目内部统一结构,或者把统一结构重新编码成目标 provider 可接受结构。
handoff
把原来在一个 provider 中产生的会话,安全迁移到另一个 provider 继续使用。
结语
如果你只记住一件事,请记住这句:
这个项目真正统一的不是“模型列表”,而是“会话语义、调用入口、事件流和配置模型”。
也正因为它统一的是这些更本质的层次,所以 provider 越多,系统越能体现这套架构的价值,而不是越多越乱。