Skip to content

7.4 自定义 Provider ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:Pi 内置了 41 个 Provider(模型服务提供方;按 pi-ai 的 builtinProviders() 返回的数组计),但你公司的模型走私有网关、你本机跑着 llama.cpp、你想在无 API Key 的情况下教学演示——这些内置清单都装不下。本章讲清楚扩展(Extension)如何用 pi.registerProvider() 把一个新的模型来源注册进 Pi,以及这次注册在源码里一路落到了哪里。 前置知识:6.1 pi-ai:统一的模型接口、6.2 Provider 与模型注册、7.1 Extension 系统。 学习目标:读完后你能 ① 说清 registerProvider 两个重载各自适合什么场景;② 逐段读懂 examples/extensions/custom-provider-anthropic 这个官方示例,包括它为什么要自造一个 api 名字;③ 完整复述「扩展调用 → 排队 → flush → ModelRuntime → 重新合成 → 写进 Models 集合」这条注册链;④ 说出请求时刻自定义 streamSimple 是在哪一层被挑中的;⑤ 亲手用 8 行代码注册一个零 API Key 的假 Provider,并在真实 pi 进程里跑通一次对话。

建立直觉:内置清单装不下的四种情况 ​

6.2 讲过,一个 Provider 是「api 实现 + 模型清单 + 认证」三件事的打包,内置的 41 个写死在 packages/ai/src/providers/all.ts:90-134 的 builtinProviders() 里(数组元素在 92-132 行,一行一个)。但有四类需求,无论怎么改那个数组都解决不了——因为它们是每个使用者各不相同的:

  1. 私有网关 / 代理:公司要求所有模型请求走统一出口,加一个审计用的 header,模型和协议都不变,只换 baseUrl。
  2. 本地或自建模型:llama.cpp、vLLM、LM Studio 跑在 127.0.0.1 上,模型清单要运行时问服务器才知道,写死在仓库里没有意义。
  3. 完全非标准的 API:厂商既不兼容 Anthropic 也不兼容 OpenAI,或者有奇怪的握手方式,必须自己写一遍「HTTP 响应 → 统一事件流」的转换。
  4. 教学与测试用的假模型:不联网、不要 Key,但要走完整的 Agent Loop(Agent 循环)。

Pi 的配置文件 models.json 能解决第 1 类(换 URL、加 header、改模型元数据),但解决不了第 2、3、4 类——配置文件里写不了代码。所以 Pi 把第二条路开在扩展里:扩展是一个会被真正执行的 TypeScript 模块,它可以发 HTTP 请求探测模型清单,可以实现 OAuth 登录流程,也可以自己实现整套流式转换。

🌱 初学者提示先确认你需要的是哪一层
只想换个 baseUrl 或加个 header,优先改 models.json(见 6.8 配置系统),不必写扩展。需要「跑代码才能决定」的东西——动态模型清单、自定义登录、自定义协议——才轮到本章。

API 面:registerProvider 的两个重载 ​

扩展拿到的 pi 对象上,与 Provider 有关的方法只有两个:registerProvider 和 unregisterProvider。前者有两个重载(源码事实):

earendil-works/pi@16787ad第 1604–1605 行在 GitHub 查看 ↗
两个重载:传一个完整的 pi-ai Provider 对象(原生形态),或者传「名字 + 配置对象」(配置形态)。

两者的分工,官方文档 packages/coding-agent/docs/custom-provider.md 的 Quick Reference 一节说得很直接(官方文档说明):需要自定义认证、过滤、刷新或流行为时,优先用完整的 Provider;配置形态被它称作 "legacy provider-config form"。

重载 A:原生 Provider。参数就是 6.2 讲过的那个 Provider interface(packages/ai/src/models.ts:99-156)——你自己用 createProvider() 造好,或者手写一个对象字面量,Pi 直接收下。Pi 仓库自己的 llama.cpp 支持就走这条路(后面会看)。

重载 B:名字 + ProviderConfig。这是一张「填空表」,Pi 负责把它翻译成 Provider。字段全集在这里(源码事实,packages/coding-agent/src/core/extensions/types.ts:1631-1681,与内部使用的 ProviderConfigInput 一一对应,packages/coding-agent/src/core/provider-composer.ts:46-77):

字段作用
name界面上显示的 Provider 名称
baseUrl接口地址;定义模型时必填
apiKey支持 $ENV_VAR / ${ENV_VAR} 插值,或 !command 取命令输出
api用哪套 wire protocol(网络协议),如 openai-completions
streamSimple自定义流实现;用它就必须同时给 api;收到的上下文是规范化后的 TranscriptContext(见下一节)
headers / authHeader自定义请求头 / 自动加 Authorization: Bearer
models模型清单;一旦提供就整体替换该 Provider 的原有模型
refreshModels运行时刷新模型清单
oauthlogin / refreshToken / getApiKey 三件套,接入 /login

三个字段的语义值得单独记:models 是替换而不是追加;只给 baseUrl 不给 models 时,原有模型全部保留、只换地址(applyExtension,packages/coding-agent/src/core/provider-composer.ts:253-254);streamSimple 没有配 api 会在注册时直接抛错(validateExtensionProvider,provider-composer.ts:452-454)。

unregisterProvider(name) 是它的反操作:移除该 Provider 的扩展层,并让被覆盖的内置模型恢复(model-runtime.ts:791-797)。

逐段走读:custom-provider-anthropic ​

官方示例 packages/coding-agent/examples/extensions/custom-provider-anthropic/ 是配置形态的完整样板:617 行的 index.ts,加上一个 19 行的 package.json。后者用的是 7.1 讲过的 pi 清单形态:

json
// examples/extensions/custom-provider-anthropic/package.json(节选)
"pi": { "extensions": ["./index.ts"] },
"dependencies": { "@anthropic-ai/sdk": "0.52.0" }

它需要自己的 node_modules(.gitignore 里只有 node_modules/),所以用之前要先在该目录 npm install——文件头注释第 12-13 行写了这一步。

整个文件分成四段,我们从后往前读,因为最后 37 行才是主干:

ts
// examples/extensions/custom-provider-anthropic/index.ts:581-617(节选)
export default function (pi: ExtensionAPI) {
	pi.registerProvider("custom-anthropic", {
		baseUrl: "https://api.anthropic.com",
		apiKey: "$CUSTOM_ANTHROPIC_API_KEY",
		api: "custom-anthropic-api",

		models: [
			// …(省略:两个模型定义,各含 id/name/reasoning/input/cost/contextWindow/maxTokens)
		],

		oauth: {
			name: "Custom Anthropic (Claude Pro/Max)",
			login: loginAnthropic,
			refreshToken: refreshAnthropicToken,
			getApiKey: (cred) => cred.access,
		},

		streamSimple: streamCustomAnthropic,
	});
}
earendil-works/pi@16787ad第 581–617 行在 GitHub 查看 ↗
示例扩展的全部主干:一个默认导出的工厂函数,函数体里只有一次 registerProvider 调用。上面几百行都是被它引用的实现细节。

这里最容易被读漏的是 api: "custom-anthropic-api" 这一行。它不是 KnownApi 里的任何一个已知协议名,而是这个扩展自己造的一个标识符。为什么要造?因为 streamSimple 和 api 是绑定的:Pi 在请求时会拿 model.api 去和配置里的 api 比对,相同才调用你的 streamSimple(下一节会看到这行代码)。换句话说,api 字段在这里的角色是「路由键」而不是「协议名」——你自造一个不与内置协议重名的键,就能保证只有你的模型走你的实现。

再往前看第三段,streamCustomAnthropic 的骨架(源码事实):

ts
// examples/extensions/custom-provider-anthropic/index.ts:338-372 与 :563-574(节选,控制流未改)
function streamCustomAnthropic(
	model: Model<Api>,
	context: TranscriptContext,
	options?: SimpleStreamOptions,
): AssistantMessageEventStream {
	const stream = createAssistantMessageEventStream();
	// The transcript carries the prompt and tools in its system messages. This provider sends
	// one top-level system prompt, so fold later system messages into the leading one first.
	const transcript = collapseSystemMessages(context);
	const systemPrompt = getCurrentSystemPrompt(transcript.messages);
	const tools = getCurrentTools(transcript.messages);

	(async () => {
		const output: AssistantMessage = {
			role: "assistant",
			content: [],
			// …(省略:api/provider/model/usage 字段初始化)
			stopReason: "pending",
			timestamp: Date.now(),
		};
		try {
			const apiKey = options?.apiKey ?? "";
			// …(省略:建 Anthropic client、拼 params、for await 消费 SDK 流并 push 各类事件)
			stream.push({ type: "done", reason: output.stopReason, message: output });
			stream.end();
		} catch (error) {
			// …(省略:清掉内容块上的临时 index 字段)
			output.stopReason = options?.signal?.aborted ? "aborted" : "error";
			output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
			stream.push({ type: "error", reason: output.stopReason, error: output });
			stream.end();
		}
	})();
	return stream;
}

这个骨架和 6.1 讲的 StreamFunction 契约完全一致:同步返回流对象,异步往里推事件,任何错误都编码成流内的 error 事件而不是 throw。options?.apiKey 这个字段值得注意——扩展不需要自己去读环境变量或凭据文件,apiKey: "$CUSTOM_ANTHROPIC_API_KEY" 那行配置会由 Pi 解析好,通过 options 传进来(解析逻辑在 provider-composer.ts:380-403 的 resolve)。

系统提示词和工具不在 context 的顶层字段里 ​

骨架开头那三行是自定义 Provider 最容易写错的地方。流函数收到的 context 类型是 TranscriptContext(packages/ai/src/types.ts:631-634):它只有一个 messages 数组,没有 systemPrompt、也没有 tools 字段。系统提示词和工具声明都以系统消息(role: "system" 的 SystemMessage,types.ts:491-506)的形式放在 messages 里——开头一条给出初始提示词与工具,对话中途还可以再插入系统消息,补充提示词片段、增加或撤掉工具(toolsAdded / toolsRemoved)。

这个形状由 pi-ai 保证:调用方交给 Models.stream / streamSimple 的是公开入口接受的 Context(systemPrompt + messages + tools,packages/ai/src/types.ts:617-621),Models 先用 normalizeContext 把 systemPrompt 和 tools 折成一条开头的系统消息,再把结果交给 Provider(packages/ai/src/models.ts:703-710;normalizeContext 在 packages/ai/src/utils/transcript.ts:30-34,注释写明它是产生 TranscriptContext 的唯一入口,类型上还带了一个品牌字段,原始 Context 没法被误传进 Provider 代码)。所以 Provider 代码只会见到规范化后的形状,要读提示词和工具,就用 pi-ai 导出的两个回放函数:

  • getCurrentSystemPrompt(messages):按顺序回放所有系统消息,拼出「此刻」的完整提示词文本(transcript.ts:99-102);
  • getCurrentTools(messages):按顺序应用每条系统消息的 toolsRemoved / toolsAdded,得到「此刻」可用的工具清单(transcript.ts:58-66)。

如果目标 API 只接受请求顶层的一段系统提示词(Anthropic Messages 就是这样),还要先调 collapseSystemMessages(context),把所有系统消息回放成开头一条、并删掉后面的系统消息(transcript.ts:108-112),否则中途插入的系统消息会被原样混进对话里。示例正是这么做的,官方文档的 Custom Streaming API 一节也把这三步写成了标准模板(来源文件:packages/coding-agent/docs/custom-provider.md:409-433)。ProviderConfig.streamSimple 的类型注释里还有两条硬性要求:发请求前要调用 options.onPayload 并采用它返回的替换 payload,收到响应、开始读 body 之前要调用 options.onResponse,与内置 Provider 保持一致(packages/coding-agent/src/core/extensions/types.ts:1640-1651)——扩展的 before_provider_request / after_provider_response 事件就是靠这两个回调接进来的。

前两段分别是 OAuth 实现(第 51-157 行:PKCE 生成、授权 URL、粘贴 code 换 token、refresh)和消息转换(第 159-336 行:convertMessages / convertTools / mapStopReason,把 Pi 的统一消息翻译成 Anthropic SDK 的形状)。这两段的逻辑在 6.1 解剖 anthropic-messages.ts 时已经讲过同类实现,示例文件第 160 行的注释也明说它是 "simplified from packages/ai/src/api/anthropic-messages.ts"。

⚠️ 常见误解以为示例里的几百行都是「注册 Provider 必需的」
不是。真正的 API 面只有最后那次 pi.registerProvider(...)。OAuth 段、消息转换段、流实现段之所以长,是因为这个示例选择了「自造协议」这条最重的路。如果你只是换网关地址,扩展可以短到三行:pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" })。姊妹示例 custom-provider-gitlab-duo/index.ts(405 行,注册在第 383 行)是同样的结构。

落点:这次调用一路走到了哪里 ​

pi.registerProvider 被调用之后,模型是怎么真的出现在 /model 列表里的?这条链有六站,我们按顺序走。

第一站,pi 对象上的实现——它只做一件事:按参数类型分流。

ts
// packages/coding-agent/src/core/extensions/loader.ts:421-429(全文)
		registerProvider(providerOrName: Provider | string, config?: ProviderConfig) {
			assertActive();
			if (typeof providerOrName === "string") {
				if (!config) throw new Error("Provider config is required when registering by name");
				applyRuntimeChange(() => runtime.registerProvider(providerOrName, config, extension.path));
				return;
			}
			applyRuntimeChange(() => runtime.registerNativeProvider(providerOrName, extension.path));
		},
earendil-works/pi@16787ad第 421–429 行在 GitHub 查看 ↗
两个重载在运行时用 typeof 分流:字符串走配置形态 registerProvider,对象走原生形态 registerNativeProvider。两者都记下 extension.path 以便出错时定位,并且都包在 applyRuntimeChange 里。

applyRuntimeChange(loader.ts:244-247)是 7.1 讲过的加载事务的一部分:工厂函数还在执行时,这次调用只被记进本扩展的暂存列表;工厂成功返回、commit 时才真正调用 runtime.registerProvider(loader.ts:452-461);工厂中途抛错则整批丢弃。工厂执行完之后再调用(比如在命令处理函数里),就直接执行。

第二站,加载期的排队。7.1 讲过扩展的两阶段初始化:工厂函数执行时,AgentSession 还没把真实实现绑进来,所以 ExtensionRuntime 里装的全是「抛错 stub」。但 Provider 注册是个例外——它不抛错,而是排队:

earendil-works/pi@16787ad第 204–211 行在 GitHub 查看 ↗
加载期的 registerProvider 只把参数压进队列。注释写明意图:等 bindCore 拿到模型注册表后再统一 flush。

第三站,bindCore 时的 flush。ExtensionRunner.bindCore 拿到真实实现后,把两条队列逐条重放,每条单独 try/catch——某个扩展的 Provider 配置写坏了,只会上报一条 register_provider 错误,不会拖垮其它扩展:

earendil-works/pi@16787ad第 442–459 行在 GitHub 查看 ↗
flush 配置形态的排队注册;紧随其后的 460-476 行对原生 Provider 做同样的事。之后 runtime 上的方法被替换成直通实现,注册立即生效、不需要 /reload。

第四站,AgentSession 注入的真实动作。bindCore 的第三个参数就是这组 Provider 动作,由 AgentSession._bindExtensionCore 提供:

earendil-works/pi@16787ad第 3127–3140 行在 GitHub 查看 ↗
三个动作都是「转发给 ModelRuntime + 刷新当前选中模型」。第二步不能少:如果你覆盖的正是当前正在用的 Provider,模型对象需要跟着换成新合成的那个。

第五站,ModelRuntime——真正的登记处:

earendil-works/pi@16787ad第 753–789 行在 GitHub 查看 ↗
先 validateExtensionProvider 独立校验(坏的重复注册必须抛错且不污染已存配置),再与上次注册做「已定义值覆盖、未定义值保留」的合并,最后重新合成并更新模型快照。

第六站,重新合成。recomposeProvider 是三层叠加的收口处:

earendil-works/pi@16787ad第 246–268 行在 GitHub 查看 ↗
没有任何覆盖层时直接用内置 Provider 原件(保证 auth/login/stream 行为分毫不差);有覆盖层时调 composeModelProvider 合成;合成抛错则记录 compositionErrors 并回退到内置件。

最后一行 this.models.setProvider(...) 把合成结果写进 pi-ai 的 Models 集合——从这一刻起,--model custom-anthropic/claude-sonnet-4-5 就能解析到了。

完整调用链(每环 path:line,均为本书作者逐条 Read 复核):

  1. 扩展工厂调用 pi.registerProvider(packages/coding-agent/src/core/extensions/loader.ts:421)
  2. → 工厂成功返回后 commit 应用暂存项(loader.ts:452-461),加载期排队 runtime.pendingProviderRegistrations.push(loader.ts:206-208)
  3. → ExtensionRunner.bindCore flush(packages/coding-agent/src/core/extensions/runner.ts:442-459)
  4. → providerActions.registerProvider(packages/coding-agent/src/core/agent-session.ts:3128-3131)
  5. → ModelRuntime.registerProvider(packages/coding-agent/src/core/model-runtime.ts:753)
  6. → recomposeProvider(model-runtime.ts:246)→ composeModelProvider(packages/coding-agent/src/core/provider-composer.ts:459)
  7. → models.setProvider(...)(model-runtime.ts:261),终点是 pi-ai 的 Models 集合(packages/ai/src/models.ts:237-242 的 MutableModels)

图 7.4-1 registerProvider 的注册链
从上往下读。请重点关注中间那个菱形分支:同一个 API 在「扩展加载期」与「运行期」走两条不同的路——前者排队、后者直通,这是 Pi 能让扩展在命令处理函数里临时换 Provider 而不用 /reload 的原因。图中每个方框都对应本节列出的一站源码位置。

三层叠加:你的配置不是唯一的一层 ​

composeModelProvider 的名字里那个 "compose" 不是修辞。同一个 Provider id 上可能同时存在三层来源——内置件、models.json、扩展,这是函数自己的文档注释给出的说法(provider-composer.ts:458 的注释:"Compose built-in, models.json, and extension layers without reading credentials.")。实际执行时,这三层在 getModels 里被拆成五个先后步骤,顺序是固定的(源码事实,provider-composer.ts:472-485 的 getModels,以及 470-471 行说明「modelOverrides 是最顶层用户配置」的注释):

图 7.4-2 一个 Provider 的模型清单叠加顺序
从左到右就是覆盖的先后顺序,越靠右优先级越高。关注两端:起点是内置清单,终点是用户在 models.json 里写的 modelOverrides——也就是说,用户配置能覆盖扩展,扩展不能把用户锁死。中间那步「扩展的 models 整体替换」对应 applyExtension,provider-composer.ts:247-274;倒数第二步的 modifyModels 只有在扩展配了 oauth.modifyModels 且已有 OAuth 凭据时才会执行,provider-composer.ts:478-480。

从源码结构看,这个顺序表达了一个明确的优先级判断:内置是默认值,扩展是程序化的定制,用户手写的配置永远在最上面。

请求时刻:你的 streamSimple 是怎么被挑中的 ​

注册只是让模型出现在清单里。真正发请求时,合成出来的 Provider 的 stream/streamSimple 会做一次三级分派:

ts
// packages/coding-agent/src/core/provider-composer.ts:493-513(节选)
	const streamWith = (
		model: Model<Api>,
		context: TranscriptContext,
		options: StreamOptions | undefined,
		simple: boolean,
	): AssistantMessageEventStream =>
		lazyStream(model, async () => {
			if (extension?.streamSimple && model.api === extension.api) {
				return extension.streamSimple(model, context, options as SimpleStreamOptions);
			}
			if (base && supportsBaseApi(model)) {
				return simple
					? base.streamSimple(model, context, options as SimpleStreamOptions)
					: base.stream(model, context, options);
			}
			const api = getApiProvider(model.api);
			if (!api) throw new Error(`No API provider registered for api: ${model.api}`);
			// …(省略:按 simple 选择 api.streamSimple 或 api.stream)
		});
earendil-works/pi@16787ad第 493–513 行在 GitHub 查看 ↗
三级分派:扩展自定义实现(要求 model.api 与配置的 api 相同)→ 内置 Provider 的实现(要求它支持这个 api)→ 按 api 名取通用实现。外层 lazyStream 保证同步返回流、异步做 setup。

第一个 if 就是上一节那个悬念的答案:model.api === extension.api。示例扩展自造 "custom-anthropic-api" 这个名字,正是为了让这个等式只对它自己的模型成立。注意三级分派原样转交的 context 已经是 TranscriptContext——合成层不会替你把系统消息还原成 systemPrompt / tools,这件事只能在你的 streamSimple 里用上一节的回放函数完成。

图 7.4-3 请求时刻的三级分派
从上往下读。关注第一个菱形:它是「自定义协议」能生效的唯一开关,两边条件都成立才会走你的代码。最后三条边汇入同一个出口——不管走哪一级,Agent Loop 拿到的都是同一种事件流,这正是 6.1 讲的统一事件协议的价值。图中 streamWith 与 getApiProvider 都在 provider-composer.ts:493-513;B 节点的规范化与认证在 packages/ai/src/models.ts:703-710。

相关测试:packages/coding-agent/test/agent-session-dynamic-provider.test.ts 用五个用例把这条链钉死——扩展工厂顶层注册(第 95 行)、session_start 事件里注册(第 108 行)、加载期注册原生 Provider(第 125 行)、命令时刻注册配置且不需要 reload(第 138 行)、命令时刻注册原生 Provider(第 159 行)。它们的断言方式很值得学:把 session.agent.streamFunction 换成一个只记录 model.baseUrl 就抛错的假函数(第 87-90 行),从而验证「配置真的走到了请求那一刻」。

零 API Key 的教学 Provider:注册一个 faux ​

6.11 SDK 已经证明过:pi-ai 自带的 faux(假)Provider 可以在没有任何 API Key的情况下驱动一次真实的 Agent Loop。那一章是用 SDK 在进程内组装的;本章可以更进一步——把 faux 塞进扩展里,用真实的 pi 命令跑。

代码只有 8 行(fauxProvider() 返回的句柄上有一个现成的 .provider,正好喂给原生重载):

ts
// /tmp/faux-provider-ext.ts
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
	const faux = fauxProvider();
	faux.setResponses([fauxAssistantMessage("Hello from an extension-registered faux provider.")]);
	pi.registerProvider(faux.provider);
}

本书作者在锁定版本上实际运行通过(真实输出见下面的实践任务)。三个细节值得说明:

  • 扩展里 import "@earendil-works/pi-ai" 能直接解析,是因为加载器给 jiti 配了虚拟模块表 / tsconfig paths(表在 packages/coding-agent/src/core/extensions/virtual-modules.ts:14-38,选择策略在 loader.ts:487-501,7.1 已详解)。
  • 用的是原生重载:faux.provider 已经是一个 createProvider() 造好的完整 Provider,包含一个永远成功的假认证解析器(packages/ai/src/providers/faux.ts:691),所以不需要任何 Key。
  • faux 不在 builtinProviders() 返回的 41 个 Provider 里(packages/ai/src/providers/all.ts:90-134 的数组中没有它,6.2 提到过),所以不加 -e 时 faux/faux-1 根本不存在——这正好可以拿来当验证手段。

llama.cpp:仓库里唯一的内置扩展 ​

原生重载不是给外部用户准备的旁路,Pi 自己就在用。仓库里唯一的内置扩展是 llama.cpp 支持:

earendil-works/pi@16787ad第 1–4 行在 GitHub 查看 ↗
全文四行:把 llama.cpp 扩展包装成一个 hidden 的内联扩展。main.ts:568 把它拼在用户扩展之前一起加载。

它的工厂函数第一件事就是 pi.registerProvider(provider.provider)(packages/coding-agent/src/extensions/llama/index.ts:42-44),传的是一个手写的原生 Provider(packages/coding-agent/src/extensions/llama/provider.ts:103-198)。为什么必须用原生重载?因为它要做配置形态表达不了的事:refreshModels 里先读本地缓存、再按需联网问 llama-server 要模型清单(provider.ts:151-195),login 流程里要交互式地问服务器地址、再实际连一次做验证(provider.ts:110-131)。官方文档 packages/coding-agent/docs/llama-cpp.md 从用户视角描述了对应的使用方式(官方文档说明)。

这件事本身是个有用的信号:Pi 把自己的一等能力也放在扩展体系里实现,说明这套 API 面不是玩具。

对照官方文档 ​

官方说明来源:packages/coding-agent/docs/custom-provider.md(786 行)。逐条核对下来与源码一致的要点:

  • "Extensions can register either a complete pi-ai Provider or use the legacy provider-config form"(第 33 行)——与 types.ts:1604-1605 的两个重载一致。
  • "The extension factory can also be async. … pi waits for the factory before startup continues, so the provider is available during interactive startup and to pi --list-models"(第 91 行)——与 loader.ts:547 的 await factory(load.api) 以及 runner.ts:442-476 的 flush 时机一致。
  • "Calls made after the initial extension load phase are applied immediately, so no /reload is required."(第 217 行)——与 runner.ts:478-500 把 runtime 上的三个方法替换成直通实现一致,并有测试覆盖(agent-session-dynamic-provider.test.ts:138、:159)。
  • "When models is provided, it replaces all existing models for that provider."(第 184 行)——与 applyExtension(provider-composer.ts:256-273)一致。
  • "The context is a normalized transcript … read them with getCurrentSystemPrompt(context.messages) and getCurrentTools(context.messages)"(第 409 行)——与 TranscriptContext 的类型定义以及示例 index.ts:346-348 一致。
  • 文档的 "Testing Your Implementation" 一节(第 641-661 行)建议把 packages/ai/test/ 下的 11 个测试文件复制改造成你自己 Provider 的测试集。这是官方给出的、本书没有展开的进阶路径。

需要提醒的一处措辞差异:文档把「名字 + 配置」形态称为 "legacy",但它在源码里完全不是弃用状态——ProviderConfigInput 有独立的校验、合并与合成逻辑,官方两个示例用的也都是这一形态。据此推断(尚未在源码中直接证实),"legacy" 在这里表达的是「先有的那套、能力上是子集」,而不是「即将移除」。

实践任务 ​

🛠 实践任务走读官方示例 + 亲手注册一个零 Key Provider

目标:① 读懂官方 Provider 示例的骨架;② 用 grep 自己找出 registerProvider 的全部处理端;③ 写一个 8 行扩展,在真实 pi 进程里跑通一次不需要任何 API Key 的对话。

前提:本地有 Pi 源码仓库(下面记作 $PI,请用绝对路径),已在仓库根跑过 npm install。全程不修改仓库内任何文件,扩展写在仓库之外。

第一部分 · 走读(纯阅读)

  1. sed -n '1,25p' $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts——文件头注释告诉你怎么用(先 npm install,再 pi -e,然后 /model 选)。
  2. sed -n '581,617p' $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts——37 行的主干。在纸上回答:api 字段的值是不是一个已知协议名?如果不是,它起什么作用?
  3. grep -n "createAssistantMessageEventStream\|stream.push\|stream.end" $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts | head -20——确认流实现遵守「同步返回、异步推送、错误进流」的契约。
  4. grep -n "getCurrentSystemPrompt\|getCurrentTools\|collapseSystemMessages" $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts——确认它从 TranscriptContext 的系统消息里读提示词和工具,而不是读 context.systemPrompt。

第二部分 · grep 处理端

grep -rn "registerProvider" $PI/packages/coding-agent/src --include="*.ts"

预期现象:在锁定版本上共 47 行命中,落在 9 个文件里(按命中条数排)——core/extensions/runner.ts 15 条(flush 与直通替换)、core/extensions/types.ts 11 条(类型与文档注释)、core/model-registry.ts 6 条(旧门面)、core/extensions/loader.ts 6 条(pi 对象上的实现 + 加载期排队)、core/agent-session.ts 4 条(注入真实动作)、core/model-runtime.ts 2 条(登记处)、以及 core/provider-composer.ts、core/agent-session-services.ts、extensions/llama/index.ts 各 1 条。想复现这份统计可以接上 | awk -F: '{print $1}' | sort | uniq -c | sort -rn。如何判断成功:你能只看这份 grep 结果,把本章图 7.4-1 的六站顺序排出来;并且能指出最后那条 extensions/llama/index.ts 属于「调用方」而不是「处理端」。

第三部分 · 注册一个零 Key Provider(真的会跑起来)

把本章那 8 行代码保存为 /tmp/faux-provider-ext.ts,然后执行(三条命令都在同一个空目录里跑;PI_CODING_AGENT_DIR 指向临时目录,以免污染你真实的 ~/.pi):

text
mkdir -p /tmp/faux-run /tmp/faux-agent-dir && cd /tmp/faux-run

# A. 跑一次对话
env PI_CODING_AGENT_DIR=/tmp/faux-agent-dir $PI/pi-test.sh --no-env --offline \
  -e /tmp/faux-provider-ext.ts --model faux/faux-1 -nt --no-session -p "hi"

# B. 确认模型真的进了清单
env PI_CODING_AGENT_DIR=/tmp/faux-agent-dir $PI/pi-test.sh --no-env --offline \
  -e /tmp/faux-provider-ext.ts --list-models faux

# C. 去掉 -e 做对照
env PI_CODING_AGENT_DIR=/tmp/faux-agent-dir $PI/pi-test.sh --no-env --offline \
  --model faux/faux-1 -nt --no-session -p "hi"

预期现象(本书在锁定版本上实际运行,真实输出;Running without API keys... 这行来自 pi-test.sh 的 --no-env):

text
# A
Running without API keys...
Hello from an extension-registered faux provider.

# B
Running without API keys...
provider  model   context  max-out  thinking  images
faux      faux-1  128K     16.4K    no        yes

# C
Running without API keys...
Error: Model "faux/faux-1" not found. Use --list-models to see available models.

如何判断成功:① A 打印的正是你在 setResponses 里写的那句原文——说明它穿过了完整的 Agent Loop,而不是被你自己 console.log 出来的;② B 里 faux/faux-1 出现在模型表中,说明注册链一路走到了 Models 集合;③ C 报「模型不存在」,反证 A、B 的模型确实来自你的扩展而非内置清单;④ 在 Pi 仓库里跑 git status --porcelain,输出为空。

常见错误:① 忘了 -nt,faux 只脚本化了一条纯文本回复,若中途需要第二次模型调用会报 No more faux responses queued(packages/ai/src/providers/faux.ts:513-523);② 忘了 PI_CODING_AGENT_DIR,会读写你真实的 ~/.pi/agent;③ 把 pi.registerProvider(faux.provider) 写成 pi.registerProvider("faux", faux.provider)——那会走配置重载,而 Provider 对象并不满足 ProviderConfig 的形状;④ 忘了 faux.setResponses([...]),队列一开始就是空的,第一次请求就报队列耗尽;⑤ 扩展文件里 export default 的不是函数,加载器会报 "Extension does not export a valid factory function"(loader.ts:569-571)。

对应源码位置:packages/coding-agent/src/core/extensions/loader.ts:421-429、:204-211;packages/coding-agent/src/core/extensions/runner.ts:442-459;packages/coding-agent/src/core/agent-session.ts:3127-3140;packages/coding-agent/src/core/model-runtime.ts:753-789、:246-268;packages/coding-agent/src/core/provider-composer.ts:493-513;packages/ai/src/providers/faux.ts:687-710。-e 与 --list-models 两个参数的帮助文本见 research/cli-captures/pi-help.txt(真实采集)。

本章小结 ​

  • 自定义 Provider 解决四类内置清单装不下的需求:私有网关、本地/自建模型、非标准 API、零 Key 的教学与测试模型。只换 URL 或 header 时优先用 models.json,需要「跑代码」时才写扩展。
  • pi.registerProvider 有两个重载:原生形态(一个完整的 pi-ai Provider,能力最全)与配置形态(名字 + ProviderConfig 填空表)。loader.ts:421-429 用 typeof 分流。
  • 自定义 streamSimple 收到的是 TranscriptContext:系统提示词和工具在系统消息里,用 getCurrentSystemPrompt / getCurrentTools 读,只接受顶层提示词的 API 先 collapseSystemMessages。
  • 配置形态的三条硬规则:models 是整体替换;只给 baseUrl 时原模型全保留、只换地址;给了 streamSimple 就必须给 api(否则注册即抛错)。
  • api 字段在自定义流的场景里是路由键:请求时 model.api === extension.api 才会调用你的 streamSimple(provider-composer.ts:500)。官方示例自造 "custom-anthropic-api" 正是为此。
  • 注册链六站:loader.ts:421 → 加载期排队 loader.ts:206 → runner.ts:442 flush → agent-session.ts:3128 注入动作 → model-runtime.ts:753 登记 → recomposeProvider / composeModelProvider 合成 → models.setProvider。加载期排队、运行期直通,所以在命令处理函数里注册也能立即生效。
  • 合成有明确的优先级:内置 → models.json → 扩展 → modelOverrides,用户手写的配置在最上面。
  • llama.cpp 是仓库里唯一的内置扩展(src/extensions/index.ts,4 行),它用原生重载注册手写 Provider,证明这套 API 面是一等能力。
  • 用 pi-ai 的 faux Provider,8 行扩展就能得到一个零 API Key、可脚本化回复的教学 Provider,并在真实 pi -e 进程里跑通。
  • 关键术语:Provider(模型服务提供方)、ProviderConfig / ProviderConfigInput、原生重载与配置重载、wire protocol(网络协议)、路由键、合成(compose)、两阶段初始化、faux Provider(假模型)。
  • 关键源码索引:
    • packages/coding-agent/src/core/extensions/types.ts:1604-1605(两个重载)、:1631-1681(ProviderConfig)
    • packages/coding-agent/src/core/extensions/loader.ts:421-429(分流)、:204-211(排队)
    • packages/coding-agent/src/core/extensions/runner.ts:442-476(flush)、:478-500(直通替换)
    • packages/coding-agent/src/core/agent-session.ts:3127-3140(注入动作)
    • packages/coding-agent/src/core/model-runtime.ts:753-789(登记)、:246-268(重新合成)、:791-797(注销)
    • packages/coding-agent/src/core/provider-composer.ts:46-77(ProviderConfigInput)、:247-274(applyExtension)、:446-456(校验)、:459-562(composeModelProvider)、:493-513(三级分派)
    • packages/ai/src/types.ts:631-634(TranscriptContext)、packages/ai/src/utils/transcript.ts:30-34(normalizeContext)、:58-66 / :99-112(回放函数)
    • packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts:338-575(自定义流)、:581-617(注册)
    • packages/coding-agent/src/extensions/index.ts:1-4、src/extensions/llama/provider.ts:103-198
    • 测试:packages/coding-agent/test/agent-session-dynamic-provider.test.ts、packages/coding-agent/test/model-registry.test.ts:1106(dynamic provider lifecycle)
  • 自测问题:① 你写了 pi.registerProvider("anthropic", { models: [一个模型] }),内置的其它 Claude 模型还在吗?为什么?② 一个扩展在 session_start 事件里调用 registerProvider,会走排队还是直通?③ 如果你的 streamSimple 里 throw 了异常而不是 push error 事件,会发生什么?(提示:回看 StreamFunction 契约与 lazyStream)④ 为什么 llama.cpp 支持必须用原生重载而不能用配置形态?
  • 下一章:第七部分到此结束,接下来是第八部分 · 动手实现 Mini Harness——把前七部分读到的结构,用几百行代码自己搭一遍。
  • 本章未展开的内容:ProviderConfig.oauth 三件套如何被适配成 pi-ai 的 OAuthAuth(provider-composer.ts:276-295)与凭据的持久化;refreshModels 的 ModelsStore 持久化与离线恢复;compat 字段的完整清单(官方文档 custom-provider.md:219-268 有 API 类型表与 compat 示例);authHeader 与 !command 形式的 API Key 解析;把官方 11 个 Provider 测试文件改造成自己 Provider 测试集的做法。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 不在了。models 的语义是整体替换而不是追加——applyExtension(provider-composer.ts:247-274)会用你给的这一条把 anthropic 原有的模型清单整个换掉,官方文档也明确写着「When models is provided, it replaces all existing models for that provider」。想只改地址、保留原有模型,就别写 models,只给 baseUrl(provider-composer.ts:253-254)。
  2. 直通。排队只发生在扩展加载期——那时 AgentSession 还没把真实实现绑进来,registerProvider 于是把请求推进 pendingProviderRegistrations。等 bindCore 走完 flush(runner.ts:442-476),runtime 上的方法就被替换成直通实现(:478-500)。session_start 发生在这之后,所以那时调用是立即生效的,不需要 /reload——这也是扩展能在命令处理函数里临时换 Provider 的原因。
  3. 违反了 StreamFunction 的契约:失败必须编码进流,不许 throw。后果取决于在哪里 throw。如果是在 streamSimple 同步返回流之前就抛出,合成层外面那层 lazyStream 会兜住:它的 setup 是一个 async 函数,同步异常变成 Promise 拒绝,被 .catch 转成一条 error 事件(packages/ai/src/api/lazy.ts:46-61),调用方看到的是一条 stopReason: "error" 的消息。如果是在已经返回流之后、那段异步 IIFE 里抛出,就没人接得住了:从源码结构看,它会变成一次无人处理的 Promise 拒绝,而你返回的那个流既收不到 done 也收不到 error,Agent Loop 会一直等下去。所以正确做法是像示例那样把整段异步逻辑包进 try/catch,在 catch 里 push error 事件并 end()。
  4. 因为它要做的事配置形态表达不了:refreshModels 里要先读本地缓存、再按需联网问 llama-server 要模型清单(模型清单只有运行时才知道),login 流程里要交互式地问用户服务器地址、再实际连一次做验证。这些都是行为而不是数据,只能写成代码交给原生重载。反过来说,这也证明了原生重载不是给外部用户准备的旁路——Pi 自己唯一的内置扩展就走这条路。

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