Skip to content

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-messagesopenai-completionsopenai-responses 三种)。正因为可组合,Pi 才能用几十行代码接入一家新厂商。

这三件事在源码里就是 Provider interface 的三组字段(源码事实):

earendil-works/pi@c13ffe1第 75–120 行在 GitHub 查看 ↗
Provider 接口:id/name/baseUrl/headers 元数据 + 必填的 auth + getModels 同步模型清单 + stream/streamSimple 流行为;可选的 refreshModels/filterModels 供动态目录使用。

注意 auth必填的(models.ts:89)。源码注释解释了原因:即使是只靠环境变量、AWS profile 甚至完全不需要密钥的本地服务,也要提供一个 apiKey 认证对象,因为 resolve() 的返回值同时承担了「这个 Provider 有没有配好」这个判断——Models.getAuth() 在未配置时返回 undefined。这是一个值得记住的设计:认证不是可选装饰,而是可用性的唯一信号源

最小示例:一个 15 行的 Provider

packages/ai/src/providers/ 下最短的 Provider 定义文件之一是 Cerebras,全文 15 行(源码事实):

ts
// 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(),
	});
}
earendil-works/pi@c13ffe1第 6–15 行在 GitHub 查看 ↗
最小 Provider 工厂:三件套各占一行——api 用共享的 openai-completions 实现、auth 用通用的环境变量认证、models 来自生成的目录分片。

把这 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_TOKENANTHROPIC_OAUTH_TOKENANTHROPIC_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:547574-587);如果配了 fetchModels,就自动接上 ModelsStore 的读取/回写逻辑(models.ts:596-617)。

⚠️ 常见误解以为 Provider 必须用 createProvider 生成
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 循环(源码事实):

ts
// 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(),
	];
}
earendil-works/pi@c13ffe1第 87–128 行在 GitHub 查看 ↗
内置 Provider 清单:38 个工厂函数调用,按 id 字母序排列。新增一家厂商 = 加一个 import 加一行调用。
earendil-works/pi@c13ffe1第 131–137 行在 GitHub 查看 ↗
一键注册:createModels 造出空集合,再逐个 setProvider。setProvider 按 provider.id upsert,所以同 id 会被替换。

从源码结构看,选择显式数组而不是目录扫描,至少换来三点:打包工具能静态分析(只 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 选实现。

这张图的每个节点都能在源码里定位。特别注意 applyAuthmodels.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.jsongeneratedAt);你重新 hydrate 之后数字会变。

#Provider id名称主要 wire protocol认证静态模型数
1amazon-bedrockAmazon Bedrockbedrock-converse-streamapiKey114
2ant-lingAnt Lingopenai-completionsapiKey3
3anthropicAnthropicanthropic-messagesapiKey + oauth15
4azure-openai-responsesAzure OpenAIazure-openai-responsesapiKey38
5cerebrasCerebrasopenai-completionsapiKey3
6cloudflare-ai-gatewayCloudflare AI Gateway三种混合apiKey43
7cloudflare-workers-aiCloudflare Workers AIopenai-completionsapiKey13
8deepseekDeepSeekopenai-completionsapiKey2
9fireworksFireworks两种混合apiKey16
10github-copilotGitHub Copilot三种混合apiKey + oauth29
11googleGooglegoogle-generative-aiapiKey24
12google-vertexGoogle Vertex AIgoogle-vertexapiKey12
13groqGroqopenai-completionsapiKey7
14huggingfaceHugging Faceopenai-completionsapiKey51
15kimi-codingKimi For Codinganthropic-messagesapiKey + oauth4
16minimaxMiniMaxanthropic-messagesapiKey3
17minimax-cnMiniMax CNanthropic-messagesapiKey3
18mistralMistralmistral-conversationsapiKey30
19moonshotaiMoonshot AIopenai-completionsapiKey10
20moonshotai-cnMoonshot AI CNopenai-completionsapiKey10
21nvidiaNVIDIAopenai-completionsapiKey30
22openaiOpenAIopenai-responsesapiKey38
23openai-codexOpenAI Codexopenai-codex-responses仅 oauth7
24opencodeOpenCode Zen四种混合apiKey59
25opencode-goOpenCode Go三种混合apiKey16
26openrouterOpenRouteropenai-completionsapiKey + oauth303
27qwen-token-planQwen Token Planopenai-completionsapiKey15
28qwen-token-plan-cnQwen Token Plan CNopenai-completionsapiKey15
29radiusRadiuspi-messagesapiKey + oauth0(纯动态)
30togetherTogetheropenai-completionsapiKey17
31vercel-ai-gatewayVercel AI Gatewayanthropic-messagesapiKey193
32xaixAI两种混合apiKey + oauth3
33xiaomiXiaomiopenai-completionsapiKey6
34xiaomi-token-plan-amsXiaomi Token Plan AMSopenai-completionsapiKey3
35xiaomi-token-plan-cnXiaomi Token Plan CNopenai-completionsapiKey3
36xiaomi-token-plan-sgpXiaomi Token Plan SGPopenai-completionsapiKey3
37zaiZ.AIopenai-completionsapiKey6
38zai-coding-cnZ.AI Coding CNopenai-completionsapiKey6

三个观察值得展开:

  1. 协议高度收敛。38 家里有 20 家直接用 openai-completionsKnownApi 一共只列了 10 种协议(packages/ai/src/types.ts:16-26),而 KnownProvider 列了 38 个(types.ts:34-72)——协议数和厂商数差一个数量级,这正是「统一模型接口」这个包成立的前提。
  2. radius 是唯一没有静态目录的内置 ProviderbuiltinProviders() 返回 38 个,但生成的目录 MODELS 只有 37 个键。all.ts:48-51 的注释直接说明了这一点:KnownProvider 额外包含纯动态 Provider。这个 38 vs 37 的差值是本章实践任务里可以亲手量出来的。
  3. 数量不等于可用。上表是「Pi 知道有这些模型」,不是「你现在能用」。没有任何凭据时,pi --list-models 的真实输出是(真实采集,见 research/cli-captures/pi-list-models-claude.txt):
text
No models available. Use /login to log into a provider via OAuth or API key. See:

后面两行是本机上 docs/providers.mddocs/models.md 的绝对路径。可用性由认证决定,这是本章最后一节的主题。

模型数据从哪来:generate-models 与 models.generated.ts

上千条模型元数据不可能手写维护。Pi 的做法是生成式目录:数据来自外部数据源,由脚本生成三层文件,其中一层刻意不进版本库。

先看最里面一层。每个 Provider 的目录分片只有 8 行,且明确写着「不要手改」(源码事实):

ts
// 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 类型。一条模型记录长这样(取自本仓库快照):

json
{ "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 常量,没有任何逻辑。

earendil-works/pi@c13ffe1第 42–118 行在 GitHub 查看 ↗
生成的聚合常量:把 37 个 provider 目录分片合成一张按 provider id 索引的大表,供 all.ts 的 getBuiltinModel/getBuiltinModels 做类型化读取。

第三层是生成器本身。它从 models.dev 这个公共模型数据库抓取原始数据:

earendil-works/pi@c13ffe1第 1081–1086 行在 GitHub 查看 ↗
数据源:fetch https://models.dev/api.json。脚本随后对其做逐 provider 的修正与补齐,再写出三类产物。

写出的三类产物分别是: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:modelsgenerate-models.ts --strict全量重生成:数据 + 分片 + 聚合
npm run hydrate:model-data... --strict --data-only只补 data/*.json,不动 TS 文件
npm run check:model-datacheck-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 会在第一个分片上就报:

text
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.

🌱 初学者提示为什么不干脆把 JSON 签进仓库
源码里没有直接写明动机,据此推断(尚未在源码中直接证实):模型价格与上下文窗口是高频变动的外部事实,签进仓库会让每次数据刷新都产生几千行 diff,且容易与代码变更混在一条 commit 里。把「代码」和「外部事实」分库管理、构建时再水合,是数据密集型项目的常见取舍;代价就是新贡献者必须多跑一条命令。

.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

ts
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));

秘密全在 lazyApi 里:

ts
// 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)),
	};
}
earendil-works/pi@c13ffe1第 68–75 行在 GitHub 查看 ↗
懒加载包装:返回一个满足 ProviderStreams 契约的对象,真正的模块直到第一次调用 stream 才被动态 import。

它依赖同文件的 lazyStreamapi/lazy.ts:46-61):同步返回一个空的事件流对象,异步在背后做 setup(动态 import、认证解析),setup 抛错时不向调用方 throw,而是把错误 push 成流里的一个 error 事件再结束流(lazy.ts:54-58)。这个「错误进流、不抛异常」的约定贯穿整个 pi-ai,6.1StreamFunction 契约时会再遇到。

这不是纸面设计——仓库里有一个专门的探针测试守着它。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 行):

text
  --model <pattern>              Model pattern or ID (supports "provider/id" and optional ":<thinking>")

解析入口在 coding-agent 侧,不在 pi-ai 里。完整调用链(每环都是源码事实):

main()packages/coding-agent/src/main.ts:521)→ parseArgspackages/coding-agent/src/cli/args.ts:64--model 分支在第 90 行)→ createAgentSessionpackages/coding-agent/src/core/sdk.ts:169)→ findInitialModelpackages/coding-agent/src/core/model-resolver.ts:572)→ resolveCliModelmodel-resolver.ts:385)→ parseModelPatternmodel-resolver.ts:195)→ tryMatchModelmodel-resolver.ts:127)→ findExactModelReferenceMatchmodel-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 用的是「先整串、失败再剥最后一个冒号」的递归策略:

ts
// 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)
earendil-works/pi@c13ffe1第 195–248 行在 GitHub 查看 ↗
模型 pattern 解析:整串优先,失败后从右侧剥冒号后缀;后缀是合法思考档位就记下并递归前缀,否则按调用方策略警告降级或直接失败。

注意第 230-235 行的 allowInvalidThinkingLevelFallback:CLI 的 --model严格模式resolveCliModelmodel-resolver.ts:465-467false),非法后缀直接判定为不匹配,而不是悄悄剥掉冒号解析成另一个模型——这避免了「你以为选了 A,实际跑的是 B」。合法档位来自 ThinkingLevelminimal | low | medium | high | xhigh | maxpackages/ai/src/types.ts:79)。

③ 模糊匹配与版本偏好。精确匹配失败后,tryMatchModelmodel-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 在目录里查无此物时,buildFallbackModelmodel-resolver.ts:166-180)不会直接失败,而是拿该 provider 的默认模型做模板、把 id 和 name 换成你输入的字符串,并附一条警告。这就是自建服务、私有部署模型能被 --provider xxx --model 我的模型名 直接使用的原因。

如果用户根本没给 --model 呢?findInitialModelmodel-resolver.ts:572-652)按五级优先级兜底:CLI 参数 → --models 指定的 scoped models 第一个 → settings 里保存的默认模型(且该 provider 已配好认证)→ 遍历 defaultModelPerProvidermodel-resolver.ts:14)找第一个「已认证的 provider 的默认模型」→ 仍然没有就返回 undefined(对应上面 No models available 的输出)。

⚠️ 常见误解以为 --provider 的默认值真的是 google
pi --help 里写着 --provider <name> Provider name (default: google)(真实采集,pi-help.txt 第 17 行)。但源码里 findInitialModel 没有任何硬编码的「默认 google」逻辑,走的是上面这五级兜底。这条帮助文本疑为陈旧(分析解释:未找到与之对应的实现)。读文档与读源码冲突时,以源码为准。

凭据:环境变量与 OAuth

最后一块拼图是「怎么证明你有权用这个模型」。

环境变量路径。绝大多数 Provider 用同一个 helper:

earendil-works/pi@c13ffe1第 9–27 行在 GitHub 查看 ↗
通用环境变量认证:先看已存储的凭据,再按给定顺序查环境变量;都没有就返回 undefined,表示该 Provider 未配置。

具体哪个 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-bedrockgoogle-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-keyauth.json → 环境变量 → models.json 里的自定义 Provider 密钥。

各家 OAuth 的具体流程(PKCE、设备码、企业域名等)本章不展开,它们与 Agent 原理无关,属于 4.4 划定的支线。

Pi 侧的组装。pi-ai 提供 builtinModels(),但 coding-agent 并不直接用它,而是自己拿 builtinProviders() 再包一层:

earendil-works/pi@c13ffe1第 135–174 行在 GitHub 查看 ↗
Pi 侧的模型运行时:取内置 Provider 列表,除 radius 外统统包上远程目录刷新能力,再叠加 models.json 里的自定义 Provider 与 extension 注册的 Provider,最后构建 Models 集合。

ModelRuntimerebuildProviders()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 数量、静态目录数量,解释两者的差值;再完整读懂一个 Provider 文件;最后跑通懒加载测试。全程不需要任何 API Key。

前提:已完成 4.1 的实践任务(依赖已装、npm run hydrate:model-data 已跑过)。以下命令都在 Pi 仓库根目录执行,且全部是只读操作。

步骤与命令

  1. 数注册条目:grep -c "Provider()," packages/ai/src/providers/all.ts
  2. 数目录分片:ls packages/ai/src/providers/*.models.ts | wc -l
  3. 数数据快照:ls packages/ai/src/providers/data/*.json | wc -l
  4. 找出差值来自谁:ls packages/ai/src/providers/ | grep -v "\.models\.ts" | grep "\.ts$" 对照 ls packages/ai/src/providers/*.models.ts,看哪个 xxx.ts 没有对应的 xxx.models.ts
  5. 读最短的 Provider:cat packages/ai/src/providers/cerebras.ts(15 行),逐行标出「api / auth / models」三件套各在哪一行
  6. 跑懒加载测试:npm test --workspace=@earendil-works/pi-ai -- lazy-module-load

预期现象:第 1 步输出 38;第 2、3 步各输出 37(不同 shell 可能带前导空格);第 4 步你会发现 radius.tsfaux.tsopenrouter-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-137packages/ai/src/providers/cerebras.ts:6-15packages/ai/test/lazy-module-load.test.ts:65-119

本章小结

  • 一个 Provider = api 实现(wire protocol)+ 模型清单 + 认证三件套,接口定义在 models.ts:75-120auth 必填,因为它同时是「是否可用」的唯一信号。
  • 注册是显式的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-120Provider)、:556-623createProvider)、:463-487applyAuth
    • packages/ai/src/providers/all.ts:87-128builtinProviders)、:131-137builtinModels
    • packages/ai/src/providers/cerebras.ts:6-15(最小 Provider)、anthropic.ts:38-50radius.ts:20-67(手写 Provider)
    • packages/ai/src/models.generated.ts:42-118packages/ai/scripts/generate-models.ts:1081-1086
    • packages/ai/src/api/lazy.ts:46-75
    • packages/coding-agent/src/core/model-resolver.ts:79/127/195/385/572
    • packages/coding-agent/src/core/model-runtime.ts:135-174
    • 测试:packages/ai/test/lazy-module-load.test.tspackages/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)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)