7.4 自定义 Provider
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:Pi 内置了 38 个 Provider(模型服务提供方),但你公司的模型走私有网关、你本机跑着 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 实现 + 模型清单 + 认证」三件事的打包,内置的 38 个写死在 packages/ai/src/providers/all.ts:87-127 的数组里。但有四类需求,无论怎么改那个数组都解决不了——因为它们是每个使用者各不相同的:
- 私有网关 / 代理:公司要求所有模型请求走统一出口,加一个审计用的 header,模型和协议都不变,只换
baseUrl。 - 本地或自建模型:llama.cpp、vLLM、LM Studio 跑在
127.0.0.1上,模型清单要运行时问服务器才知道,写死在仓库里没有意义。 - 完全非标准的 API:厂商既不兼容 Anthropic 也不兼容 OpenAI,或者有奇怪的握手方式,必须自己写一遍「HTTP 响应 → 统一事件流」的转换。
- 教学与测试用的假模型:不联网、不要 Key,但要走完整的 Agent Loop(Agent 循环)。
Pi 的配置文件 models.json 能解决第 1 类(换 URL、加 header、改模型元数据),但解决不了第 2、3、4 类——配置文件里写不了代码。所以 Pi 把第二条路开在扩展里:扩展是一个会被真正执行的 TypeScript 模块,它可以发 HTTP 请求探测模型清单,可以实现 OAuth 登录流程,也可以自己实现整套流式转换。
models.json(见 6.8 配置系统),不必写扩展。需要「跑代码才能决定」的东西——动态模型清单、自定义登录、自定义协议——才轮到本章。 API 面:registerProvider 的两个重载
扩展拿到的 pi 对象上,与 Provider 有关的方法只有两个:registerProvider 和 unregisterProvider。前者有两个重载(源码事实):
registerProvider两者的分工,官方文档 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:75-120)——你自己用 createProvider() 造好,或者手写一个对象字面量,Pi 直接收下。Pi 仓库自己的 llama.cpp 支持就走这条路(后面会看)。
重载 B:名字 + ProviderConfig。这是一张「填空表」,Pi 负责把它翻译成 Provider。字段全集在这里(源码事实,packages/coding-agent/src/core/extensions/types.ts:1427-1464,与内部使用的 ProviderConfigInput 一一对应,packages/coding-agent/src/core/provider-composer.ts:44-68):
| 字段 | 作用 |
|---|---|
name | 界面上显示的 Provider 名称 |
baseUrl | 接口地址;定义模型时必填 |
apiKey | 支持 $ENV_VAR / ${ENV_VAR} 插值,或 !command 取命令输出 |
api | 用哪套 wire protocol(网络协议),如 openai-completions |
streamSimple | 自定义流实现;用它就必须同时给 api |
headers / authHeader | 自定义请求头 / 自动加 Authorization: Bearer |
models | 模型清单;一旦提供就整体替换该 Provider 的原有模型 |
refreshModels | 运行时刷新模型清单 |
oauth | login / refreshToken / getApiKey 三件套,接入 /login |
三个字段的语义值得单独记:models 是替换而不是追加;只给 baseUrl 不给 models 时,原有模型全部保留、只换地址(applyExtension,packages/coding-agent/src/core/provider-composer.ts:206-209);streamSimple 没有配 api 会在注册时直接抛错(validateExtensionProvider,provider-composer.ts:405-407)。
unregisterProvider(name) 是它的反操作:移除该 Provider 的扩展层,并让被覆盖的内置模型恢复(model-runtime.ts:588-594)。
逐段走读:custom-provider-anthropic
官方示例 packages/coding-agent/examples/extensions/custom-provider-anthropic/ 是配置形态的完整样板:610 行的 index.ts,加上一个 19 行的 package.json。后者用的是 7.1 讲过的 pi 清单形态:
// 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 行写了这一步。
整个文件分成四段,我们从后往前读,因为最后 36 行才是主干:
// examples/extensions/custom-provider-anthropic/index.ts:574-610(节选)
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,
});
}pi.registerProvider这里最容易被读漏的是 api: "custom-anthropic-api" 这一行。它不是 KnownApi 里的任何一个已知协议名,而是这个扩展自己造的一个标识符。为什么要造?因为 streamSimple 和 api 是绑定的:Pi 在请求时会拿 model.api 去和配置里的 api 比对,相同才调用你的 streamSimple(下一节会看到这行代码)。换句话说,api 字段在这里的角色是「路由键」而不是「协议名」——你自造一个不与内置协议重名的键,就能保证只有你的模型走你的实现。
再往前看第三段,streamCustomAnthropic 的骨架(源码事实):
// examples/extensions/custom-provider-anthropic/index.ts:334-361 与 :556-568(节选,控制流未改)
function streamCustomAnthropic(
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();
(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:333-355 的 resolve)。
前两段分别是 OAuth 实现(第 52-153 行:PKCE 生成、授权 URL、粘贴 code 换 token、refresh)和消息转换(第 159-332 行:convertMessages / convertTools / mapStopReason,把 Pi 的统一消息翻译成 Anthropic SDK 的形状)。这两段的逻辑在 6.1 解剖 anthropic-messages.ts 时已经讲过同类实现,示例文件第 156 行的注释也明说它是 "simplified from packages/ai/src/api/anthropic-messages.ts"。
pi.registerProvider(...)。OAuth 段、消息转换段、流实现段之所以长,是因为这个示例选择了「自造协议」这条最重的路。如果你只是换网关地址,扩展可以短到三行:pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com" })。姊妹示例 custom-provider-gitlab-duo/index.ts(404 行,注册在第 382 行)是同样的结构。 落点:这次调用一路走到了哪里
pi.registerProvider 被调用之后,模型是怎么真的出现在 /model 列表里的?这条链有六站,我们按顺序走。
第一站,pi 对象上的实现——它只做一件事:按参数类型分流。
// packages/coding-agent/src/core/extensions/loader.ts:376-384(全文)
registerProvider(providerOrName: Provider | string, config?: ProviderConfig) {
runtime.assertActive();
if (typeof providerOrName === "string") {
if (!config) throw new Error("Provider config is required when registering by name");
runtime.registerProvider(providerOrName, config, extension.path);
return;
}
runtime.registerNativeProvider(providerOrName, extension.path);
},registerProvider第二站,加载期的排队。7.1 讲过扩展的两阶段初始化:工厂函数执行时,AgentSession 还没把真实实现绑进来,所以 ExtensionRuntime 里装的全是「抛错 stub」。但 Provider 注册是个例外——它不抛错,而是排队:
pendingProviderRegistrations第三站,bindCore 时的 flush。ExtensionRunner.bindCore 拿到真实实现后,把两条队列逐条重放,每条单独 try/catch——某个扩展的 Provider 配置写坏了,只会上报一条 register_provider 错误,不会拖垮其它扩展:
pendingProviderRegistrations第四站,AgentSession 注入的真实动作。bindCore 的第三个参数就是这组 Provider 动作,由 AgentSession._bindExtensionCore 提供:
registerNativeProvider第五站,ModelRuntime——真正的登记处:
registerProvider第六站,重新合成。recomposeProvider 是三层叠加的收口处:
recomposeProvider最后一行 this.models.setProvider(...) 把合成结果写进 pi-ai 的 Models 集合——从这一刻起,--model custom-anthropic/claude-sonnet-4-5 就能解析到了。
完整调用链(每环 path:line,均为本书作者逐条 Read 复核):
- 扩展工厂调用
pi.registerProvider(packages/coding-agent/src/core/extensions/loader.ts:376) - → 加载期排队
runtime.pendingProviderRegistrations.push(loader.ts:210-212) - →
ExtensionRunner.bindCoreflush(packages/coding-agent/src/core/extensions/runner.ts:352-369) - →
providerActions.registerProvider(packages/coding-agent/src/core/agent-session.ts:2439-2442) - →
ModelRuntime.registerProvider(packages/coding-agent/src/core/model-runtime.ts:550) - →
recomposeProvider(model-runtime.ts:202)→composeModelProvider(packages/coding-agent/src/core/provider-composer.ts:412) - →
models.setProvider(...)(model-runtime.ts:217),终点是 pi-ai 的Models集合(packages/ai/src/models.ts:189-194的MutableModels)
图 7.4-1 registerProvider 的注册链
从上往下读。请重点关注中间那个菱形分支:同一个 API 在「扩展加载期」与「运行期」走两条不同的路——前者排队、后者直通,这是 Pi 能让扩展在命令处理函数里临时换 Provider 而不用 /reload 的原因。图中每个方框都对应本节列出的一站源码位置。
三层叠加:你的配置不是唯一的一层
composeModelProvider 的名字里那个 "compose" 不是修辞。同一个 Provider id 上可能同时存在三层来源——内置件、models.json、扩展,这是函数自己的文档注释给出的说法(provider-composer.ts:411 的注释:"Compose built-in, models.json, and extension layers without reading credentials.")。实际执行时,这三层在 getModels 里被拆成五个先后步骤,顺序是固定的(源码事实,provider-composer.ts:425-438 的 getModels,以及 423-424 行说明「modelOverrides 是最顶层用户配置」的注释):
图 7.4-2 一个 Provider 的模型清单叠加顺序
从左到右就是覆盖的先后顺序,越靠右优先级越高。关注两端:起点是内置清单,终点是用户在 models.json 里写的 modelOverrides——也就是说,用户配置能覆盖扩展,扩展不能把用户锁死。中间那步「扩展的 models 整体替换」对应 applyExtension,provider-composer.ts:201-228;倒数第二步的 modifyModels 只有在扩展配了 oauth.modifyModels 且已有 OAuth 凭据时才会执行,provider-composer.ts:431-433。
从源码结构看,这个顺序表达了一个明确的优先级判断:内置是默认值,扩展是程序化的定制,用户手写的配置永远在最上面。
请求时刻:你的 streamSimple 是怎么被挑中的
注册只是让模型出现在清单里。真正发请求时,合成出来的 Provider 的 stream/streamSimple 会做一次三级分派:
// packages/coding-agent/src/core/provider-composer.ts:446-466(节选)
const streamWith = (
model: Model<Api>,
context: Context,
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)
});streamWith第一个 if 就是上一节那个悬念的答案:model.api === extension.api。示例扩展自造 "custom-anthropic-api" 这个名字,正是为了让这个等式只对它自己的模型成立。
图 7.4-3 请求时刻的三级分派
从上往下读。关注第一个菱形:它是「自定义协议」能生效的唯一开关,两边条件都成立才会走你的代码。最后三条边汇入同一个出口——不管走哪一级,Agent Loop 拿到的都是同一种事件流,这正是 6.1 讲的统一事件协议的价值。图中 streamWith 与 getApiProvider 都在 provider-composer.ts:446-466。
相关测试: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,正好喂给原生重载):
// /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/loader.ts:48-72与:413-424,7.1 已详解)。 - 用的是原生重载:
faux.provider已经是一个createProvider()造好的完整 Provider,包含一个永远成功的假认证解析器(packages/ai/src/providers/faux.ts:527),所以不需要任何 Key。 - faux 不在
builtinProviders()返回的 38 个 Provider 里(packages/ai/src/providers/all.ts:87-127的数组中没有它,6.2 提到过),所以不加-e时faux/faux-1根本不存在——这正好可以拿来当验证手段。
llama.cpp:仓库里唯一的内置扩展
原生重载不是给外部用户准备的旁路,Pi 自己就在用。仓库里唯一的内置扩展是 llama.cpp 支持:
builtInExtensions它的工厂函数第一件事就是 pi.registerProvider(provider.provider)(packages/coding-agent/src/extensions/llama/index.ts:42-44),传的是一个手写的原生 Provider(packages/coding-agent/src/extensions/llama/provider.ts:58-134)。为什么必须用原生重载?因为它要做配置形态表达不了的事:refreshModels 里先读本地缓存、再按需联网问 llama-server 要模型清单(provider.ts:113-128),login 流程里要交互式地问服务器地址、再实际连一次做验证(provider.ts:72-93)。官方文档 packages/coding-agent/docs/llama-cpp.md 从用户视角描述了对应的使用方式(官方文档说明)。
这件事本身是个有用的信号:Pi 把自己的一等能力也放在扩展体系里实现,说明这套 API 面不是玩具。
对照官方文档
官方说明来源:packages/coding-agent/docs/custom-provider.md(772 行)。逐条核对下来与源码一致的要点:
- "Extensions can register either a complete pi-ai
Provideror use the legacy provider-config form"(第 33 行)——与types.ts:1400-1401的两个重载一致。 - "The extension factory can also be
async. … pi waits for the factory before startup continues, so the provider is available during interactive startup and topi --list-models"(第 91 行)——与loader.ts:477的await factory(api)以及runner.ts:352-386的 flush 时机一致。 - "Calls made after the initial extension load phase are applied immediately, so no
/reloadis required."(第 217 行)——与runner.ts:388-410把 runtime 上的三个方法替换成直通实现一致,并有测试覆盖(agent-session-dynamic-provider.test.ts:138、:159)。 - "When
modelsis provided, it replaces all existing models for that provider."(第 184 行)——与applyExtension(provider-composer.ts:210-227)一致。 - 文档的 "Testing Your Implementation" 一节(第 635-655 行)建议把
packages/ai/test/下的 11 个测试文件复制改造成你自己 Provider 的测试集。这是官方给出的、本书没有展开的进阶路径。
需要提醒的一处措辞差异:文档把「名字 + 配置」形态称为 "legacy",但它在源码里完全不是弃用状态——ProviderConfigInput 有独立的校验、合并与合成逻辑,官方两个示例用的也都是这一形态。据此推断(尚未在源码中直接证实),"legacy" 在这里表达的是「先有的那套、能力上是子集」,而不是「即将移除」。
实践任务
目标:① 读懂官方 Provider 示例的骨架;② 用 grep 自己找出 registerProvider 的全部处理端;③ 写一个 8 行扩展,在真实 pi 进程里跑通一次不需要任何 API Key 的对话。
前提:本地有 Pi 源码仓库(下面记作 $PI,请用绝对路径),已在仓库根跑过 npm install。全程不修改仓库内任何文件,扩展写在仓库之外。
第一部分 · 走读(纯阅读)
sed -n '1,25p' $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts——文件头注释告诉你怎么用(先 npm install,再pi -e,然后/model选)。sed -n '574,610p' $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts——36 行的主干。在纸上回答:api字段的值是不是一个已知协议名?如果不是,它起什么作用?grep -n "createAssistantMessageEventStream\|stream.push\|stream.end" $PI/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts | head -20——确认流实现遵守「同步返回、异步推送、错误进流」的契约。
第二部分 · 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):
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):
# 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:453-464);② 忘了 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:471-473)。
对应源码位置:packages/coding-agent/src/core/extensions/loader.ts:376-384、:208-215;packages/coding-agent/src/core/extensions/runner.ts:352-369;packages/coding-agent/src/core/agent-session.ts:2438-2451;packages/coding-agent/src/core/model-runtime.ts:550-586、:202-224;packages/coding-agent/src/core/provider-composer.ts:446-466;packages/ai/src/providers/faux.ts:523-541。-e 与 --list-models 两个参数的帮助文本见 research/cli-captures/pi-help.txt(真实采集)。
本章小结
- 自定义 Provider 解决四类内置清单装不下的需求:私有网关、本地/自建模型、非标准 API、零 Key 的教学与测试模型。只换 URL 或 header 时优先用
models.json,需要「跑代码」时才写扩展。 pi.registerProvider有两个重载:原生形态(一个完整的 pi-aiProvider,能力最全)与配置形态(名字 +ProviderConfig填空表)。loader.ts:376-384用typeof分流。- 配置形态的三条硬规则:
models是整体替换;只给baseUrl时原模型全保留、只换地址;给了streamSimple就必须给api(否则注册即抛错)。 api字段在自定义流的场景里是路由键:请求时model.api === extension.api才会调用你的streamSimple(provider-composer.ts:453)。官方示例自造"custom-anthropic-api"正是为此。- 注册链六站:
loader.ts:376→ 加载期排队loader.ts:210→runner.ts:352flush →agent-session.ts:2439注入动作 →model-runtime.ts:550登记 →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:1400-1401(两个重载)、:1427-1464(ProviderConfig)packages/coding-agent/src/core/extensions/loader.ts:376-384(分流)、:208-215(排队)packages/coding-agent/src/core/extensions/runner.ts:352-386(flush)、:388-410(直通替换)packages/coding-agent/src/core/agent-session.ts:2438-2451(注入动作)packages/coding-agent/src/core/model-runtime.ts:550-586(登记)、:202-224(重新合成)、:588-594(注销)packages/coding-agent/src/core/provider-composer.ts:44-68(ProviderConfigInput)、:201-228(applyExtension)、:399-409(校验)、:412-499(composeModelProvider)、:446-466(三级分派)packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts:334-568(自定义流)、:574-610(注册)packages/coding-agent/src/extensions/index.ts:1-4、src/extensions/llama/provider.ts:58-134- 测试:
packages/coding-agent/test/agent-session-dynamic-provider.test.ts、packages/coding-agent/test/model-registry.test.ts:917(dynamic provider lifecycle)
- 自测问题:① 你写了
pi.registerProvider("anthropic", { models: [一个模型] }),内置的其它 Claude 模型还在吗?为什么?② 一个扩展在session_start事件里调用registerProvider,会走排队还是直通?③ 如果你的streamSimple里throw了异常而不是 pusherror事件,会发生什么?(提示:回看StreamFunction契约与lazyStream)④ 为什么 llama.cpp 支持必须用原生重载而不能用配置形态? - 下一章:第七部分到此结束,接下来是第八部分 · 动手实现 Mini Harness——把前七部分读到的结构,用几百行代码自己搭一遍。
- 本章未展开的内容:
ProviderConfig.oauth三件套如何被适配成 pi-ai 的OAuthAuth(provider-composer.ts:230-248)与凭据的持久化;refreshModels的ModelsStore持久化与离线恢复;compat字段的完整清单(官方文档custom-provider.md:219-266有 API 类型表与compat示例);authHeader与!command形式的 API Key 解析;把官方 11 个 Provider 测试文件改造成自己 Provider 测试集的做法。