Skip to content

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 的三组字段(源码事实):

earendil-works/pi@16787ad第 99–156 行在 GitHub 查看 ↗
Provider 接口:id/name/baseUrl/headers 元数据 + 必填的 auth + getModels 同步模型清单 + stream/streamSimple 流行为;可选的 refreshModels/filterModels 供动态目录使用,可选的 fetchDeferred/cancelDeferred 供延迟响应使用。

注意 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 行(源码事实):

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@16787ad第 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-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)。

packages/ai/src/models.ts · createProvider
earendil-works/pi@16787ad第 784–884 行在 GitHub 查看 ↗
Provider 工厂:合并静态与动态模型、按 model.api 分派 api 实现、由 fetchModels 生成 refreshModels;任一 api 实现带 fetchDeferred/cancelDeferred 时,Provider 也暴露同名方法。

这个生成出来的 refreshModels 不自己读写存储,而是只跟调用方递进来的上下文打交道:

packages/ai/src/models.ts · RefreshModelsContext
earendil-works/pi@16787ad第 48–64 行在 GitHub 查看 ↗
刷新上下文:credential 是生效的凭据;stored 是本轮开始前拍下的该 Provider 目录快照(只读);publish 是带代次检查的发布入口;allowNetwork 为 false 时只允许离线恢复;force 跳过新鲜度检查;signal 始终存在。

读 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 统一处理。

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

ts
// packages/ai/src/providers/all.ts:90-134(节选,中间同构的调用省略)
export function builtinProviders(): Provider[] {
	return [
		amazonBedrockProvider(),
		antLingProvider(),
		anthropicProvider(),
		// …(省略:azure-openai-responses 到 zai 共 37 个同样形式的工厂调用)
		zaiCodingCnProvider(),
	];
}
earendil-works/pi@16787ad第 90–134 行在 GitHub 查看 ↗
内置 Provider 清单:41 个工厂函数调用,按 id 字母序排列。新增一家厂商 = 加一个 import 加一行调用。
earendil-works/pi@16787ad第 137–143 行在 GitHub 查看 ↗
一键注册:createModels 造出空集合,再逐个 setProvider。setProvider 按 provider.id upsert,所以同 id 会被替换。

从源码结构看,选择显式数组而不是目录扫描,至少换来三点:打包工具能静态分析(只 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认证静态模型数
1amazon-bedrockAmazon Bedrockbedrock-converse-streamapiKey165
2ant-lingAnt Lingopenai-completionsapiKey3
3anthropicAnthropicanthropic-messagesapiKey + oauth15
4azure-openai-responsesAzure OpenAIazure-openai-responsesapiKey39
5basetenBasetenopenai-completionsapiKey21
6cerebrasCerebrasopenai-completionsapiKey2
7cloudflare-ai-gatewayCloudflare AI Gateway三种混合apiKey51
8cloudflare-workers-aiCloudflare Workers AIopenai-completionsapiKey18
9deepseekDeepSeekopenai-completionsapiKey2
10fireworksFireworks两种混合apiKey33
11github-copilotGitHub Copilot三种混合apiKey + oauth29
12googleGooglegoogle-generative-aiapiKey22
13google-vertexGoogle Vertex AIgoogle-vertexapiKey14
14groqGroqopenai-completionsapiKey7
15huggingfaceHugging Faceopenai-completionsapiKey76
16kimi-codingKimi For Codinganthropic-messagesapiKey + oauth4
17metaMetaopenai-responsesapiKey + oauth5
18minimaxMiniMaxanthropic-messagesapiKey3
19minimax-cnMiniMax CNanthropic-messagesapiKey3
20mistralMistralmistral-conversationsapiKey33
21moonshotaiMoonshot AIopenai-completionsapiKey4
22moonshotai-cnMoonshot AI CNopenai-completionsapiKey4
23nvidiaNVIDIAopenai-completionsapiKey19
24openaiOpenAIopenai-responsesapiKey39
25openai-codexOpenAI Codexopenai-codex-responses仅 oauth6
26opencodeOpenCode Zen四种混合apiKey70
27opencode-goOpenCode Go三种混合apiKey30
28openrouterOpenRouter两种混合apiKey + oauth377
29qwen-token-planQwen Token Planopenai-completionsapiKey20
30qwen-token-plan-cnQwen Token Plan CNopenai-completionsapiKey20
31qwen-token-plan-individualQwen Token Plan Individualopenai-completionsapiKey9
32radiusRadiuspi-messagesapiKey + oauth28(另有动态目录)
33togetherTogetheropenai-completionsapiKey22
34vercel-ai-gatewayVercel AI Gatewayanthropic-messagesapiKey240
35xaixAIopenai-responsesapiKey + oauth4
36xiaomiXiaomiopenai-completionsapiKey6
37xiaomi-token-plan-amsXiaomi Token Plan AMSopenai-completionsapiKey4
38xiaomi-token-plan-cnXiaomi Token Plan CNopenai-completionsapiKey4
39xiaomi-token-plan-sgpXiaomi Token Plan SGPopenai-completionsapiKey4
40zaiZ.AIopenai-completionsapiKey7
41zai-coding-cnZ.AI Coding CNopenai-completionsapiKey4

三个观察值得展开:

  1. 协议高度收敛。41 家里有 20 家只用 openai-completions。KnownApi 一共只列了 10 种协议(packages/ai/src/types.ts:17-27),而 KnownProvider 列了 41 个(types.ts:35-75),和 builtinProviders() 返回的 41 个 id 一一对应——协议数和厂商数差了四倍,这正是「统一模型接口」这个包成立的前提。
  2. 静态目录与注册表一样多,但有一条注释没跟上。生成的目录 MODELS 也是 41 个键,radius 也在其中(它的 28 条静态模型就来自这里)。可 all.ts:51-53 的注释还写着「KnownProvider 额外包含没有静态目录条目的纯动态 Provider(例如 radius)」。源码里的注释也会过时:本章实践任务会让你亲手量出这三个 41,再对照这段注释。
  3. 数量不等于可用。上表是「Pi 知道有这些模型」,不是「你现在能用」。没有任何凭据时,Pi 给出的提示来自 packages/coding-agent/src/core/auth-guidance.ts:6-16,第一行是:
text
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 行,且明确写着「不要手改」(源码事实):

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 },
  "promptCache": { "short": 300, "long": 3600 },
  "inputLimits": { "maxRequestBytes": 33554432, "images": { "maxPerRequest": 100, "resize": { "…": "省略" } } } }

除了价格和窗口,记录里还有提示词缓存的两档时长(promptCache,单位秒)和单次请求的输入上限(inputLimits,含图片张数与缩放规则)。resize 的四个字段这里省略了。

第二层是聚合文件 models.generated.ts:41 个 import,一个 MODELS 常量,没有任何逻辑。

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

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

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

写出的三类产物分别是: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: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 把 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.

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

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

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

秘密全在 lazyApi 里:

ts
// 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;
}
earendil-works/pi@16787ad第 73–98 行在 GitHub 查看 ↗
懒加载包装:返回一个满足 ProviderStreams 契约的对象,真正的模块直到第一次调用 stream 才被动态 import;第二个参数声明是否也要包装延迟响应的 fetchDeferred/cancelDeferred。

第二个参数 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):

text
  --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 用的是「先整串、失败再剥最后一个冒号」的递归策略:

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

注意第 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 参数,所以在这条路径上真正起作用的只有后三级。

⚠️ 常见误解以为 --provider 的默认值真的是 google
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:

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

具体哪个 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() 再包一层:

earendil-works/pi@16787ad第 173–218 行在 GitHub 查看 ↗
Pi 侧的模型运行时:取内置 Provider 列表,除 radius 外统统包上远程目录刷新能力(withRemoteCatalog),为 models.json 里声明 oauth 为 radius 的网关另建 Radius Provider,叠加自定义配置后构建 Models 集合,最后做一次刷新。

ModelRuntime 的 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 数量并解剖最短的一个

目标:不靠本书的表格,自己量出内置 Provider 数量和静态目录数量,并用源码判断 all.ts 里一段注释是否还成立;再完整读懂一个 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. 对照注释与事实:先读 sed -n '51,53p' packages/ai/src/providers/all.ts,再跑 grep -n radius packages/ai/src/models.generated.ts,看 radius 是否真的「没有静态目录条目」
  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 步输出 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-1738
    • packages/ai/src/api/lazy.ts:46-98
    • packages/coding-agent/src/core/model-resolver.ts:88/136/204/406/622
    • packages/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)。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 因为 auth 同时是「这个 Provider 现在可不可用」的唯一信号。envApiKeyAuth 解析不到凭据时返回 undefined,findInitialModel 和 resolveCliModel 正是靠这个来判断「哪些 provider 已经配好了认证」,从而挑出默认模型、以及在 /login 界面里显示哪些可选。如果允许「没有密钥就不写 auth」,这个信号就成了「字段缺失」和「字段存在但解析不出」两种含义混在一起,调用方无从区分「这家不需要认证」和「这家没配好」。
  2. 先看第一个斜杠前缀 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 的一部分。
  3. 至少三处:① 新建 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 的映射表。
  4. 因为它是机器生成的——文件头两行就写着「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。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)