6.2 Provider 与模型注册
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:Pi 内置了 38 个 Provider(模型服务提供方)、上千个模型条目,它们是怎么「注册」进程序里的?
--model anthropic/claude-opus-4-5:high这一串字符,又是怎么变成一个能真正发请求的对象的? 前置知识:4.3 monorepo 与 package 地图、4.4 从哪里开始读源码、6.1 pi-ai:统一的模型接口。 学习目标:读完后你能 ① 说出一个 Provider 由哪三部分组成,并读懂任意一个providers/*.ts文件;② 讲清providers/all.ts的注册机制以及它为什么是显式数组而不是「自动扫描」;③ 说明models.generated.ts的数据从哪来、为什么 clone 仓库后必须先跑hydrate:model-data;④ 解释.lazy.ts懒加载解决的是什么工程问题;⑤ 手工推演--model的匹配过程,包括provider/id与:thinking后缀。
建立直觉:Provider 是三件事的打包
零基础读者很容易把「Provider」理解成「一个厂商」。在 Pi 里它是一个更具体的运行时单元。可以把它想成一家餐厅:
- 后厨的做菜流程——对应 api 实现,也就是按哪套网络协议(wire protocol)跟服务器说话。Anthropic 用
anthropic-messages,OpenAI 用openai-responses,绝大多数国内外中小厂商都兼容openai-completions。 - 菜单——对应 模型清单:这个 Provider 有哪些模型、每个模型多大上下文窗口、多少钱、支不支持思考(reasoning)。
- 进门方式——对应 认证:拿 API Key 进,还是走 OAuth 登录订阅账号。
三者是可以自由组合的:openai-completions 这一套「做菜流程」被十几家 Provider 共用;反过来,一个 Provider 也可能同时提供好几套协议(Cloudflare AI Gateway 的模型分属 anthropic-messages、openai-completions、openai-responses 三种)。正因为可组合,Pi 才能用几十行代码接入一家新厂商。
这三件事在源码里就是 Provider interface 的三组字段(源码事实):
Provider注意 auth 是必填的(models.ts:89)。源码注释解释了原因:即使是只靠环境变量、AWS profile 甚至完全不需要密钥的本地服务,也要提供一个 apiKey 认证对象,因为 resolve() 的返回值同时承担了「这个 Provider 有没有配好」这个判断——Models.getAuth() 在未配置时返回 undefined。这是一个值得记住的设计:认证不是可选装饰,而是可用性的唯一信号源。
最小示例:一个 15 行的 Provider
packages/ai/src/providers/ 下最短的 Provider 定义文件之一是 Cerebras,全文 15 行(源码事实):
// packages/ai/src/providers/cerebras.ts(全文)
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { envApiKeyAuth } from "../auth/helpers.ts";
import { createProvider, type Provider } from "../models.ts";
import { CEREBRAS_MODELS } from "./cerebras.models.ts";
export function cerebrasProvider(): Provider<"openai-completions"> {
return createProvider({
id: "cerebras",
name: "Cerebras",
baseUrl: "https://api.cerebras.ai/v1",
auth: { apiKey: envApiKeyAuth("Cerebras API key", ["CEREBRAS_API_KEY"]) },
models: Object.values(CEREBRAS_MODELS),
api: openAICompletionsApi(),
});
}cerebrasProvider把这 15 行和上面的三件套对照,就能看出整个体系的骨架:
api: openAICompletionsApi()—— 「做菜流程」,来自api/openai-completions.lazy.ts,是所有 OpenAI 兼容厂商共用的同一个模块。auth: { apiKey: envApiKeyAuth(...) }—— 「进门方式」,通用 helper,只需告诉它认哪个环境变量。models: Object.values(CEREBRAS_MODELS)—— 「菜单」,来自机器生成的cerebras.models.ts(下一节讲它从哪来)。
复杂一点的 Provider 也只是在这三格里填更多东西。Anthropic 多了一个 OAuth 入口和一段自定义的 apiKey 解析(packages/ai/src/providers/anthropic.ts:9-36,按 stored credential → ANTHROPIC_AUTH_TOKEN → ANTHROPIC_OAUTH_TOKEN → ANTHROPIC_API_KEY 的顺序找),但主体仍然是一次 createProvider() 调用(anthropic.ts:38-50)。
createProvider() 本身(packages/ai/src/models.ts:556-623)做三件事:把静态 models 和动态刷新得到的 dynamicModels 合并成 getModels()(models.ts:561-569);把 api 字段规范化——它既可以是单个实现,也可以是一张按 model.api 分派的表(models.ts:547、574-587);如果配了 fetchModels,就自动接上 ModelsStore 的读取/回写逻辑(models.ts:596-617)。
createProvider 只是一个便利工厂。`Provider` 是普通 interface,手写一个对象字面量同样合法——内置的 Radius 就是这么做的(packages/ai/src/providers/radius.ts:20-67,返回的对象字面量在第 28–66 行),因为它的模型目录完全来自网关动态下发,需要自定义 refreshModels 里的历史缓存迁移逻辑。第七部分 7.4 自定义 Provider 会用到这一点。 注册:providers/all.ts 里没有魔法
很多框架的「插件注册」靠副作用:import 一个文件,它在模块顶层偷偷往全局表里塞东西。Pi 不这样做。providers/all.ts 里的注册就是一个显式数组加一个 for 循环(源码事实):
// packages/ai/src/providers/all.ts:87-96(节选,中间 30 行同构省略)
export function builtinProviders(): Provider[] {
return [
amazonBedrockProvider(),
antLingProvider(),
anthropicProvider(),
// …(省略:azure-openai-responses 到 zai-coding-cn 共 35 个同样形式的工厂调用)
zaiCodingCnProvider(),
];
}builtinProvidersbuiltinModels从源码结构看,选择显式数组而不是目录扫描,至少换来三点:打包工具能静态分析(只 import providers/anthropic.ts 的用户不会被拖进另外 37 个)、注册顺序确定(后面讲的「默认模型兜底」依赖顺序)、类型可推导(BuiltinProvider 直接从 MODELS 的键推出来,all.ts:51)。一种看法是这牺牲了一点「零样板」,代价是每加一家厂商要改三处;但对一个需要 tree-shaking 的库来说,这笔交易通常划算。
注册完成后,查找走的是 Models 集合:getModel(provider, id) 同步查最近一次已知的模型列表,stream() 则先解析认证再委托给拥有该模型的 Provider。
图 6.2-1 Provider 的注册与查找两条路径
上半部分只在启动时跑一次,下半部分每次请求都跑。请重点关注两个「分派点」:setProvider 按 id 去重,dispatch 按 model.api 选实现。
这张图的每个节点都能在源码里定位。特别注意 applyAuth(models.ts:463-487)的位置:认证是在 Models 层注入的,不在 api 实现里。api 模块只负责协议翻译,拿到的已经是带好 apiKey/headers/baseUrl 的请求选项。这解释了为什么同一个 openai-completions 实现能服务十几家 baseUrl 完全不同的厂商。
38 个内置 Provider 的清单
下表由本书作者在锁定版本上真实运行脚本导出(做法见本章实践任务),不是手抄。「静态模型数」依赖本地 providers/data/ 快照(本仓库快照生成于 2026-07-30,见 packages/ai/src/providers/data/.manifest.json 的 generatedAt);你重新 hydrate 之后数字会变。
| # | Provider id | 名称 | 主要 wire protocol | 认证 | 静态模型数 |
|---|---|---|---|---|---|
| 1 | amazon-bedrock | Amazon Bedrock | bedrock-converse-stream | apiKey | 114 |
| 2 | ant-ling | Ant Ling | openai-completions | apiKey | 3 |
| 3 | anthropic | Anthropic | anthropic-messages | apiKey + oauth | 15 |
| 4 | azure-openai-responses | Azure OpenAI | azure-openai-responses | apiKey | 38 |
| 5 | cerebras | Cerebras | openai-completions | apiKey | 3 |
| 6 | cloudflare-ai-gateway | Cloudflare AI Gateway | 三种混合 | apiKey | 43 |
| 7 | cloudflare-workers-ai | Cloudflare Workers AI | openai-completions | apiKey | 13 |
| 8 | deepseek | DeepSeek | openai-completions | apiKey | 2 |
| 9 | fireworks | Fireworks | 两种混合 | apiKey | 16 |
| 10 | github-copilot | GitHub Copilot | 三种混合 | apiKey + oauth | 29 |
| 11 | google | google-generative-ai | apiKey | 24 | |
| 12 | google-vertex | Google Vertex AI | google-vertex | apiKey | 12 |
| 13 | groq | Groq | openai-completions | apiKey | 7 |
| 14 | huggingface | Hugging Face | openai-completions | apiKey | 51 |
| 15 | kimi-coding | Kimi For Coding | anthropic-messages | apiKey + oauth | 4 |
| 16 | minimax | MiniMax | anthropic-messages | apiKey | 3 |
| 17 | minimax-cn | MiniMax CN | anthropic-messages | apiKey | 3 |
| 18 | mistral | Mistral | mistral-conversations | apiKey | 30 |
| 19 | moonshotai | Moonshot AI | openai-completions | apiKey | 10 |
| 20 | moonshotai-cn | Moonshot AI CN | openai-completions | apiKey | 10 |
| 21 | nvidia | NVIDIA | openai-completions | apiKey | 30 |
| 22 | openai | OpenAI | openai-responses | apiKey | 38 |
| 23 | openai-codex | OpenAI Codex | openai-codex-responses | 仅 oauth | 7 |
| 24 | opencode | OpenCode Zen | 四种混合 | apiKey | 59 |
| 25 | opencode-go | OpenCode Go | 三种混合 | apiKey | 16 |
| 26 | openrouter | OpenRouter | openai-completions | apiKey + oauth | 303 |
| 27 | qwen-token-plan | Qwen Token Plan | openai-completions | apiKey | 15 |
| 28 | qwen-token-plan-cn | Qwen Token Plan CN | openai-completions | apiKey | 15 |
| 29 | radius | Radius | pi-messages | apiKey + oauth | 0(纯动态) |
| 30 | together | Together | openai-completions | apiKey | 17 |
| 31 | vercel-ai-gateway | Vercel AI Gateway | anthropic-messages | apiKey | 193 |
| 32 | xai | xAI | 两种混合 | apiKey + oauth | 3 |
| 33 | xiaomi | Xiaomi | openai-completions | apiKey | 6 |
| 34 | xiaomi-token-plan-ams | Xiaomi Token Plan AMS | openai-completions | apiKey | 3 |
| 35 | xiaomi-token-plan-cn | Xiaomi Token Plan CN | openai-completions | apiKey | 3 |
| 36 | xiaomi-token-plan-sgp | Xiaomi Token Plan SGP | openai-completions | apiKey | 3 |
| 37 | zai | Z.AI | openai-completions | apiKey | 6 |
| 38 | zai-coding-cn | Z.AI Coding CN | openai-completions | apiKey | 6 |
三个观察值得展开:
- 协议高度收敛。38 家里有 20 家直接用
openai-completions。KnownApi一共只列了 10 种协议(packages/ai/src/types.ts:16-26),而KnownProvider列了 38 个(types.ts:34-72)——协议数和厂商数差一个数量级,这正是「统一模型接口」这个包成立的前提。 radius是唯一没有静态目录的内置 Provider。builtinProviders()返回 38 个,但生成的目录MODELS只有 37 个键。all.ts:48-51的注释直接说明了这一点:KnownProvider额外包含纯动态 Provider。这个 38 vs 37 的差值是本章实践任务里可以亲手量出来的。- 数量不等于可用。上表是「Pi 知道有这些模型」,不是「你现在能用」。没有任何凭据时,
pi --list-models的真实输出是(真实采集,见research/cli-captures/pi-list-models-claude.txt):
No models available. Use /login to log into a provider via OAuth or API key. See:后面两行是本机上 docs/providers.md 与 docs/models.md 的绝对路径。可用性由认证决定,这是本章最后一节的主题。
模型数据从哪来:generate-models 与 models.generated.ts
上千条模型元数据不可能手写维护。Pi 的做法是生成式目录:数据来自外部数据源,由脚本生成三层文件,其中一层刻意不进版本库。
先看最里面一层。每个 Provider 的目录分片只有 8 行,且明确写着「不要手改」(源码事实):
// packages/ai/src/providers/anthropic.models.ts(全文 8 行)
// 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);真正的数据在 ./data/anthropic.json 里,按 api 分组、按模型 id 索引;flattenModelCatalog()(packages/ai/src/model-catalog.ts:22-27)在运行时只做一次 Object.assign 把分组压平,其余全是类型体操,把 JSON 的字面量结构提升成精确的 TypeScript 类型。一条模型记录长这样(取自本仓库快照):
{ "id": "claude-opus-4-5", "name": "Claude Opus 4.5 (latest)",
"api": "anthropic-messages", "provider": "anthropic",
"baseUrl": "https://api.anthropic.com", "reasoning": true,
"input": ["text", "image"],
"cost": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25 },
"contextWindow": 200000, "maxTokens": 64000,
"compat": { "supportsStrictTools": true } }第二层是聚合文件 models.generated.ts:37 个 import,一个 MODELS 常量,没有任何逻辑。
MODELS第三层是生成器本身。它从 models.dev 这个公共模型数据库抓取原始数据:
loadModelsDevData写出的三类产物分别是:src/providers/data/<id>.json 数据快照与 .manifest.json 校验清单(generate-models.ts:2612-2621)、<id>.models.ts 分片(2649-2657,同时删除已不存在的旧分片)、src/models.generated.ts 聚合(2663-2676)。
对应三个 npm script(packages/ai/package.json:52-54),入口在仓库根(package.json:24-25):
| 命令 | 实际执行 | 用途 |
|---|---|---|
npm run generate:models | generate-models.ts --strict | 全量重生成:数据 + 分片 + 聚合 |
npm run hydrate:model-data | ... --strict --data-only | 只补 data/*.json,不动 TS 文件 |
npm run check:model-data | check-model-data.ts | 校验数据是否缺失/过期,build 前置 |
图 6.2-2 模型目录的生成流水线
实线是构建期的「写文件」方向,虚线是运行期的 import 方向。请重点关注左下角:只有 data 目录被 gitignore 排除,而 import 它的 .models.ts 分片是入库的。
这就是 4.1 实践任务 里那个报错的根源(源码事实)。.gitignore 第 11 行写着 packages/ai/src/providers/data/,也就是说数据快照不进版本库;但 anthropic.models.ts 这类分片是入库的,它们顶部就 import values from "./data/anthropic.json"。于是刚 clone 完直接跑源码,Node 会在第一个分片上就报:
Cannot find module '.../providers/data/amazon-bedrock.json'(这条错误是本书实际踩过并验证的。)解法就是 npm run hydrate:model-data 把 37 份 JSON 抓回来。仓库自己也内置了这个提示——构建前置脚本失败时会打印(packages/ai/scripts/check-model-data.ts:14):Model data is missing or stale. Run npm run hydrate:model-data from the repository root.
.lazy.ts:38 个 Provider 不该拖慢启动
38 个 Provider 分摊在 10 种协议上,而每种协议的 api 实现往往依赖一个厂商 SDK。如果 providers/all.ts 顺着 import 链把它们全拉进来,那么每次启动 Pi 都会连带加载 Anthropic SDK、AWS Bedrock SDK、Google GenAI SDK、Mistral SDK……而一次会话通常只用其中一个。这是典型的启动时间与包体积问题。
Pi 的解法是在 Provider 与 api 实现之间插一层薄包装。packages/ai/src/api/ 下有 11 个 *.lazy.ts 文件,每个的实现体都只有一行,例如 api/anthropic-messages.lazy.ts:4:
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));秘密全在 lazyApi 里:
// packages/ai/src/api/lazy.ts:68-75(全文)
export function lazyApi(load: () => Promise<ProviderStreams>): ProviderStreams {
return {
stream: (model, context, options) =>
lazyStream(model, async () => (await load()).stream(model, context, options)),
streamSimple: (model, context, options) =>
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
};
}lazyApi它依赖同文件的 lazyStream(api/lazy.ts:46-61):同步返回一个空的事件流对象,异步在背后做 setup(动态 import、认证解析),setup 抛错时不向调用方 throw,而是把错误 push 成流里的一个 error 事件再结束流(lazy.ts:54-58)。这个「错误进流、不抛异常」的约定贯穿整个 pi-ai,6.1 讲 StreamFunction 契约时会再遇到。
这不是纸面设计——仓库里有一个专门的探针测试守着它。packages/ai/test/lazy-module-load.test.ts 用子进程加载模块并记录实际被解析的 npm 包,断言「导入根入口时不加载任何 provider SDK」(第 66 行)、「构建全部内置 Provider 时也不加载」(第 71 行),而「通过懒包装真正发起一次流式请求时,只加载 @anthropic-ai/sdk 一个包」(第 87 行)。本章实践任务会让你亲自跑一遍这 5 个用例。
模型选择:--model 是怎么被解析的
到这里,Pi 已经知道「世界上有哪些模型」了。剩下的问题是:用户敲的那串字符对应哪一个?
CLI 的帮助文本对 --model 的描述是(真实采集,research/cli-captures/pi-help.txt 第 18 行):
--model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")解析入口在 coding-agent 侧,不在 pi-ai 里。完整调用链(每环都是源码事实):
main()(packages/coding-agent/src/main.ts:521)→ parseArgs(packages/coding-agent/src/cli/args.ts:64,--model 分支在第 90 行)→ createAgentSession(packages/coding-agent/src/core/sdk.ts:169)→ findInitialModel(packages/coding-agent/src/core/model-resolver.ts:572)→ resolveCliModel(model-resolver.ts:385)→ parseModelPattern(model-resolver.ts:195)→ tryMatchModel(model-resolver.ts:127)→ findExactModelReferenceMatch(model-resolver.ts:79)。
拆开看三个关键动作:
① provider/id 前缀推断。resolveCliModel 先建一张小写 provider 名到规范名的映射,然后看 --model 里第一个斜杠前面是不是已知 provider(model-resolver.ts:431-442)。是的话就把它当 provider,剩下当 pattern。源码注释特意举了例子:zai/glm-5 应当解析成 provider=zai、model=glm-5,而不是去匹配某个 id 里字面含斜杠的 Vercel 网关模型。如果前缀不是已知 provider(比如 OpenRouter 那种 vendor/model 形式的 id),代码会退回去做整串精确匹配(model-resolver.ts:446-454)。
② :thinking 后缀与冒号歧义。麻烦在于有些模型 id 本身带冒号(OpenRouter 的 :exacto)。parseModelPattern 用的是「先整串、失败再剥最后一个冒号」的递归策略:
// packages/coding-agent/src/core/model-resolver.ts:200-218(节选,省略无冒号与非法后缀分支)
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: undefined, warning: undefined };
}
const lastColonIndex = pattern.lastIndexOf(":");
// …(省略:无冒号则直接返回未匹配,model-resolver.ts:208-211)
const prefix = pattern.substring(0, lastColonIndex);
const suffix = pattern.substring(lastColonIndex + 1);
if (isValidThinkingLevel(suffix)) {
// Valid thinking level - recurse on prefix and use this level
const result = parseModelPattern(prefix, availableModels, options);
// …(省略:把 thinkingLevel 挂到递归结果上,model-resolver.ts:219-227)parseModelPattern注意第 230-235 行的 allowInvalidThinkingLevelFallback:CLI 的 --model 走严格模式(resolveCliModel 在 model-resolver.ts:465-467 传 false),非法后缀直接判定为不匹配,而不是悄悄剥掉冒号解析成另一个模型——这避免了「你以为选了 A,实际跑的是 B」。合法档位来自 ThinkingLevel:minimal | low | medium | high | xhigh | max(packages/ai/src/types.ts:79)。
③ 模糊匹配与版本偏好。精确匹配失败后,tryMatchModel(model-resolver.ts:134-156)做子串包含匹配,然后在候选里优先选 alias 而不是带日期的具体版本:isAlias()(model-resolver.ts:65-72)把不以 -YYYYMMDD 结尾的 id 视为 alias。所以 --model sonnet 更可能落到 claude-sonnet-4-6 而不是 claude-sonnet-4-5-20250929;若候选全是带日期版本,则按 id 倒序取最新那个。
图 6.2-3 model 参数从字符串到 Model 对象的解析决策
这是一张决策图,不是时序图。请重点关注两个回环:斜杠前缀失败会退回整串匹配,冒号后缀合法则剥掉后缀递归重来。最右下角的 error 由调用方 findInitialModel 变成 process.exit(1)。
图里的 L 节点值得单独说一句:当 provider 已经确定、但模型 id 在目录里查无此物时,buildFallbackModel(model-resolver.ts:166-180)不会直接失败,而是拿该 provider 的默认模型做模板、把 id 和 name 换成你输入的字符串,并附一条警告。这就是自建服务、私有部署模型能被 --provider xxx --model 我的模型名 直接使用的原因。
如果用户根本没给 --model 呢?findInitialModel(model-resolver.ts:572-652)按五级优先级兜底:CLI 参数 → --models 指定的 scoped models 第一个 → settings 里保存的默认模型(且该 provider 已配好认证)→ 遍历 defaultModelPerProvider(model-resolver.ts:14)找第一个「已认证的 provider 的默认模型」→ 仍然没有就返回 undefined(对应上面 No models available 的输出)。
pi --help 里写着 --provider <name> Provider name (default: google)(真实采集,pi-help.txt 第 17 行)。但源码里 findInitialModel 没有任何硬编码的「默认 google」逻辑,走的是上面这五级兜底。这条帮助文本疑为陈旧(分析解释:未找到与之对应的实现)。读文档与读源码冲突时,以源码为准。 凭据:环境变量与 OAuth
最后一块拼图是「怎么证明你有权用这个模型」。
环境变量路径。绝大多数 Provider 用同一个 helper:
envApiKeyAuth具体哪个 Provider 认哪个变量,集中在 packages/ai/src/env-api-keys.ts 的映射表里(env-api-keys.ts:79-114,函数 getApiKeyEnvVars 起于第 68 行),例如 openai: "OPENAI_API_KEY"、google: "GEMINI_API_KEY"、huggingface: "HF_TOKEN"。两个特例值得注意:anthropic 认三个变量(env-api-keys.ts:75-77),其中 ANTHROPIC_AUTH_TOKEN 要以 Authorization: Bearer 头发送而非当作 apiKey,所以 getEnvApiKey() 会跳过它(env-api-keys.ts:146-149);amazon-bedrock 与 google-vertex 支持「环境凭据」——AWS profile、IAM key、ECS 任务角色,或 Google 的 ADC 文件——此时函数返回哨兵值 "<authenticated>" 而不是真密钥(env-api-keys.ts:151-183)。
OAuth 路径。订阅制服务(Claude Pro/Max、ChatGPT Plus 的 Codex、GitHub Copilot 等)走 OAuth。Provider 定义里只声明一个懒包装:oauth: lazyOAuth({ name, load: loadAnthropicOAuth })(providers/anthropic.ts:45),实现直到第一次 login/refresh 才被动态加载(auth/helpers.ts:36-39),动机与 .lazy.ts 一样——Node 专属的 OAuth 流程代码不该进浏览器打包产物。官方文档说明(来源:packages/coding-agent/docs/providers.md:15-26):交互模式下用 /login 选择 Provider,token 存在 ~/.pi/agent/auth.json 并在过期时自动刷新;/logout 清除。同文档第 302-309 行给出凭据解析顺序:CLI --api-key → auth.json → 环境变量 → models.json 里的自定义 Provider 密钥。
各家 OAuth 的具体流程(PKCE、设备码、企业域名等)本章不展开,它们与 Agent 原理无关,属于 4.4 划定的支线。
Pi 侧的组装。pi-ai 提供 builtinModels(),但 coding-agent 并不直接用它,而是自己拿 builtinProviders() 再包一层:
static async createModelRuntime 的 rebuildProviders()(model-runtime.ts:226-231)会把四个来源——内置、extension 原生 Provider、models.json 配置、extension 配置覆盖——按 provider id 合并后逐个 setProvider。这就是为什么 setProvider 的「按 id upsert」语义重要:用户在 models.json 里写一个 id: "anthropic" 的条目,就能覆盖内置定义的 baseUrl 或模型清单。7.4 自定义 Provider 会完整讲这条路径。
实践任务
目标:不靠本书的表格,自己量出内置 Provider 数量、静态目录数量,解释两者的差值;再完整读懂一个 Provider 文件;最后跑通懒加载测试。全程不需要任何 API Key。
前提:已完成 4.1 的实践任务(依赖已装、npm run hydrate:model-data 已跑过)。以下命令都在 Pi 仓库根目录执行,且全部是只读操作。
步骤与命令:
- 数注册条目:
grep -c "Provider()," packages/ai/src/providers/all.ts - 数目录分片:
ls packages/ai/src/providers/*.models.ts | wc -l - 数数据快照:
ls packages/ai/src/providers/data/*.json | wc -l - 找出差值来自谁:
ls packages/ai/src/providers/ | grep -v "\.models\.ts" | grep "\.ts$"对照ls packages/ai/src/providers/*.models.ts,看哪个xxx.ts没有对应的xxx.models.ts - 读最短的 Provider:
cat packages/ai/src/providers/cerebras.ts(15 行),逐行标出「api / auth / models」三件套各在哪一行 - 跑懒加载测试:
npm test --workspace=@earendil-works/pi-ai -- lazy-module-load
预期现象:第 1 步输出 38;第 2、3 步各输出 37(不同 shell 可能带前导空格);第 4 步你会发现 radius.ts 与 faux.ts、openrouter-images.ts 等没有 .models.ts 兄弟文件,其中只有 radius 出现在 builtinProviders() 里;第 6 步 vitest 报告 Test Files 1 passed (1) / Tests 5 passed (5)(本书作者在锁定版本上实际运行通过,耗时约 1 秒)。
如何判断成功:你能用一句话回答「为什么注册了 38 个 Provider,却只有 37 份模型目录」,并能指出 all.ts 中说明这件事的那段注释在第几行(提示:48–51)。
常见错误:① 第 1 步用 grep -c "Provider"(不带括号逗号)会连 import 行一起数进去,得到远大于 38 的结果;② 第 3 步报「No such file or directory」说明 hydrate:model-data 没跑过,回到 4.1 补上;③ 第 6 步若提示找不到 workspace,确认你在仓库根目录而不是 packages/ai 里。
进阶(可选):把下面几行写进仓库之外的临时文件(例如 ~/list-providers.ts,注意不要写进 _sources/pi),再用仓库自带的 tsx 运行:./node_modules/.bin/tsx --tsconfig ./tsconfig.json ~/list-providers.ts。它会打印每个 Provider 的 id、名称、静态模型数与认证方式——本章那张 38 行表格就是这么导出的。
import { builtinProviders } from "<仓库绝对路径>/packages/ai/src/providers/all.ts";
for (const p of builtinProviders()) {
console.log([p.id, p.name, p.getModels().length, Object.keys(p.auth).join("+")].join(" | "));
}对应源码位置:packages/ai/src/providers/all.ts:87-137、packages/ai/src/providers/cerebras.ts:6-15、packages/ai/test/lazy-module-load.test.ts:65-119。
本章小结
- 一个 Provider = api 实现(wire protocol)+ 模型清单 + 认证三件套,接口定义在
models.ts:75-120;auth必填,因为它同时是「是否可用」的唯一信号。 - 注册是显式的:
builtinProviders()是一个 38 项的数组,builtinModels()逐个setProvider;没有目录扫描、没有副作用注册,换来 tree-shaking 与确定的顺序。 - 协议只有 10 种(
KnownApi),Provider 有 38 个(KnownProvider)——一对多的复用是这个包成立的前提;radius是唯一没有静态目录的内置 Provider(38 vs 37)。 - 模型元数据来自 models.dev,经
generate-models.ts落成三层文件;providers/data/被.gitignore排除,所以 clone 后必须先跑npm run hydrate:model-data,否则报Cannot find module '.../providers/data/amazon-bedrock.json'。 .lazy.ts+lazyApi/lazyStream让厂商 SDK 直到第一次真正发流才加载,lazy-module-load.test.ts是它的守门测试。--model的解析顺序:provider/id前缀推断 → 整串精确匹配 → 剥:thinking后缀递归 → 模糊匹配时 alias 优先于带日期版本;无参数时由findInitialModel五级兜底。- 凭据:
envApiKeyAuth按「已存凭据 → 环境变量」解析,映射表在env-api-keys.ts;OAuth 通过lazyOAuth声明、token 存~/.pi/agent/auth.json(官方文档说明,来源packages/coding-agent/docs/providers.md)。 - 关键术语:Provider(模型服务提供方)、wire protocol(网络协议)、模型目录(catalog)、生成式目录、懒加载(lazy loading)、alias(模型别名)、思考档位(ThinkingLevel)。
- 关键源码索引:
packages/ai/src/models.ts:75-120(Provider)、:556-623(createProvider)、:463-487(applyAuth)packages/ai/src/providers/all.ts:87-128(builtinProviders)、:131-137(builtinModels)packages/ai/src/providers/cerebras.ts:6-15(最小 Provider)、anthropic.ts:38-50、radius.ts:20-67(手写 Provider)packages/ai/src/models.generated.ts:42-118、packages/ai/scripts/generate-models.ts:1081-1086packages/ai/src/api/lazy.ts:46-75packages/coding-agent/src/core/model-resolver.ts:79/127/195/385/572packages/coding-agent/src/core/model-runtime.ts:135-174- 测试:
packages/ai/test/lazy-module-load.test.ts、packages/coding-agent/test/model-resolver.test.ts
- 自测问题:① 为什么
Provider.auth是必填字段,而不是「没有密钥就不写」?②--model openrouter/qwen/qwen3-max:high里有两个斜杠一个冒号,解析器会怎么切?(提示:先看第一个斜杠前缀是不是已知 provider,再从右剥冒号)③ 如果你要给 Pi 加一家新的 OpenAI 兼容厂商,最少要改哪几个文件?④ 为什么models.generated.ts不能手工编辑? - 下一章:6.3 pi-agent-core:Agent 与循环。
- 本章未展开的内容:各家 OAuth 的具体流程(PKCE、设备码、企业域名);
models.json自定义 Provider 的完整字段与覆盖规则(留给 7.4);动态目录的持久化ModelsStore与离线恢复;compat字段如何按 api 条件分派模型兼容性开关;图像模型 Provider(builtinImagesProviders)。