开源编程 Agent pi 的服务商层:模型清单从哪来,怎么加一家
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
在 pi 里,Models 这个集合完全不认识任何一家模型服务商——它只做四件事:按 id 存 provider、按 model.provider 找到对应的 provider、把鉴权结果并进请求参数、把流转发出去。 所有跟服务商相关的知识(域名、鉴权方式、模型清单、走哪套协议)都被推到具体的 provider 对象里。看懂这条分界,剩下的问题——清单从哪来、加一家要改哪几处——都会变成机械劳动。
站内已有两篇讲通用方法论的文章:模型路由策略讲的是多模型之间怎么分派任务,模型别名风险讲的是别名与版本漂移怎么坑人。本篇不重复那些方法论,而是把一个能当场打开对照的开源仓库拆开,看这些抽象概念在真实工程里落成了哪些文件、哪些接口、哪些约定。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。
一、这一层要解决的问题:把”谁能提供模型”和”谁来调度”切开
一个编程 Agent 要接的服务商,形态差得非常远。有的用 API key,有的走 OAuth 订阅登录;有的模型清单是固定的,有的必须联网拉;有的一家之内还混着好几套协议。如果把这些差异都塞进一个调度器,调度器就会变成一堆 if 分支,每加一家就长一截。
pi 的做法是先立一个接口。packages/ai/src/models.ts 里的 Provider 是运行期的具体单位,它自己拥有 id、name、baseUrl、headers、auth、模型列举和流式请求行为。注释里那句话说得很直白:a provider is the concrete runtime unit。而 Models 只是”provider 的集合 + 鉴权应用 + 流转发”。
分工落到代码上是这样的:Models.getModel(provider, id) 就是在自己那张 Map 里找到 provider,再在它的清单里按 id 匹配;Models.stream() 找到 provider、解析鉴权、合并请求头、把改写过 baseUrl 的 model 对象交给 provider.stream()。集合本身不按 model.api 分派——分派是 provider 内部的事。
这个切法带来一个直接好处:Models 的实现是有限的、可穷尽的,而服务商是可增长的。你往集合里 setProvider() 一个自己写的 provider,它和内置的三十余家享受完全一样的待遇。
二、两个接口各自的职责边界
Provider 侧的关键约定,都写在接口注释里,值得逐条记住:
auth是必填的,apiKey与oauth至少给一个。注释明确说,即使是只认环境变量的服务商、甚至不需要 key 的本地服务器,也要提供apiKey这条路径,让它的resolve()去回答”配没配好”这个问题。getModels()必须同步、必须不抛。静态服务商返回自己的目录,动态服务商返回上一次refreshModels()之后的清单(第一次刷新之前是空的)。refreshModels?()只有动态服务商实现,失败时必须保留上一次的清单,并且要响应传进来的中断信号。filterModels?()是可选策略,用来表达”这份凭据下只有部分模型可用”。
Models 侧则是 getProviders / getProvider / getModels / getModel / refresh / checkAuth / getAvailable / getAuth / login / logout,加上 stream / complete / streamSimple / completeSimple。可变版本 MutableModels 多了 setProvider / deleteProvider / clearProviders,setProvider 按 provider.id 覆盖写入。
有几个实现细节比接口签名更能说明设计取向:
同步读取是”尽力而为”的。getModels() 遍历所有 provider,任何一家抛异常都被 catch 掉当作没有模型,注释写着 ill-behaved providers yield no models。一家写坏的 provider 不会让整个模型列表挂掉。
refresh() 并发跑所有实现了 refreshModels 的 provider,错误收进返回值里的 errors,而不是让 Promise reject。更细的一点:某家刷新失败之后,它还会再调一次 refreshModels,这次传 allowNetwork: false,目的是把本地缓存恢复回来,同时保留原始错误。
鉴权是在 applyAuth 里合并的。显式传进来的请求参数按字段覆盖鉴权解析的结果;请求头合并时做大小写不敏感的去重,同名但大小写不同的旧键会先被删掉再写入新值,避免同一个头出现两份;合并完的结果最后交给 transformHeaders 改写一遍,这个钩子只属于 Models 层,provider 看到的是改写之后的头。auth.baseUrl 存在时会生成一个 { ...model, baseUrl } 的副本交给 provider,而不是改原来那个 model 对象。这套顺序决定了”我在调用点临时塞一个 key”一定能盖住存储里的凭据。
流是懒的。stream() 同步返回一个 AssistantMessageEventStream,鉴权解析、模块动态加载这些异步准备都发生在流内部,失败以流上的错误事件呈现,不会在返回之后突然抛出去。写调用方的时候,这意味着你不需要给 stream() 包 try/catch,但必须处理流上的错误事件。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
Provider 接口 | 一家服务商的全部知识:id、baseUrl、鉴权、模型清单、流行为 | packages/ai/src/models.ts | 自己接一家服务商,或读懂某家的特殊处理 |
Models / MutableModels | provider 集合、鉴权应用、请求转发,不含任何服务商知识 | packages/ai/src/models.ts | 组装运行期集合、排查”provider 没配置”类报错 |
createProvider() | 从零件拼出 provider:静态清单 + 可选动态拉取 + 单协议或按 model.api 分派 | packages/ai/src/models.ts | 新增一家服务商的主入口 |
| 内置工厂 | 每家一个文件,声明 baseUrl、鉴权方式、目录、协议实现 | packages/ai/src/providers/<id>.ts | 照抄一个最像的做参考 |
| 生成的模型目录 | 各家模型的元数据快照,文件头注明自动生成 | packages/ai/src/providers/<id>.models.ts | 想改模型元数据时(改错地方会被覆盖) |
| 目录摊平工具 | 把按协议分组的 JSON 摊成 id 到 model 的映射,并保住类型 | packages/ai/src/model-catalog.ts | 看懂生成文件里那两行在干什么 |
| 内置聚合入口 | builtinProviders() / builtinModels() / getBuiltin* | packages/ai/src/providers/all.ts | 新增服务商时必须加一行的地方 |
| 动态清单存储 | 按 provider id 持久化清单,带 checkedAt / etag / lastModified | packages/ai/src/models-store.ts | 排查离线启动时清单从哪来 |
| 目标设计文档 | 这套重构的设计意图与阶段清单 | packages/agent/docs/models.md | 想知道”为什么这么设计”,而不是”现在怎么跑” |
三、模型清单从哪来:生成的快照、运行期刷新、用户覆盖
清单其实有三个来源,叠在一起。
第一层是生成的静态目录。 每家一个 providers/<id>.models.ts,文件开头两行注释写得很清楚:自动生成,不要手改,要更新就跑生成命令。以 Anthropic 那份为例,整个文件的实质内容只有这几行:
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const ANTHROPIC_MODELS: ModelCatalog<typeof values, "anthropic"> =
flattenModelCatalog("anthropic", values);
flattenModelCatalog 在 model-catalog.ts 里只有一行实现:把按协议分组的对象合并成一个平铺对象。它存在的价值几乎全在类型上——通过 ModelGroups / ModelId / ModelApi 这几个映射类型,从 JSON 的结构里把”某个模型 id 对应哪套 api”这件事推导出来,于是 getBuiltinModel(provider, modelId) 才能返回精确到具体协议字面量的 Model<...>。
数据本身来自生成脚本。packages/ai/scripts/generate-models.ts 会去拉 models.dev 的 api.json 作为主数据源,另外还单独请求了 OpenRouter 的模型列表接口、Vercel AI Gateway 的模型接口、NVIDIA NIM 的模型接口,再叠上脚本里手写的修正(比如那组 GitHub Copilot 的 thinking 级别覆盖,注释里注明是人工对照鉴权后的模型接口逐条核过的,并且刻意说明只做窄修正、不整份快照 Copilot 的目录)。生成产物包括各家的 JSON 数据、<id>.models.ts、聚合文件 models.generated.ts,以及一份 .manifest.json——providers/all.ts 直接 import 了这份 manifest,用 getBuiltinModelDataGeneratedAt() 把生成时间暴露出来。
值得留意的是,我在仓库树里看不到 src/providers/data/ 这个目录,package.json 里有专门的 hydrate-model-data 命令,构建脚本 build:offline 也会先跑 check:model-data 再把 data 目录拷进 dist。也就是说这份数据是构建期物料,不是源码。
第二层是运行期动态清单。 createProvider() 接受一个 fetchModels,内部把它包装成 refreshModels:先读 ProviderModelsStore 里存的清单恢复上来,再在允许联网时去拉新的,拉到之后写回存储;同一时刻的并发调用共享同一个在途 Promise。合并规则是”静态基线 + 动态覆盖”:动态清单里 id 撞上基线的替换掉,没撞上的追加。
Radius 那家没有走 createProvider,而是手写了整个 provider 对象,因为它还要兼容早期实现缓存下来的旧目录。它也是 all.ts 注释里点名的例子:BuiltinProvider 是生成目录里有的那些,而纯动态的服务商在生成目录里没有条目。
存储条目的形状在 models-store.ts 里:models 加上 lastModified、checkedAt、etag,注释说明 etag 是原样保存并回传的。命令行侧的落点,文档里写的是缓存到 ~/.pi/agent/models-store.json 供离线使用。
第三层是用户侧覆盖,不需要碰仓库,下一节一起说。
四、加一家服务商要动哪几处
分三条完全不同的路,先看不用改仓库的那两条。
路线一:用户配置。 ~/.pi/agent/models.json 里按 provider 写 baseUrl、api、apiKey、models,最小写法每个模型只需要 id。文档给的 Ollama 例子里 apiKey 填的是占位串,并特意解释:pi 认为模型要先有鉴权才会出现在 /model 里,所以不需要 key 的本地服务器也得留个占位值、或者用 /login 存一个、或者用 --api-key 传。key 的取值支持 !command 执行命令、$ENV_VAR 与 ${ENV_VAR} 插值,$$ 和 $! 是转义。
路线二:写扩展。 扩展可以直接 pi.registerProvider(createProvider({...})) 注册一个完整的 provider,也可以用旧的配置式写法只覆盖某家的 baseUrl 或请求头;pi.unregisterProvider(name) 撤销。需要自定义 OAuth 流程或者非标准协议时,这是官方指的路。
路线三:往仓库里加内置服务商,改动点是这些:
packages/ai/src/types.ts的KnownProvider联合类型里加上这家的 id。- 让生成脚本认识这家,跑生成命令产出
providers/<id>.models.ts与对应数据、更新models.generated.ts聚合。 - 新建
packages/ai/src/providers/<id>.ts工厂函数,内容通常就是一个createProvider调用。Anthropic 那份是最标准的样子:
export function anthropicProvider(): Provider<"anthropic-messages"> {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com",
auth: {
apiKey: anthropicApiKeyAuth(),
oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }),
},
models: Object.values(ANTHROPIC_MODELS),
api: anthropicMessagesApi(),
});
}
- 鉴权:普通情况直接用
packages/ai/src/auth/helpers.ts里的envApiKeyAuth(name, envVars),它的语义是”存过的凭据优先,否则按顺序试环境变量”。有非标准解析需求(额外的账号 id、凭据文件、区域配置)就自己写一个ApiKeyAuth,像 Anthropic 那样在resolve里分支。支持登录态的话,用lazyOAuth挂上去,实现放在auth/oauth/下按需加载。 - 协议:能复用就复用
api/*.lazy.ts里现成的实现,OpenAI Chat Completions 那套被一大票服务商共用。一家之内混协议的,api传一个按model.api索引的映射,GitHub Copilot 就同时挂了三套。真要接全新协议,才需要在src/api/下新增一个导出stream与streamSimple的模块,再配一个 lazy 包装。 packages/ai/src/providers/all.ts:import 一行,builtinProviders()的数组里加一行。这一步漏了,前面全部白做。
顺带一提,filterModels 是给”同一家、不同账号能用的模型不一样”准备的。GitHub Copilot 的实现是:凭据是 OAuth 时,读凭据上带的可用模型 id 列表,校验它确实是字符串数组,再据此过滤;拿不到就原样返回。这个策略只在 getAvailable() 里生效,getModels() 仍然返回完整目录。
五、边界与代价:这套设计明确不管的事
不做路由。 Models 里没有任何”哪个模型更合适”的判断,它按 model.provider 找 provider,找不到就是 ModelsError,代码 provider。选模型、降级、多方案对比全是上层的事,可以参考多模型兜底设计那类做法。
同步读一定会过期。 getModels() 返回的是”最后一次已知”的清单。设计文档里专门辩护过这个取舍:同步或异步的联合类型会养出一堆潜伏的同步假设,而全异步又会逼所有消费方(界面列表、扩展的查找接口)为几乎不变的数据走 Promise。所以拆成两个动词,把过期这件事摆到明面上。
生成目录是快照。 里面的元数据是生成那一刻的上游数据,各家规则不同且会调整,以官方最新说明为准。
存过的凭据会挡住环境变量回落。 这是刻意的:文档里写明,鉴权解析失败要响亮地报错,因为静默换一条鉴权路径可能带来计费上的意外。刷新失败时原凭据保留、供重试,状态界面负责显示成”需要重新登录”,而不是”没配置”。
动态刷新只允许无副作用的发现。 文档给了明确的可以与不可以清单:拉一次模型列表、枚举本地目录、刷新缓存是可以的;加载模型、下载模型、改服务端状态、发一个探测请求是不可以的。模型的加载卸载属于管理命令,不属于 refreshModels()。
packages/agent/docs/models.md 不是现状描述。 它开头第一段就声明自己描述的是目标设计而非当前实现,末尾的阶段清单里还有没打勾的条目。把它当设计意图读没问题,当 API 手册读会翻车——下一节第一条就是这个坑。
海外服务商的可达性不在项目管辖范围内。 Anthropic、OpenAI、Google 等官方对中国大陆存在区域限制、不支持直连,这是各家自己的政策;市面上存在第三方中转,本文不做背书也不给具体渠道,用之前请自行确认合规与数据流向。密钥托管给第三方的风险,可以对照API 密钥安全管理那套办法评估。
六、上手与避坑清单
照着设计文档写代码。 为什么会踩:那份文档写得比源码还清楚,看完很容易直接抄签名。但它自称目标设计,而且确实和实现对不上——文档里 createProvider 的入参叫 refreshModels,源码里叫 fetchModels;文档里 refresh(provider?) 返回 Promise<void>,源码里 refresh(options?) 返回带 aborted 和 errors 的结果对象。怎么避:文档用来理解意图,签名一律以 packages/ai/src/models.ts 为准。
新建了 provider 文件却不生效。 为什么会踩:文件建好、工厂函数导出了,看起来该有的都有了,但 builtinProviders() 是一个手写数组,不是目录扫描。怎么避:把”改 providers/all.ts”当成新增服务商的最后一道必做步骤,检查 import 和数组两处都加了。
手改 <id>.models.ts 调模型元数据。 为什么会踩:改这里最快见效,本地跑也确实生效。但文件头写着自动生成,下一次跑生成命令就被覆盖,而且构建流程里还有一步数据校验。怎么避:元数据修正写进生成脚本(脚本里已有大量这类手工修正,带日期注释),或者用 models.json 的覆盖能力解决个人需求。
从包根入口 import 服务商。 为什么会踩:习惯性 import { openaiProvider } from "包名",类型报错之后随手换成 providers/all。但根入口是刻意保持精简的,而 providers/all 是文档里点名的”显式重量级入口”,会把所有服务商的元数据都拉进来。怎么避:只用哪家就走哪家的子路径导出,providers/* 是通配的。
存过 key 之后改环境变量不生效。 为什么会踩:以为环境变量优先级更高,改完发现请求还在用旧的。实际规则是存储里的凭据独占该服务商,环境变量只在什么都没存时才被查。怎么避:改的时候走 /logout 或直接改凭据文件;临时验证用命令行的 key 覆盖参数,它优先级最高且不落盘。
混协议服务商写错 model.api。 为什么会踩:注册时不校验,问题要到真正发请求那一刻才暴露。源码里这种情况会产出一个流错误,消息形如 Provider <id> has no API implementation for "<api>"。怎么避:混协议的 provider 加完模型立刻跑一次真实请求,别只看列表出没出来。
把区域变体和本体搞混。 为什么会踩:内置服务商里有大量成对出现的 id,主站与中国大陆站、不同区域的订阅套餐往往是各自独立的 provider,鉴权和端点都不一样。怎么避:以 id 为准而不是以品牌名为准,配置前先确认自己那份凭据属于哪个 id,这类漂移的普遍规律见模型别名风险。
收束:按这个顺序读源码
如果你要吃透这一层,读文件的顺序建议是:先 packages/ai/src/models.ts,把 Provider、Models、createProvider 三段看完,这是全部约定的出处;再挑 providers/anthropic.ts 和 providers/github-copilot.ts 各读一遍,前者是最标准的单协议加 OAuth,后者展示了混协议与凭据级过滤;再看 providers/radius.ts,它是唯一手写的动态 provider;最后回到 providers/all.ts 确认这些东西是怎么被串起来的。packages/agent/docs/models.md 放在最后读,当作设计说明。
自检三问:你能说清 getModels() 和 refresh() 分别在什么时候被谁调用吗?你能指出某个模型的元数据是从哪个文件、由哪个脚本、依据哪份上游数据生成的吗?你能不看提示写出新增一家服务商需要改的文件清单吗?三个都能,这一层就算过了。至于把接进来的多家服务商编排到日常工作流里,那是另一个话题,可以接着看模型路由策略。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的统一模型接口 和 逐段读开源编程 Agent pi 的主循环。