模型、供应商与认证
42 个供应商背后只有 10 种 API 协议,以及两种认证方式
「Pi 支持 42 个供应商」这个说法在文档里到处出现,但它是整篇文章里最没用的一句话。你不会因为供应商数量多而受益,你受益的原因是:无论你最后用哪一家,底层只有 10 种聊天协议、1 种图像协议、4 种分类协议,而认证总共只有两种形状。
理解了这个二维结构,42 这个数字就从一个炫耀变成了一个坐标:供应商是纵轴,协议是横轴,读懂表格就知道一个供应商背后发生了什么、换一个供应商要改什么、以及什么情况下你根本不需要换供应商。
本文基线是 v1.3.0-16-g8efd314fa。所有数字都能在 packages/ai/src/types.ts 里数出来。
4.1 42 个供应商,10 种 API 协议
先说结论,再拆开。
结论:Pi 的模型层是二维的。一维是供应商(42 个 ID),另一维是协议(api 字段)。供应商决定「去哪、用什么凭据」,协议决定「请求长什么样、流怎么解析」。两个维度互相独立,所以 42 个供应商没有对应 42 套代码。
「API 协议」具体指什么?指一条已经存在的、被多家厂商实现的线上协议。举三个你一定见过的:
openai-completions就是POST /v1/chat/completions,很多「OpenAI 兼容」的第三方服务说的也是它。anthropic-messages就是 Anthropic 的 Messages API。bedrock-converse-stream是 AWS Bedrock 的 Converse API。
Pi 的做法是:为每种协议实现一个适配器(packages/ai/src/api/<协议名>.ts),然后让多个供应商共用它。协议之间的差异不是靠 if-else 散落在各处,而是收敛在一批 compat 兼容开关里——这一点在 4.5 会具体展开。
三个类型,五个协议之外
协议维度还要再切一刀。Pi 内部把模型分成三种类型,每种类型有自己的协议空间(types.ts:888 的 ModelTypeMap):
三个细节值得记住:
chat 的 type 字段可以省略。Model 接口写的是 type?: "chat",注释说得很直接:聊天是默认类型,没有 type 的就是聊天模型。所以你看到模型定义里没有 type 字段,不必怀疑是写漏了。要判断混合类型列表里的模型类型,用 isModelType() 收窄,不要直接比较 type === "chat"——那会把所有聊天模型判成 undefined。
图像和分类模型只有 1 种和 4 种协议。这说明这两个方向远没有聊天那样百花齐放。图像模型目前只有 OpenRouter 一家(openrouter 供应商下的 google/gemini-2.5-flash-image、black-forest-labs/flux.2-pro 之类);分类模型的四个协议来自四个不同的来源。
分类模型不聊天。它们回答的是有类型的题目:从若干选项里挑一个、回答是/否、打一个分数,每题都带概率。这套 API 是给 codemode 脚本用的,模型通过 models.getAvailableOfType("classifier") 拿到它们,不出现在 /model 里(docs/models.md:159)。图像模型同理(docs/models.md:186)。也就是说,42 个供应商里有一部分模型,你在 /model 里根本选不到——这是设计,不是遗漏。
10 种聊天协议
KnownApi 联合类型(packages/ai/src/types.ts:61-71)就是全部清单:
这张表的行是协议,列是「有多少个供应商的模型走这条协议」。读法是:一个供应商可以占多行,一个协议可以有多家供应商。
比如 anthropic-messages 这一行有 10 个供应商,但它们对这条协议的理解并不相同:Fireworks 不支持 cache_control 挂在工具定义上,OpenCode Zen 和 GitHub Copilot 接受对话中途的 system 消息但拒绝 tool_addition 块。这些差异被编码成 compat 字段里的布尔开关(anthropic-messages 的兼容项见 docs/models-configuration.md:159-175)。协议相同不等于行为相同,compat 才是行为差异的存放处。
最右边那三行值得单独说:
pi-messages是 Pi 自己的协议,目前只有 Radius 一个供应商在用。Radius 是 Pi 的作者 Earendil Works 做的 AI 网关,docs/providers.md:106。所以看到pi-messages就知道你在跟自家网关说话。google-vertex是 Gemini 在 Google Cloud 上的托管形态,与直连 Google 的google-generative-ai是两个协议,因为认证路径完全不同(见 4.3)。azure-openai-responses独立于openai-responses,因为它有额外的部署名映射和资源名解析逻辑。
推导方法:每个供应商工厂函数的返回类型 Provider<...> 里写死了它用哪些协议。比如 packages/ai/src/providers/openrouter.ts:62 写着 Provider<"anthropic-messages" | "openai-completions">。少数供应商用了类型别名(OpenCodeApi、OpenCodeGoApi、CloudflareAIGatewayApi),先展开别名再数。
为什么这个结构能撑住 42 个供应商
三个机制,共同把「新供应商」的成本压到很低。
第一,协议适配器是复用的。 ApiOptionsMap(types.ts:282-293)把 10 个已知协议映射到各自的选项类型;所有 API 实现模块都导出 stream 和 streamSimple 两个函数,接口叫 ProviderStreams(types.ts:311-324)。加一个走 openai-completions 的供应商,你不需要写任何流解析代码。
第二,认证是独立的一层。 供应商对象上挂的是 auth 字段,跟 api 无关。这个分层就是「42 个供应商只落在 10 种协议上」能成立的前提——因为认证差异不会污染协议实现。
第三,差异用 compat 描述,不用子类。 一个 compat 对象就是几十个可选布尔值和枚举。加一个新供应商通常只是在它的模型定义里多写一个 compat,而不是多写一个类。
十个数量级的差异都压在 compat 里
compat 具体有多少东西?看 openai-completions 一组就有三十多个字段(docs/models-configuration.md:109-139),anthropic-messages 一组有十五个(:159-175)。这些开关覆盖的差异从「序列化层」一直到「产品层」:
序列化层 maxTokensField 用哪个字段名
sessionAffinityFormat 发哪些亲和头
requiresThinkingAsText 思考块怎么降级
cacheControlFormat 缓存标记用哪种约定
协议能力层 supportsStrictMode 收不收 strict
supportsMidConvoSystemMessages 中途能不能插 system 消息
supportsMidConvoToolAdditions 中途能不能加工具
supportsOpenAIGrammarTools 收不收语法工具
supportsLongCacheRetention 支不支持长保留期缓存
产品层 openRouterRouting 让 OpenRouter 路由到哪些上游
vercelGatewayRouting 让 Vercel 网关路由到哪些上游
vllmPriority vLLM 调度优先级
thinkingTokenBudgetField 用哪个字段限制思考预算最后两类特别值得注意,因为它们不影响能不能通,只影响通得多快、多省。openRouterRouting 让你指定 OpenRouter 把请求转给哪些上游供应商;thinkingTokenBudgetField 让 Pi 知道该往自建服务发哪个字段来限制思考 token(vLLM 发 thinking_token_budget,Qwen/DashScope/SGLang 发 thinking_budget,llama.cpp 发 thinking_budget_tokens)。这些开关默认关闭,生成目录里也没设——需要你自己按目标服务打开。
这就是「供应商 × 协议」二维结构的真正收益:换供应商时你要判断的不是「重写一遍适配器」,而是「打开哪几个 compat 开关」。
类型系统留的口子
Api 的定义是 KnownApi | (string & {})(types.ts:73),图像和分类版本一样。这行 string & {} 是 TypeScript 的一个小技巧:它让任意字符串都能被赋值,同时保留字面量的自动补全。也就是说类型层面接受未知协议,运行时由你注册的适配器负责。写自定义供应商时你会真的用到它。
4.2 模型数据从哪来
一个常见的误解是把模型目录当成硬编码在源码里的东西。它不是。 Pi 的模型数据有两条来源,在构建期和运行期各占一半:
构建期 运行期
────────────────────────────────────────────────────────
generate-models.ts pi.dev /models/providers/<id>
├─ models.dev/api.json ├─ ?types=chat,image,classifier
├─ openrouter.ai/api/v1/models ├─ If-None-Match 复验
│ ├─ ?output_modalities=image ├─ 至少隔 4 小时才再查
│ └─ ?output_modalities=decisions └─ 304 = 只更新检查时间
├─ integrate.api.nvidia.com/v1/models
├─ ai-gateway.vercel.sh/v1/models
└─ Radius 网关配置
│
▼
src/providers/*.models.ts(42 个,自动生成)
│
▼
npm 包里的 .json 数据 ──► 打包进 dist
│
├──────────────► 打包版即为「本地基线」
│
运行期刷新叠加 ──────────► models-store.json(缓存)构建期:从上游拉数据,生成静态目录
生成脚本是 packages/ai/scripts/generate-models.ts(3646 行),产物是 src/models.generated.ts——文件头写着 This file is auto-generated by scripts/generate-models.ts,它自己声明了 42 个供应商各自的 MODELS / IMAGE_MODELS / CLASSIFIER_MODELS 三张表。
脚本的数据源有五个:
注意一件事:这些 URL 全是 供应商侧的数据源,pi.dev 并不在构建脚本里出现。这跟运行期正好反过来。
生成出来的每个 <供应商>.models.ts 只是个薄壳——从同名 JSON 导入,然后按 type 过滤。真正的分类逻辑在 packages/ai/src/model-catalog.ts 的 flattenModelCatalog() 里(model-catalog.ts:53-60):遍历所有分组、展开所有模型、按 type 筛选、最后用 id 建索引。这就是为什么每张表都是 { [模型id]: 模型 } 的形状。
生成器还会打上大量修正标记。比如某个模型在 models.dev 上报了但 API 实际不接受(MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS)、某个模型不支持工具调用(到处都有 if (m.tool_call !== true) continue)、OpenAI 的长上下文溢价(超过 272000 token 后输入价翻倍、输出价 1.5 倍,写进 cost.tiers)。这类修正有几百条,全在生成脚本里。它们的共同点是:都是构建期一次性算好的,运行时不再判断。
一条模型记录里有什么
理解模型目录最好的办法是看一条记录长什么样。以 Model 接口为准(types.ts:825-872),一条聊天模型记录包含:
三点值得停下来:
cost.tiers 支持分档定价。 每档有 inputTokensAbove 门槛和四个价格字段,匹配到的最高档适用于整个请求(不是只对超出部分)。OpenAI 的长上下文溢价就是这么表达的:超过 272000 token 后输入价翻倍、输出价 1.5 倍、缓存读写各翻倍。
input 只有两个取值。 不是任意模态串,就 text 和 image。视觉模型的区别就在这个数组里。
compat 的类型随 api 变。 一个 openai-completions 模型带 OpenAICompletionsCompat,一个 anthropic-messages 模型带 AnthropicMessagesCompat——TypeScript 的条件类型保证你不会给 OpenAI 模型写 Anthropic 的开关。
运行期:从 pi.dev 刷新并缓存
Pi 启动后不会满足于打包时那份目录。内置供应商被包在 withRemoteCatalog() 里(packages/coding-agent/src/core/remote-catalog-provider.ts:104),它给每个供应商加了一层运行时叠加。
这套机制的参数都在文件顶部,全部可查:
DEFAULT_CATALOG_BASE_URL = "https://pi.dev"
REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000
REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000 // 4 小时请求打的是 https://pi.dev/api/models/providers/<供应商ID>?types=chat,image,classifier。带上 types 是为了拿全类型分片——只服务老客户端的接口只返回聊天分片,带上这个参数服务端就返回完整的一份(源码注释写明了这个兼容性考虑)。
四个实现细节决定了这套机制在生产里能不能用:
条件请求。 缓存里存着上一次响应的 ETag(packages/ai/src/models-store.ts:14),下次带 If-None-Match 发出去。目录没变,服务端回 304 空体,Pi 只更新 checkedAt。注释说明了这里的谨慎:只有在缓存里确实有响应体时才带验证器,这样 304 永远不会导致叠加层变空。
4 小时节流。 checkedAt 距今不足 4 小时就直接跳过请求。而且只有同时具备 checkedAt 和 lastModified 才节流——老版本存下的、或者首次请求失败的条目会一直被重新拉取,因为 remoteModels() 根本不看它们的 lastModified。
404/501 = 「这个供应商没有目录」,不是错误。 Pi 把 lastModified 记成 0、ETag 清空,下次照常 4 小时后再试一次。这让 pi.dev 逐步上线新供应商时不需要客户端配合。
「更新」靠时间戳比较,不靠信不信。 isNewer() 比较远端响应的 Last-Modified 和本地打包时的生成时间,只在远端更新时叠加。旧版 Pi 拿到新目录不会显得「新」,新版权本才有资格用上。
缓存文件是 ~/.pi/agent/models-store.json,由 FileModelsStore 读写(packages/coding-agent/src/core/models-store.ts:52),带文件锁和 mtime 复检,结构是 { models, lastModified, checkedAt, etag } 按供应商 ID 索引。
刷新分两个阶段,这是关键
Models.refresh()(packages/ai/src/models.ts:558-618)对每个动态供应商跑两遍:
- 先用缓存恢复一遍状态,这次
allowNetwork是false——在解析认证之前、在任何网络访问之前; - 如果允许联网,再解析凭据(必要时刷新 OAuth),跑第二遍,这次
allowNetwork是true。
这个顺序保证了一件事:离线启动时你拿到的仍然是完整目录,而不是空列表,然后等网络慢慢补。源码注释写得很直接:「Restore cached provider state before auth resolution or network access.」
/model 打开时会触发刷新,/login 之后会,pi update --models 会强制刷新(--force 绕过 4 小时节流)。默认的 4 秒单次超时意味着即使 pi.dev 挂了也不会拖慢启动。
两个例外
OpenRouter 走的是另一条路。 它不满足于静态目录加叠加,而是用 withPersonalizedCatalog()(remote-catalog-provider.ts:195)。拿 key 去打 OpenRouter 的 /models/user,得到的列表只包含这个 key 有权使用的模型,带这个 key 自己的上下文长度和价格。刷新结果被保存下来,离线和 pi -p / RPC 模式都直接用它。如果把 base URL 改成一个代理(它在 /models/user 上没有这个端点或拒绝该 key),Pi 会退回到列出全部 OpenRouter 模型。
llama.cpp 完全没有目录。 本地模型的列表来自 llama.cpp router 自己。/llama 负责加载和卸载,/model 只列出当前已加载的模型。所以本地模型「不出现」通常不是 bug,是还没加载。
本地模型:第三种数据来源
llama.cpp 值得单独说,因为它跟前两种都不一样:既不是构建期生成,也不是从 pi.dev 刷,而是由你本地跑的一个 HTTP 服务在运行时提供。
Pi 直连 llama.cpp 的 router 服务。router 发现 GGUF 文件、按需加载和卸载模型,Pi 通过 /llama 命令管理它、通过 /model 选择已加载的模型。
启动 router 的方式有个容易搞错的点:不要传 --model 或 -m。传了就是单模型模式,不是 router 模式。
llama-server \
--models-dir ~/models \
--no-models-autoload \
--jinja \
--host 127.0.0.1 \
--port 8080 \
-ngl 999 \
-c 32768四个参数值得记住:--models-dir 指定 GGUF 目录;--jinja 启用聊天模板和工具调用;-ngl 999 把尽可能多的层卸载到 GPU;-c 32768 给每个加载的模型设上下文窗口(省略它会用模型的原生上下文,显存需求可能高得多)。
模型目录的布局也有要求:单文件模型可以直接放在目录里;多模态和多分片模型要各自放进独立子目录。手动加了文件之后要重启 router。
Pi 侧的连接配置走 /login llama.cpp,输入 router URL 和可选的 API key,默认 URL 是 http://127.0.0.1:8080。也可以用环境变量:
export LLAMA_BASE_URL=http://127.0.0.1:8080
export LLAMA_API_KEY=optional-secret
pi用了 --no-models-autoload 时,/login llama.cpp 只存连接信息,得先 /llama 加载模型,再 /model 选它。/llama 还能直接搜 Hugging Face 下载模型——搜索会依次尝试 HF_TOKEN、$HF_TOKEN_PATH、$HF_HOME/token、$XDG_CACHE_HOME/huggingface/token 和 ~/.cache/huggingface/token,不认证也能搜,只是速率限制更低。下载动作是 llama.cpp server 执行的,所以那个进程自己也得有 HF_TOKEN。
⚠️ llama.cpp 既是模型来源也是下载入口。 /llama 里选一个仓库就会让 server 去拉取它。这条路径通向你本机的模型缓存,也通向 Hugging Face。放在隔离环境里跑 Pi 时(见 隔离与约束),这条能力也在边界之内还是之外,取决于你用的是哪种形态。
llama.cpp 上的分类模型有两种来源:decision 模型(Julia-1、Laya、Kev 等)通过 llama.cpp 的 /v1/systemone 端点原生回答,只作为分类模型出现,不进 /model;聊天模型同时被列为分类模型,用 llama-cpp-classify 协议——它不发问,而是把每个问题变成一段对话提示,读下一个 token 的概率作为答案。
4.3 两种认证方式
packages/ai/src/auth/types.ts 里,凭据只有两个类型。整个认证系统的形状就是这两个联合分支:
Credential = ApiKeyCredential | OAuthCredential
ApiKeyCredential OAuthCredential
{ type: "api_key", { type: "oauth",
key?: string, refresh: string,
env?: ProviderEnv } access: string,
expires: number }AuthType 也是这个二元:"api_key" | "oauth"(auth/types.ts:117)。
api_key:不只是一串字符串
除了 key,ApiKeyCredential 还有一个 env 字段。这值得解释——有些云服务的认证不是「一个 token」,而是「一个 token + 一组区域/账户配置」。比如 Cloudflare Workers AI 需要 CLOUDFLARE_API_KEY 和 CLOUDFLARE_ACCOUNT_ID。把这个 account id 塞进 env 对象,它就跟着凭据一起走:
{
"cloudflare-workers-ai": {
"type": "api_key",
"key": "...",
"env": {
"CLOUDFLARE_ACCOUNT_ID": "account-id"
}
}
}env 里的值优先于进程环境变量。这一点在 auth/resolve.ts:95-100 的 overlayEnvAuthContext() 里实现:先查 env 对象,miss 了才落到 process.env。所以你可以为一个供应商单独覆盖配置,而不影响其他供应商。
key 字段还能是一个以 ! 开头的命令(docs/providers.md:71-84):
{
"anthropic": {
"type": "api_key",
"key": "!security find-generic-password -ws 'anthropic'"
}
}Pi 在第一次需要这个 key 时执行命令,把标准输出缓存到进程结束。空输出、超时、非零退出码都会让这个 key 保持「未解析」状态,直到重启 Pi。这是从系统钥匙串取密钥而不落盘的推荐做法。
oauth:access + refresh 的双令牌
OAuthCredential 有三个字段:access(短期访问令牌)、refresh(刷新令牌)、expires(过期时刻,Unix 毫秒)。
刷新逻辑在 auth/resolve.ts,有几处设计值得单独讲,因为它们解释了「为什么 Pi 不会用坏你的令牌」。
五分钟余量。 剩余有效期不足 5 分钟就提前刷新(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS,resolve.ts:102)。理由很实际:请求可能在飞行途中就超过这个窗口。
双重检查锁。 刷新在凭据存储的锁内完成,needsRefresh 在锁内再检查一次(resolve.ts:105-118)。所以并发请求、并发进程都只会刷新一次。
刷新一旦开始就不受取消信号影响。 源码注释解释了原因:供应商可能已经轮换了 refresh token,此时中途取消会丢掉唯一有效的那一份。它只被一个 15 秒超时(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,resolve.ts:103)兜住。想快速返回的调用方自己去 race。
没有静默回退。 刷新失败不会退回环境变量。resolve.ts 开头的注释把这条规则写死了:「A stored credential owns the provider.」
解析顺序,以及那个「登录后就看不到模型」的坑
docs/models.md:23 给出的顺序是:
1. 运行时的 --api-key 覆盖
2. auth.json 里的已存凭据
3. models.json 里的 apiKey
4. 供应商的环境变量 / 环境中已有的云凭据配合上面那条「已存凭据拥有这个供应商」,就得到一个具体行为:你在 auth.json 里存了凭据,环境变量就对它失效了。
这不是设计缺陷,是有意的安全选择——否则你 logout 之后,key 会从环境变量里悄悄回来。但它有一个真实后果,文档也点名了:OpenAI 的 Decisions API 需要 API key,但用 ChatGPT 凭据登录后 gpt-6-luna 不出现在可用列表里,即使 OPENAI_API_KEY 已经设好(docs/models.md:157)。要改用 API key,得先 /logout openai。
这是本文最值得记住的一条:「模型不出现」的第一嫌疑是认证,不是模型目录。 /model 的选择器只显示凭据可用的供应商。
哪些供应商支持 OAuth
42 个里有 9 个声明了 oauth 字段。其中 7 个是订阅制(isSubscription: true),2 个不是:
推导方法:grep -n "oauth:" packages/ai/src/providers/*.ts,命中 9 个文件。
三种登录方式对应不同的网络条件:
- PKCE + 本地回调:Pi 起一个一次性的 loopback HTTP 服务器等回调。浏览器在同一台机器上时体验最好。
- 设备码(RFC 8628):终端打印一个码和一个网址,你去别的设备上输入。SSH、容器、远程开发环境的救命稻草。
- 两者都有:
openai-codex和radius会在登录时弹一个选择框让你挑。
openai-codex 的代码里有个细节:设备码登录返回 404 时会提示「此服务器未启用设备码登录,请用浏览器登录或检查服务器地址」——它把两条路径当成对等选项,而不是降级。
远程机器上还有个通用兜底:OAuth 回调打不回本地进程时,Pi 会提示你把最终的跳转 URL 或授权码粘贴回来(docs/models.md:14)。
两种 OAuth 的形态:令牌对 vs 常驻 key
大多数 OAuth 存的是 access/refresh 令牌对。有两个例外,读源码时容易困惑:
OpenRouter 的 OAuth 通过授权码换回来的是一个永久的、用户自己掌控的 API key,不是会过期的令牌对。文件头注释专门解释了这一点。
Meta 更绕:它把身份令牌存成 refresh,每次拿身份令牌去换一个 API key 存成 access(auth/oauth/meta.ts)。换出来的 key 有效期约一天(API_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000)。这样标准 OAuth 调度器就能在 key 过期时自动重铸,不用写专门的续期逻辑。但身份令牌本身不可再生——auth.meta.com 对 grant_type=refresh_token 返回 404。所以一旦铸造返回 401/403,就意味着整个会话死了,只能重新 /login meta。
这是个真实的边界:OAuth 登录不等于长期免维护。它只是把「手动复制粘贴」变成了「自动轮换」。
两种不属于这两种类型的凭据
严格说,认证类型仍然只有两种——但有两个供应商能从运行环境里捡到凭据,不经过 auth.json 也不经过环境变量。文档把它们单列为「需要额外配置或支持环境凭据」(docs/providers.md:8)。
Amazon Bedrock 接受一整套 AWS 凭据来源:
export AWS_PROFILE=your-profile # 命名 profile
export AWS_ACCESS_KEY_ID=AKIA... # IAM key
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=... # 临时凭据必需
export AWS_BEARER_TOKEN_BEDROCK=... # Bedrock bearer token
export AWS_REGION=us-west-2 # AWS_DEFAULT_REGION 也支持还支持 ECS 任务凭据和 IRSA(AWS_CONTAINER_CREDENTIALS_* 和 AWS_WEB_IDENTITY_TOKEN_FILE)。这是 EKS 上跑 Agent 最省事的一条路。
Google Vertex AI 三选一:Google Cloud API key(GOOGLE_CLOUD_API_KEY)、Application Default Credentials(GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION,再跑 gcloud auth application-default login)、或者服务账号密钥文件(GOOGLE_APPLICATION_CREDENTIALS)。GCLOUD_PROJECT 是 GOOGLE_CLOUD_PROJECT 的别名。
还有两个供应商各自有一组特殊变量,容易踩:
Azure。供应商 ID 现在叫 azure(原来叫 azure-openai-responses),在 auth.json、models.json、settings.json 和 --model azure/gpt-5.4 里都用这个新名字。要配的是 AZURE_OPENAI_API_KEY 加 AZURE_OPENAI_BASE_URL 或 AZURE_OPENAI_RESOURCE_NAME 二选一。Pi 把模型 ID 当作部署名发送,部署名不同时用 AZURE_OPENAI_DEPLOYMENT_NAME_MAP 映射。AZURE_OPENAI_API_VERSION 覆盖 API 版本,默认 v1。
Anthropic 除了 ANTHROPIC_API_KEY,还认 ANTHROPIC_OAUTH_TOKEN(当 API 凭据)和 ANTHROPIC_AUTH_TOKEN(当 bearer 认证)。两者都没设时,如果 ANTHROPIC_FEDERATION_RULE_ID、ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_IDENTITY_TOKEN_FILE 三件套齐全,会走工作负载身份联合——SDK 用身份令牌换一个短期 access token 并自己续期。源码提醒要保持那个文件新鲜,否则长会话会断。
存储在哪
凭据写在 ~/.pi/agent/models.json 的邻居——~/.pi/agent/auth.json(docs/configuration.md:17)。/logout 删除某个供应商的已存凭据,但不会取消环境变量、不会移除 models.json 里的认证、也不会去供应商那边吊销凭据。
这两件事(auth.json 和 models.json 里的 !command)都应该保持私有。文档在 docs/models.md:25 特别提醒:项目设置和扩展会在你信任一个项目之后于 Pi 进程内执行。
4.4 切换模型与思考等级
三个入口:交互式选择器、命令行参数、配置文件。它们的优先级是固定的。
思考等级:七个档,两处会被裁剪
思考等级有七档:off、minimal、low、medium、high、xhigh、max。
启动时的实际取值按这个顺序解析(src/core/model-resolver.ts:571):
scopedModels 里该模型自带的等级
?? modelThinkingLevels["provider/modelId"]
?? settings.defaultThinkingLevel
?? "medium"最后一档的 "medium" 是硬编码常量 DEFAULT_THINKING_LEVEL(src/core/defaults.ts:3)。
裁剪发生在两处。 第一处是命令行的 --thinking,它覆盖 --model 的 :等级 后缀,然后被裁剪到模型支持的范围(docs/cli.md:70)。第二处是 thinkingLevelMap——这是 Pi 抽象层和供应商词汇之间的翻译表。
thinkingLevelMap 的语义值得展开,它是本节最容易被误解的东西:
- 键是 Pi 的思考等级,
minimallowmediumhighxhighmax; - 值是供应商实际收到的字符串;
- 值为
null表示这个等级该模型不支持; - 表里没列出的等级,沿用供应商默认值一直到
high;xhigh和max默认不支持。
举个真实的例子。同一个 Pi 等级 high,在不同模型上发出的东西完全不同:
Pi: high → anthropic 发出 thinking 的 effort 参数
Pi: high → zai 发出 thinking.type
Pi: high → qwen 发出顶层的 enable_thinking
Pi: high → deepseek 发出 thinking.type 加 reasoning_effort
Pi: high → together 发出 reasoning.enabled 加 reasoning_effort
Pi: high → openrouter 发出 reasoning.effort
Pi: high → baseten 发出可配置的 chat_template_args这些不是 Pi 在运行时猜的,是生成脚本在构建期写进模型定义的。目录里有 11 种 thinkingFormat 取值(docs/models-configuration.md:123)。
会话记录切换,不改默认值
这是本节最实用的一条:会话会记录模型和思考等级的变更,恢复会话时把它们还原,但不动新会话的默认值。(docs/models.md:35)
所以「我为了省钱临时切到 Haiku」和「我默认就用 Haiku」是两件事。前者写在会话里,后者要写进 settings.json。
命令行
pi --model sonnet:high一个容易忽略的点:--models 里同一个模型如果多个供应商都提供,每个都会加进去并给出警告。这跟 --model 的「选一个」语义不同。
交互式
Ctrl+P 在可用模型之间循环。
常见故障排查
这四条来自官方 troubleshooting(docs/models.md:205-221),按排查顺序排列,因为前面的原因比后面的常见得多。
第一条值得展开,因为它是最容易误判的。/model 的选择器只显示凭据可用的供应商的模型,而凭据解析有四层(见 4.3)。所以「模型不出现」的排查顺序应该是:
1. 这个供应商的凭据解析出来了吗? → /login <provider> 看它支持哪些方式
2. 是不是被 OAuth 登录屏蔽了环境变量? → 已知案例:OpenAI Decisions API
3. 模型 ID 拼对了吗? → provider/id 的 provider 部分别写错
4. 模型定义里写了 type 吗? → image / classifier 模型不在 /model 里
5. llama.cpp:模型加载了吗? → /llama反过来也有一条同样常见的:「在 /model 里找不到分类模型」不是 bug。图像和分类模型只通过 codemode 脚本暴露,models.getAvailableOfType("classifier") 和 getAvailableOfType("image") 是取它们的入口(Codemode)。扩展侧则用 ctx.modelRegistry.classify() 和 ctx.modelRegistry.generateImages(),不需要 codemode。
4.5 自定义端点:models.json
当端点说的协议 Pi 已经支持时,用 models.json,不用写代码。这个文件在 ~/.pi/agent/models.json(docs/configuration.md:16),可以加一行 $schema 拿编辑器补全:
{
"$schema": "https://pi.dev/schemas/models.schema.json",
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "qwen2.5-coder:7b" }
]
}
}
}那个 "apiKey": "ollama" 是占位 key——Ollama 会忽略它,但 Pi 需要它的存在才把这个模型列进 /model。
providers 下的字段
顶层键的语义分两种:写内置供应商 ID 是修改那个供应商;写别的 ID 是新增供应商,而新增必须同时给 baseUrl 和 api(供应商级或模型级)。
值的三种写法
apiKey 和 headers 的值支持四种形态:
models.json 里的命令在请求时执行,Pi 不做缓存。这跟 auth.json 里的 ! 命令不同——后者缓存到进程结束。前者的取舍是:每次请求都跑一次命令(贵,但 key 永远新鲜);后者只跑一次(便宜,但长会话里 key 过期了不会更新)。
models vs modelOverrides
这两个字段经常被搞混,区别是替换还是合并:
models里一条id匹配到已有模型,就替换它。不写models就保留该供应商的全部内置模型。modelOverrides只改元数据,不替换模型列表。未知的 ID 被静默忽略。对象类型字段(如cost)逐字段合并,其余字段整体替换。
这个设计让你可以给一个走代理的内置模型改价格和上下文长度,而不用把它的模型列表重抄一遍——它还能作用于通过已验证代理访问的模型。
只改 baseUrl 就是走代理
想让内置供应商走自己的代理,只需要改一个字段:
{
"providers": {
"anthropic": { "baseUrl": "https://proxy.example.com/v1" }
}
}模型和认证逻辑原封不动。这是改端点最省事的形式。
常用模型字段与默认值
两个默认值需要单独说,因为它们直接影响行为:contextWindow 默认 128000,maxTokens 默认 16384。如果你新加一个模型忘了写这两个,Pi 会按 128K 上下文记账——压缩触发点也跟着算错(压缩条件是 contextTokens > contextWindow - reserveTokens,见 上下文与压缩)。
compat:描述你验证过的差异
compat 的文档措辞值得原样引用:兼容性设置应该描述在端点的请求或响应行为上验证过的差异,不要仅仅因为一个端点宣称兼容 OpenAI 或 Anthropic 就打开它们。(docs/models.md:140)
openai-completions 的兼容项是最丰富的一组(docs/models-configuration.md:109-139),挑几个说明它们各自解决什么问题:
supportsMidConvoSystemMessages 和 supportsMidConvoToolAdditions 是模型级开关而不是供应商级。同一个供应商的不同模型能力不一样,目录只在验证过的模型上打开它们。
图像缩放与 prompt cache
inputLimits.images.resize 控制新图像进入会话历史前的缩放。省略时的保守默认是 2000×2000 像素、编码后 4.5 MiB、JPEG 质量 80(docs/models.md:102,生成脚本里的 DEFAULT_IMAGE_RESIZE 与之一致)。
{
"id": "vision-model",
"input": ["text", "image"],
"inputLimits": {
"images": {
"resize": { "maxWidth": 1568, "maxHeight": 1568, "maxBytes": 524288, "jpegQuality": 75 }
}
}
}maxBytes 限制的是 base64 编码之后的体积,不是原始文件。图像只编码一次——换模型不会重写历史图像。
promptCache 声明供应商尽力而为的缓存生命周期,单位是秒:
{ "id": "claude-sonnet-5", "promptCache": { "short": 300, "long": 3600 } }关键规则有两条。填写已发布范围的保守端;以及当前激活档位没有生命周期的模型不参与缓存预热。目录里 Anthropic 的值是 { short: 300, long: 3600 }(生成脚本里的 ANTHROPIC_PROMPT_CACHE)——因为 Anthropic 的临时条目本来硬性五分钟,ttl: "1h" 把它延长到一小时。
缓存预热本身由 cacheWarming 设置控制(off / streaming / idle,默认 streaming),并且只在 Pi 估算省下的缓存未命中成本至少 $0.05 时才跑(docs/settings.md:22-23)。这个阈值防止了为省几分钱而烧掉几毛钱 token 的倒挂。
按思考等级配采样参数
samplingParams 和 samplingParamsByThinkingLevel 只对 openai-completions、openai-responses、azure-openai-responses 生效,其余协议忽略(docs/models.md:138)。这是为了让 llama.cpp、vLLM、SGLang 这类自建服务能收到 Pi 不建模的参数,比如 top_p、top_k、min_p、repetition_penalty。
注意键名用的是 Pi 的思考等级,不是 thinkingLevelMap 里的供应商值。
合并顺序是:先裁剪不支持的等级,然后依次合并模型的 samplingParams、生效等级的那一条覆盖、请求级的 samplingParams,后面的按键覆盖前面的。缺失的等级继承模型默认值。
4.6 自定义供应商与虚拟模型
先问:真的需要写代码吗
docs/custom-provider.md:9-15 给了一张「选最小的集成」表,这张表的判断顺序值得记住:
按这张表从上往下问,能避免 90% 的过度工程。第一行覆盖的场景最多——只要对方说的协议 Pi 已经支持,你写的那 42 行配置就够了。
注册供应商
在扩展工厂里调 pi.registerProvider()。Pi 会等异步工厂完成才继续启动,所以在那儿注册的供应商对启动期的模型选择和 pi --list-models 都可见(docs/custom-provider.md:21)。
两种注册形式:
- 注册完整的
Provider——从@earendil-works/pi-ai导入,自己管原生认证、过滤、发现、刷新和流式行为。 - 注册名称 +
ProviderConfig——旧的形式,给现存扩展用。
新集成建议用第一种。Pi 会把 models.json 的覆盖叠加在已注册的原生供应商之上。
两种形式对「内置模型」的处理不同,这一条容易踩:只注册 baseUrl 或 headers 会保留该供应商的内置模型;在旧形式里提供 models 则会替换掉这个供应商在聊天、图像、分类三种操作上的全部模型。
复用现成的流实现
这是自定义供应商最重要的一条建议:协议匹配时,直接用 Pi AI 已有的 API 实现,把请求转换和流式处理委托出去。
文档列出了已支持的实现范围:Anthropic Messages、OpenAI Chat Completions 和 Responses、Google Generative AI 和 Vertex、Azure OpenAI Responses、Mistral Conversations、Bedrock Converse。
为什么这比复制一份流实现安全?因为委托之后,Pi 自己的消息转换、工具处理、usage 记账、取消语义、兼容行为全部原样保留。你自己写一份,这些就得重写一遍,而且大概率会有细微偏差。
实在没有匹配的才实现 streamSimple。实现时有几条硬约束值得先知道:
- 收到的上下文是归一化后的
TranscriptContext。系统提示和工具声明在对话的系统消息里,不在context.systemPrompt上——要用getCurrentSystemPrompt(context.messages)和getCurrentTools(context.messages)拿。 - 事件顺序有要求:请求准备成功后才发
start;start之后所有更新和done;最后恰好一个done或error;然后关流。 - 必须透传
onPayload、onResponse、onProviderStreamEvent这三个探针回调。漏掉它们,你的供应商行为就和内置供应商不一样——扩展的请求审查、响应头事件、供应商流观察会全部失效。
⚠️ 信任边界提醒:供应商扩展运行在 Pi 进程内,能检查凭据、提示词、工具定义、模型响应和用量。文档的原话是「Treat them as trusted code」。写供应商扩展等于给了它你 Pi 进程的全部权限。扩展信任机制的细节在 扩展开发,运行边界的部分在 隔离与约束。
给自定义供应商配认证
静态供应商的 key 可以是字面值、环境插值或命令,用的是和 models.json 完全相同的四种语法:$NAME 和 ${NAME} 读环境变量,!command 用命令输出,$$ 出字面的 $,$! 出字面的 !。
只有当集成需要已存凭据、自定义解析、供应商作用域的环境,或者多种登录方式时,才值得走原生供应商认证。判据很清楚:能用那四种语法表达的一律用语法,表达不了的才写代码。
原生 OAuth 供应商要提供四样东西:一个显示名、一个登录流程、一个令牌刷新方法、以及从凭据推导请求认证的方法。注册之后它就出现在 /login 里,返回的凭据落在 ~/.pi/agent/auth.json。
登录交互是 UI 无关的。 一个 OAuth 流程可以打开授权 URL、显示设备码、报告进度、请求输入、或者让用户在多个登录方式里选。实现时必须遵守取消和中止信号——特别是网络请求期间。
⚠️ 不要把访问令牌、刷新令牌、授权头或完整的供应商响应写进普通日志。 这是文档里写死的红线。供应商扩展运行在 Pi 进程内,它拿到的就是你的凭据;而一旦你把它连同 auth.json 一起挂进 CI 或共享机器,泄漏面会扩大。
虚拟模型:让「选模型」和「用模型」脱钩
虚拟模型是一个可选中的模型,但每次请求时它才决定真正用哪个物理模型。用途是按任务、成本或会话状态路由:简单问题给小模型,难题给大模型,而用户只面对一个选项。
它的核心是选择与调度分离:
原文举的例子:
selected (virtual model, virtual level) -> dispatched (physical model, physical level)
jev/auto:low -> anthropic/claude-sonnet-4-5:high注册长这样:
pi.registerVirtualModel({
provider: "router",
id: "auto",
name: "Auto",
thinkingLevels: ["low", "high"],
route(request, ctx) {
const sticky = request.failed ?? request.previous;
if (request.reason !== "user" && sticky) {
return { model: sticky.model, thinkingLevel: sticky.thinkingLevel ?? "medium" };
}
const id = request.thinkingLevel === "high" ? "claude-sonnet-4-5" : "claude-haiku-4-5";
return { model: ctx.modelRegistry.find("anthropic", id)!, thinkingLevel: "medium" };
},
});默认值有三个要注意的地方:thinkingLevels 默认是 ["off"](不给就只有一个选项);input 默认同时包含文本和图像(不支持图像的物理模型会拿到占位符);contextWindow 和 maxTokens 在第一次响应之前显示,没设就是未知。
id 不能与该供应商下已有的物理模型 ID 相同——如果后续目录刷新加入了一个同 ID 的物理模型,虚拟模型会把它遮住。
route() 的返回值、四个 reason 取值、以及状态保持机制,细节在 扩展开发;这里只强调一条设计原则:previous 和 failed 不是可选的优化,是正确性要求。route() 收到 reason: "continuation" 时返回 previous,收到 retry 时返回 failed,这样思考签名和 prompt cache 才继续有效。在两轮之间换模型是允许的,但会丢 prompt cache。
上下文用量按产生最新响应的那次调度所用的物理模型来算(docs/virtual-models.md:29)。压缩检查的也是同一个模型的限制。如果被路由到的模型上下文太小,Pi 会在发请求前先压缩,路由本身保持不变。
4.7 跨供应商上下文交接
/model 换个模型继续同一个会话,看起来很自然,实际上踩到了一堆供应商私有格式。Pi 能让它工作,靠的是在写入会话时就把供应商私有的东西关进标准形状里。
会话里存的是归一化后的消息
AssistantMessage 上和供应商相关的字段是这些(types.ts:570-601):
api 用的哪个协议
provider 哪个供应商
model 请求的模型 ID
responseModel 供应商回报的实际模型(不同时)
responseId 供应商的响应 ID
providerThinkingLevel 供应商原生的 effort 词
thinkingLevel Pi 抽象层请求的等级
usage token 与成本
stopReason / errorMessage / diagnostics思考内容本身被拆成独立内容块,每块带一个 thinkingSignature——注释直说这是「供应商私有的、不透明的或序列化的推理回放数据」。这个字段是跨供应商交接里唯一的硬骨头。
工具调用同理,被归一化成 { id, name, arguments, namespace?, thoughtSignature? }(types.ts:436-449),与任何供应商的 wire format 无关。
思考签名怎么处理
签名是供应商签发的不透明串,换一个供应商就作废。Pi 的做法是按协议分别处理:
Anthropic Messages:回放历史时,如果一个思考块既没有签名、思考文本也为空,直接丢掉。如果思考文本非空但签名缺失(被中断的流就会这样),默认把思考转成普通文本再发——因为很多兼容端点会拒绝空签名。但 Pi 给了逃生门:compat.allowEmptySignature 为真时保留原样(发 signature: "")。目录里 Xiaomi 的几个供应商和 OpenCode 的 qwen3.8-flash 就开了这个。
Google:更直接——api/google-shared.ts:233 计算 isSameProviderAndModel,只有同供应商且同模型的签名才会被原样送回,否则丢弃。签名不合法也丢。
OpenAI Responses:把 reasoning item 序列化进 thinkingSignature(api/openai-responses-shared.ts:282-283),回放时再解析出来。
结论是:思考块的内容在跨供应商时基本会降级成文本,只有同供应商同模型才能完整保真。 这不影响功能,但会影响模型的思考连续性。
兼容性开关就是为交接准备的
compat 里的 requiresThinkingAsText 和 requiresReasoningContentOnAssistantMessages 直接对应两个交接场景:
- 有些 OpenAI 兼容端点根本收不了 thinking 块,必须转文本;
- DeepSeek 系端点要求回放每一条 assistant 消息都带一个(哪怕是空的)
reasoning_content字段。
生成脚本里的 applyOpenAICompletionsTranscriptMetadata() 就是干这个的:它逐个供应商判断哪些模型接受对话中途的 system 消息、哪些接受带工具的变体。Moonshot 的 Kimi K2.6/K2.7 接受纯文本但不接受带工具的 system 消息,Kimi K3 两个都接受,GitHub Copilot 会把 K3 的文本转发但静默丢掉带工具的那条——这种差异被编码成布尔开关,而不是散落的条件判断。
工具 schema 也有同样的两派:supportsStrictMode 控制 JSON Schema 约束采样,supportsOpenAIGrammarTools 控制 Lark/正则语法工具。不支持时 grammar 约束的工具会退回普通 function tool(docs/models-configuration.md:131)——约束没了,但工具还能用。
什么会丢,什么不会
prompt cache 丢失是缓存的物理性质:不同供应商的 KV cache 不共享。所以跨供应商切换等于重新付一次全量输入的价格。在一个长会话里反复横跳的开销是可观的——这也是虚拟模型的 route() 建议在 continuation 时返回 previous 的原因之一。
恢复会话时的回退
如果会话记录的模型(可能是虚拟模型)已经不再注册了,Pi 会回退到上一次实际回答的物理模型(docs/virtual-models.md:25)。这个设计让扩展卸载后旧会话仍然可读。
AssistantMessage 记的是物理模型而不是虚拟模型,所以在虚拟模型底下换底层模型,历史照样能正常回放——和手动切模型是同一套机制。
4.8 事实来源
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。