6.2 Provider 与模型注册
本页分析版本earendil-works/pi@16787ad2026-09-21本章解决什么问题:Pi 内置了 41 个 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:113)。源码注释解释了原因:即使是只靠环境变量、AWS profile 甚至完全不需要密钥的本地服务,也要提供一个 apiKey 认证对象,因为 resolve() 的返回值同时承担了「这个 Provider 有没有配好」这个判断——Models.getAuth() 在未配置时返回 undefined。这是一个值得记住的设计:认证不是可选装饰,而是可用性的唯一信号源。
还有一个细节:stream 与 streamSimple 收到的上下文类型是 TranscriptContext,不是调用方传进来的原始 Context(models.ts:138-149)。规范化由 Models 在分派前统一做,Provider 拿到的永远是已经整理过的转录(6.1 讲过这个带品牌的类型)。
最小示例:一个 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-41,按 stored credential → ANTHROPIC_AUTH_TOKEN → ANTHROPIC_OAUTH_TOKEN → ANTHROPIC_API_KEY 的顺序找;其中 ANTHROPIC_AUTH_TOKEN 解析成 Authorization: Bearer 请求头而不是 apiKey,见第 24-31 行),但主体仍然是一次 createProvider() 调用(anthropic.ts:43-59)。
createProvider() 本身(packages/ai/src/models.ts:784-884)做三件事:把静态 models 和动态刷新得到的 dynamicModels 按 id 合并成 getModels()(models.ts:788-796,同 id 以动态条目为准);把 api 字段规范化——它既可以是单个实现,也可以是一张按 model.api 分派的表(models.ts:797-801 判别,803-814 分派);如果配了 fetchModels,就生成一个 refreshModels(models.ts:823-848)。
createProvider这个生成出来的 refreshModels 不自己读写存储,而是只跟调用方递进来的上下文打交道:
RefreshModelsContext读 models.ts:823-848 可以看出它分两步:先把 context.stored 里属于本 Provider 的模型用 publish({ update }) 装回内存(只改内存,不写存储);然后,只有 allowNetwork 为真且没被中止时才调用 fetchModels,拿到结果后用 publish({ persist, update }) 同时写存储、换内存。publish 的返回值要检查:返回 false 表示这一轮已经过期,Provider 应当直接停手。
判断「过期」的逻辑在 Models 那边(源码事实):publishProviderModels(models.ts:350-377)把同一个 Provider 的发布串成一条链,写存储前后各检查一次代次(generation)和中止信号,只有都通过才执行 update。refresh()(models.ts:398-458)则对每个动态 Provider 跑两个阶段:先用 allowNetwork: false 做一次离线恢复(第 423 行),再解析凭据、用 allowNetwork: true 做一次联网刷新(第 427-429 行)。从源码结构看,Provider 的作者只需要写「怎么把一份目录装进内存、怎么从网络拿新目录」,并发刷新、过期结果丢弃、持久化这些事都由 Models 统一处理。
createProvider 只是一个便利工厂。Provider 是普通 interface,手写一个对象字面量同样合法——内置的 Radius 就是这么做的(packages/ai/src/providers/radius.ts:22-96,返回的对象字面量在第 33–95 行)。它有一份静态基线目录(只在使用默认网关时启用,第 26–29 行),网关下发的动态目录叠加在上面;它的 refreshModels 比 createProvider 生成的那个多一步:没有存储快照时,把旧版 Radius 实现缓存的目录迁移进来(第 64–79 行)。据此推断(尚未在源码中直接证实),这一步正是它选择手写的原因。第七部分 7.4 自定义 Provider 会用到这一点。 注册:providers/all.ts 里没有魔法
很多框架的「插件注册」靠副作用:import 一个文件,它在模块顶层偷偷往全局表里塞东西。Pi 不这样做。providers/all.ts 里的注册就是一个显式数组加一个 for 循环(源码事实):
// packages/ai/src/providers/all.ts:90-134(节选,中间同构的调用省略)
export function builtinProviders(): Provider[] {
return [
amazonBedrockProvider(),
antLingProvider(),
anthropicProvider(),
// …(省略:azure-openai-responses 到 zai 共 37 个同样形式的工厂调用)
zaiCodingCnProvider(),
];
}builtinProvidersbuiltinModels从源码结构看,选择显式数组而不是目录扫描,至少换来三点:打包工具能静态分析(只 import providers/anthropic.ts 的用户不会被拖进另外 40 个)、注册顺序确定(后面讲的「默认模型兜底」依赖顺序)、类型可推导(BuiltinProvider 直接从 MODELS 的键推出来,all.ts:54)。一种看法是这牺牲了一点「零样板」,代价是每加一家厂商要改三处;但对一个需要 tree-shaking 的库来说,这笔交易通常划算。
注册完成后,查找走的是 Models 集合:getModel(provider, id) 同步查最近一次已知的模型列表,stream() 则先解析认证再委托给拥有该模型的 Provider。
图 6.2-1 Provider 的注册与查找两条路径
上半部分只在启动时跑一次,下半部分每次请求都跑。请重点关注两个「分派点」:setProvider 按 id 去重,dispatch 按 model.api 选实现。
这张图的每个节点都能在源码里定位。特别注意 applyAuth(models.ts:648-677)的位置:认证是在 Models 层注入的,不在 api 实现里。api 模块只负责协议翻译,拿到的已经是带好 apiKey/headers/baseUrl 的请求选项。这解释了为什么同一个 openai-completions 实现能服务十几家 baseUrl 完全不同的厂商。
applyAuth 里还有一个只属于 Models 层的钩子:ModelsRequestTransforms(models.ts:80-83)目前只有一个字段 transformHeaders。合并顺序是「认证给的 headers ← 调用方显式传的 headers」,最后才跑 transformHeaders(models.ts:666-669);随后这个字段被剥掉,不会传给 Provider(models.ts:672)。coding-agent 就是用它在每次请求前统一加归属(attribution)请求头,并让 extension 的 before_provider_headers 事件有机会改写(packages/coding-agent/src/core/sdk.ts:325-335)。所以这个钩子看到的是已经组装完整的请求头,而不是某个半成品。
41 个内置 Provider 的清单
下表由本书作者在锁定版本上真实运行脚本导出(做法见本章实践任务),不是手抄。「静态模型数」依赖本地 providers/data/ 快照(本仓库快照生成于 2026-09-22,见 packages/ai/src/providers/data/.manifest.json 的 generatedAt);你重新 hydrate 之后数字会变。
| # | Provider id | 名称 | 主要 wire protocol | 认证 | 静态模型数 |
|---|---|---|---|---|---|
| 1 | amazon-bedrock | Amazon Bedrock | bedrock-converse-stream | apiKey | 165 |
| 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 | 39 |
| 5 | baseten | Baseten | openai-completions | apiKey | 21 |
| 6 | cerebras | Cerebras | openai-completions | apiKey | 2 |
| 7 | cloudflare-ai-gateway | Cloudflare AI Gateway | 三种混合 | apiKey | 51 |
| 8 | cloudflare-workers-ai | Cloudflare Workers AI | openai-completions | apiKey | 18 |
| 9 | deepseek | DeepSeek | openai-completions | apiKey | 2 |
| 10 | fireworks | Fireworks | 两种混合 | apiKey | 33 |
| 11 | github-copilot | GitHub Copilot | 三种混合 | apiKey + oauth | 29 |
| 12 | google | google-generative-ai | apiKey | 22 | |
| 13 | google-vertex | Google Vertex AI | google-vertex | apiKey | 14 |
| 14 | groq | Groq | openai-completions | apiKey | 7 |
| 15 | huggingface | Hugging Face | openai-completions | apiKey | 76 |
| 16 | kimi-coding | Kimi For Coding | anthropic-messages | apiKey + oauth | 4 |
| 17 | meta | Meta | openai-responses | apiKey + oauth | 5 |
| 18 | minimax | MiniMax | anthropic-messages | apiKey | 3 |
| 19 | minimax-cn | MiniMax CN | anthropic-messages | apiKey | 3 |
| 20 | mistral | Mistral | mistral-conversations | apiKey | 33 |
| 21 | moonshotai | Moonshot AI | openai-completions | apiKey | 4 |
| 22 | moonshotai-cn | Moonshot AI CN | openai-completions | apiKey | 4 |
| 23 | nvidia | NVIDIA | openai-completions | apiKey | 19 |
| 24 | openai | OpenAI | openai-responses | apiKey | 39 |
| 25 | openai-codex | OpenAI Codex | openai-codex-responses | 仅 oauth | 6 |
| 26 | opencode | OpenCode Zen | 四种混合 | apiKey | 70 |
| 27 | opencode-go | OpenCode Go | 三种混合 | apiKey | 30 |
| 28 | openrouter | OpenRouter | 两种混合 | apiKey + oauth | 377 |
| 29 | qwen-token-plan | Qwen Token Plan | openai-completions | apiKey | 20 |
| 30 | qwen-token-plan-cn | Qwen Token Plan CN | openai-completions | apiKey | 20 |
| 31 | qwen-token-plan-individual | Qwen Token Plan Individual | openai-completions | apiKey | 9 |
| 32 | radius | Radius | pi-messages | apiKey + oauth | 28(另有动态目录) |
| 33 | together | Together | openai-completions | apiKey | 22 |
| 34 | vercel-ai-gateway | Vercel AI Gateway | anthropic-messages | apiKey | 240 |
| 35 | xai | xAI | openai-responses | apiKey + oauth | 4 |
| 36 | xiaomi | Xiaomi | openai-completions | apiKey | 6 |
| 37 | xiaomi-token-plan-ams | Xiaomi Token Plan AMS | openai-completions | apiKey | 4 |
| 38 | xiaomi-token-plan-cn | Xiaomi Token Plan CN | openai-completions | apiKey | 4 |
| 39 | xiaomi-token-plan-sgp | Xiaomi Token Plan SGP | openai-completions | apiKey | 4 |
| 40 | zai | Z.AI | openai-completions | apiKey | 7 |
| 41 | zai-coding-cn | Z.AI Coding CN | openai-completions | apiKey | 4 |
三个观察值得展开:
- 协议高度收敛。41 家里有 20 家只用
openai-completions。KnownApi一共只列了 10 种协议(packages/ai/src/types.ts:17-27),而KnownProvider列了 41 个(types.ts:35-75),和builtinProviders()返回的 41 个 id 一一对应——协议数和厂商数差了四倍,这正是「统一模型接口」这个包成立的前提。 - 静态目录与注册表一样多,但有一条注释没跟上。生成的目录
MODELS也是 41 个键,radius也在其中(它的 28 条静态模型就来自这里)。可all.ts:51-53的注释还写着「KnownProvider额外包含没有静态目录条目的纯动态 Provider(例如 radius)」。源码里的注释也会过时:本章实践任务会让你亲手量出这三个 41,再对照这段注释。 - 数量不等于可用。上表是「Pi 知道有这些模型」,不是「你现在能用」。没有任何凭据时,Pi 给出的提示来自
packages/coding-agent/src/core/auth-guidance.ts:6-16,第一行是:
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 },
"promptCache": { "short": 300, "long": 3600 },
"inputLimits": { "maxRequestBytes": 33554432, "images": { "maxPerRequest": 100, "resize": { "…": "省略" } } } }除了价格和窗口,记录里还有提示词缓存的两档时长(promptCache,单位秒)和单次请求的输入上限(inputLimits,含图片张数与缩放规则)。resize 的四个字段这里省略了。
第二层是聚合文件 models.generated.ts:41 个 import,一个 MODELS 常量,没有任何逻辑。
MODELS第三层是生成器本身。它从 models.dev 这个公共模型数据库抓取原始数据:
loadModelsDevData写出的三类产物分别是:src/providers/data/<id>.json 数据快照与 .manifest.json 校验清单(generate-models.ts:3208-3217,先写进临时目录,校验通过才替换)、<id>.models.ts 分片(3245-3257,同时删除已不存在的旧分片)、src/models.generated.ts 聚合(3259-3272)。
对应三个 npm script(packages/ai/package.json:56-58),入口在仓库根(package.json:30-31):
| 命令 | 实际执行 | 用途 |
|---|---|---|
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 把 41 份 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:41 个 Provider 不该拖慢启动
41 个 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:73-98(节选,省略两个可选能力的实现)
export function lazyApi(load: () => Promise<ProviderStreams>, capabilities?: LazyApiCapabilities): ProviderStreams {
const api: ProviderStreams = {
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)),
};
if (capabilities?.fetchDeferred) {
// …(省略:同样经 lazyStream 懒加载后转调 fetchDeferred,lazy.ts:82-87)
}
if (capabilities?.cancelDeferred) {
// …(省略:懒加载后转调 cancelDeferred,lazy.ts:90-94)
}
return api;
}lazyApi第二个参数 capabilities 是给支持「延迟响应」(6.1 讲过的 deferred)的协议用的:包装层必须在加载模块之前就决定对象上有没有 fetchDeferred,因为 createProvider 靠这个字段是否存在来决定 Provider 要不要暴露同名方法(models.ts:856-881)。在 v0.87.0 的 11 个 .lazy.ts 里,没有一个传了这个参数。
它依赖同文件的 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」(第 62 行)、「构建全部内置 Provider 时也不加载」(第 71 行)、「导入 compat 入口时也不加载」(第 78 行),而「通过懒包装真正发起一次流式请求时,只加载 @anthropic-ai/sdk 一个包」(第 100 行)。本章实践任务会让你亲自跑一遍这 5 个用例。
模型选择:--model 是怎么被解析的
到这里,Pi 已经知道「世界上有哪些模型」了。剩下的问题是:用户敲的那串字符对应哪一个?
CLI 的帮助文本对 --model 的描述是(源码事实,packages/coding-agent/src/cli/args.ts:279):
--model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")解析入口在 coding-agent 侧,不在 pi-ai 里。完整调用链(每环都是源码事实):
main()(packages/coding-agent/src/main.ts:566)→ parseArgs(packages/coding-agent/src/cli/args.ts:71,--model 分支在第 106 行)→ buildSessionOptions(main.ts:450,在第 801 行被调用)→ resolveCliModel(调用点 main.ts:469,定义在 packages/coding-agent/src/core/model-resolver.ts:406)→ parseModelPattern(model-resolver.ts:204)→ tryMatchModel(model-resolver.ts:136)→ findExactModelReferenceMatch(model-resolver.ts:88)。
也就是说,CLI 给了 --model 时,模型在创建会话之前就已经解析好,作为 options.model 交给 createAgentSession。没给 --model 时才轮到 createAgentSession(packages/coding-agent/src/core/sdk.ts:175)里的 findInitialModel(调用点 sdk.ts:214,定义在 model-resolver.ts:622)兜底,本节最后会讲。
拆开看三个关键动作:
① provider/id 前缀推断。resolveCliModel 先建一张小写 provider 名到规范名的映射,然后看 --model 里第一个斜杠前面是不是已知 provider(model-resolver.ts:452-463)。是的话就把它当 provider,剩下当 pattern。源码注释特意举了例子:zai/glm-5 应当解析成 provider=zai、model=glm-5,而不是去匹配某个 id 里字面含斜杠的 Vercel 网关模型。如果前缀不是已知 provider(比如 OpenRouter 那种 vendor/model 形式的 id),代码先对整串做精确匹配(model-resolver.ts:465-504):只有一个模型命中就直接用;多个 Provider 下都有这个 id 时,如果其中恰好只有一个已配好认证就选它,否则返回一条「在多个 provider 间有歧义」的错误,要求你写明 --provider 或 provider/model,而不是按目录顺序随便挑一个。反过来,前缀被推断成 provider、却在它下面找不到模型时,也会退回来把整串当作模型 id 在全部模型里再找一次(model-resolver.ts:544-568,注释里的例子是 openai/gpt-4o:extended 其实是 OpenRouter 的模型 id)。
② :thinking 后缀与冒号歧义。麻烦在于有些模型 id 本身带冒号(OpenRouter 的 :exacto)。parseModelPattern 用的是「先整串、失败再剥最后一个冒号」的递归策略:
// packages/coding-agent/src/core/model-resolver.ts:209-227(节选,省略无冒号与非法后缀分支)
// 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:217-220)
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:225-236)parseModelPattern注意第 239-244 行的 allowInvalidThinkingLevelFallback:CLI 的 --model 走严格模式(resolveCliModel 在 model-resolver.ts:515-517 传 false),非法后缀直接判定为不匹配,而不是悄悄剥掉冒号解析成另一个模型——这避免了「你以为选了 A,实际跑的是 B」。合法档位来自 ThinkingLevel:minimal | low | medium | high | xhigh | max(packages/ai/src/types.ts:84)。
③ 模糊匹配与版本偏好。精确匹配失败后,tryMatchModel(model-resolver.ts:143-165)对 id 和 name 做子串包含匹配,然后在候选里优先选 alias 而不是带日期的具体版本:isAlias()(model-resolver.ts:74-81)把以 -latest 结尾、或不以 -YYYYMMDD 结尾的 id 视为 alias。多个 alias 时按 id 倒序取第一个;若候选全是带日期版本,同样按 id 倒序取最新那个。以本仓库的 anthropic 目录为例,--provider anthropic --model sonnet 的候选里有 claude-sonnet-4-5、claude-sonnet-4-6、claude-sonnet-5 等 alias 和 claude-sonnet-4-5-20250929 这个带日期版本,按规则会落到倒序第一的 claude-sonnet-5(据此推断(尚未在源码中直接证实):这是按上述排序规则手工推演的结果,本书没有实际运行这条命令)。
图 6.2-3 model 参数从字符串到 Model 对象的解析决策
这是一张决策图,不是时序图。请重点关注两处:冒号后缀合法就剥掉后缀递归重来;整串精确匹配命中多个 provider 时,只有「恰好一个已认证」才会自动选中,否则直接报歧义。error 会被 buildSessionOptions 收进启动诊断,main 报告后以 process.exit(1) 退出(main.ts:897-906)。
图里的 L 节点值得单独说一句:当 provider 已经确定、但模型 id 在目录里查无此物时,buildFallbackModel(model-resolver.ts:175-189)不会直接失败,而是拿该 provider 的默认模型做模板、把 id 和 name 换成你输入的字符串,并附一条警告。这就是自建服务、私有部署模型能被 --provider xxx --model 我的模型名 直接使用的原因。
如果用户根本没给 --model 呢?createAgentSession 先看调用方有没有传 options.model,再尝试从已有会话里恢复上次用的模型(要求该 provider 已认证,sdk.ts:198-210),都没有才调用 findInitialModel(sdk.ts:212-222)。findInitialModel(model-resolver.ts:614-709)本身按优先级兜底:同时给了 cliProvider 和 cliModel 就先解析它们(第 649-662 行)→ scoped models 的第一个(续接会话时跳过,第 664-673 行)→ settings 里保存的默认模型(且该 provider 已配好认证)→ 在已认证的可用模型里,按 defaultModelPerProvider(model-resolver.ts:20)的顺序找第一个「provider 的默认模型」,一个都没对上就直接取可用列表的第一个(第 690-705 行)→ 可用列表为空才返回 undefined,createAgentSession 于是给出上面那条 No models available 提示(sdk.ts:224-225)。注意 createAgentSession 调用时传的是空的 scopedModels,也不传 CLI 参数,所以在这条路径上真正起作用的只有后三级。
pi --help 里写着 --provider <name> Provider name (default: google)(源码事实,packages/coding-agent/src/cli/args.ts:278)。但源码里 findInitialModel 没有任何硬编码的「默认 google」逻辑,走的是上面这套兜底;defaultModelPerProvider 排在最前面的是 amazon-bedrock,不是 google。这条帮助文本疑为陈旧(分析解释:未找到与之对应的实现)。读文档与读源码冲突时,以源码为准。 凭据:环境变量与 OAuth
最后一块拼图是「怎么证明你有权用这个模型」。
环境变量路径。绝大多数 Provider 用同一个 helper:
envApiKeyAuth具体哪个 Provider 认哪个变量,集中在 packages/ai/src/env-api-keys.ts 的映射表里(env-api-keys.ts:79-117,函数 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:149-152);amazon-bedrock 与 google-vertex 支持「环境凭据」——AWS profile、IAM key、ECS 任务角色,或 Google 的 ADC 文件——此时函数返回哨兵值 "<authenticated>" 而不是真密钥(env-api-keys.ts:154-186)。另外 github-copilot 认的是 COPILOT_GITHUB_TOKEN,写在映射表之前的单独分支里(env-api-keys.ts:69-71)。
OAuth 路径。订阅制服务(Claude Pro/Max、ChatGPT Plus 的 Codex、GitHub Copilot 等)走 OAuth。Provider 定义里只声明一个懒包装:oauth: lazyOAuth({ name, isSubscription: true, load: loadAnthropicOAuth })(providers/anthropic.ts:50-54),实现直到第一次 login/refresh/toAuth 才被动态加载(auth/helpers.ts:33-39 的注释),动机与 .lazy.ts 一样——Node 专属的 OAuth 流程代码不该进浏览器打包产物。官方文档说明(来源:packages/coding-agent/docs/providers.md:15-27):交互模式下用 /login 选择 Provider,token 存在 ~/.pi/agent/auth.json 并在过期时自动刷新;/logout 清除。同文档第 318-325 行给出凭据解析顺序: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:270-275)会把四个来源——内置、extension 原生 Provider、models.json 配置、extension 配置覆盖——的 provider id 收齐(providerIds,model-runtime.ts:237-244),逐个交给 recomposeProvider(model-runtime.ts:246-268):没有任何覆盖时原样用内置 Provider,有覆盖时用 composeModelProvider 叠一层,再 setProvider。这就是为什么 setProvider 的「按 id upsert」语义重要:用户在 models.json 里写一个 id: "anthropic" 的条目,就能覆盖内置定义的 baseUrl 或模型清单。7.4 自定义 Provider 会完整讲这条路径。
实践任务
目标:不靠本书的表格,自己量出内置 Provider 数量和静态目录数量,并用源码判断 all.ts 里一段注释是否还成立;再完整读懂一个 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 - 对照注释与事实:先读
sed -n '51,53p' packages/ai/src/providers/all.ts,再跑grep -n radius packages/ai/src/models.generated.ts,看 radius 是否真的「没有静态目录条目」 - 读最短的 Provider:
cat packages/ai/src/providers/cerebras.ts(15 行),逐行标出「api / auth / models」三件套各在哪一行 - 跑懒加载测试:
npm test --workspace=@earendil-works/pi-ai -- lazy-module-load
预期现象:第 1 步输出 41;第 2、3 步也各输出 41(不同 shell 可能带前导空格);第 4 步的注释说 radius 这类纯动态 Provider 没有静态目录条目,但 grep 会在 models.generated.ts 里找到三行 radius(第 35 行的 import、第 78 行的类型、第 120 行的值);第 6 步 vitest 报告 Test Files 1 passed (1) / Tests 5 passed (5)(本书作者在锁定版本上实际运行通过,耗时约 1 秒)。
如何判断成功:你能用一句话说明「注册表、目录分片、数据快照三者数量一致,all.ts:51-53 那段注释已经与生成的目录不符」,并能说出自己是凭哪一行源码判断的。
常见错误:① 第 1 步用 grep -c "Provider"(不带括号逗号)会连 import 行和类型名一起数进去,得到 108 这种远大于 41 的结果;② 第 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、名称、静态模型数与认证方式——本章那张 41 行表格里的 id、名称、认证与静态模型数就是这么导出的(协议一列另外读了每个模型的 api 字段)。
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:51-53、all.ts:90-143、packages/ai/src/models.generated.ts:35、packages/ai/src/providers/cerebras.ts:6-15、packages/ai/test/lazy-module-load.test.ts:58-113。
本章小结
- 一个 Provider = api 实现(wire protocol)+ 模型清单 + 认证三件套,接口定义在
models.ts:99-156;auth必填,因为它同时是「是否可用」的唯一信号。 - 注册是显式的:
builtinProviders()是一个 41 项的数组,builtinModels()逐个setProvider;没有目录扫描、没有副作用注册,换来 tree-shaking 与确定的顺序。 - 协议只有 10 种(
KnownApi),Provider 有 41 个(KnownProvider,与builtinProviders()一一对应)——一对多的复用是这个包成立的前提;41 个 Provider 都有静态目录,all.ts:51-53说 radius 没有静态目录的注释已经过时。 - 动态目录的刷新由
Models.refresh()统一调度:先离线恢复context.stored,再联网刷新;Provider 只通过带代次检查的context.publish()改内存和存储。ModelsRequestTransforms.transformHeaders在applyAuth最后一步改写完整的请求头。 - 模型元数据来自 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 优先于带日期版本;命中多个 provider 时只在「恰好一个已认证」时自动选择;无参数时由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:99-156(Provider)、:48-64(RefreshModelsContext)、:80-83(ModelsRequestTransforms)、:784-884(createProvider)、:648-677(applyAuth)、:350-458(发布与两阶段刷新)packages/ai/src/providers/all.ts:90-134(builtinProviders)、:137-143(builtinModels)packages/ai/src/providers/cerebras.ts:6-15(最小 Provider)、anthropic.ts:43-59、radius.ts:22-96(手写 Provider)packages/ai/src/models.generated.ts:46-130、packages/ai/scripts/generate-models.ts:1733-1738packages/ai/src/api/lazy.ts:46-98packages/coding-agent/src/core/model-resolver.ts:88/136/204/406/622packages/coding-agent/src/core/model-runtime.ts:173-275- 测试:
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的存储格式与 coding-agent 的远程目录包装withRemoteCatalog;compat字段如何按 api 条件分派模型兼容性开关;图像模型 Provider(builtinImagesProviders)。
✅ 自测问题参考答案先自己回答,再点开对照
- 因为
auth同时是「这个 Provider 现在可不可用」的唯一信号。envApiKeyAuth解析不到凭据时返回undefined,findInitialModel和resolveCliModel正是靠这个来判断「哪些 provider 已经配好了认证」,从而挑出默认模型、以及在/login界面里显示哪些可选。如果允许「没有密钥就不写 auth」,这个信号就成了「字段缺失」和「字段存在但解析不出」两种含义混在一起,调用方无从区分「这家不需要认证」和「这家没配好」。 - 先看第一个斜杠前缀
openrouter——它是已知 provider,于是 provider 定为 openrouter,剩下的qwen/qwen3-max:high当作 pattern(这就是注释里zai/glm-5那个例子要解决的问题:不能把整串当模型 id 去匹配)。然后parseModelPattern拿 pattern 先整串精确匹配,匹配不到就从右边剥最后一个冒号,后缀high是合法思考档位,于是记下thinkingLevel: "high"并对前缀qwen/qwen3-max递归——注意剩下的这个斜杠不再被当作 provider 前缀,它本来就是 OpenRouter 那种vendor/model形式的 id 的一部分。 - 至少三处:① 新建
packages/ai/src/providers/<id>.ts,照cerebras.ts那 15 行写「api / auth / models」三件套——OpenAI 兼容的话 api 直接复用openAICompletionsApi();② 在providers/all.ts的builtinProviders()数组里加一行注册(注册是显式的,没有目录扫描);③ 在types.ts的KnownProvider里加上这个 id。加了之后 coding-agent 的defaultModelPerProvider(packages/coding-agent/src/core/model-resolver.ts:20)也必须补一项,因为它的类型是以KnownProvider为键的Record,缺一个 id 就过不了类型检查。模型清单如果走生成式目录,还要让 models.dev 那边有数据并重跑npm run generate:models(在仓库根,对应packages/ai里的generate-models脚本);环境变量名则加进env-api-keys.ts的映射表。 - 因为它是机器生成的——文件头两行就写着「auto-generated by scripts/generate-models.ts,Do not edit manually」。数据源是 models.dev,生成器写出三层产物(
providers/data/<id>.json快照 +<id>.models.ts分片 +models.generated.ts聚合),下次重跑npm run generate:models会把你的手改整个覆盖掉。要改就改生成器或数据源;要给自己加模型,正确入口是models.json里的自定义 Provider。