跳到主内容
创建时间:最后更新:作者:白川

模型、供应商与认证

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
一轮轮对话、工具调用
10
见下表
image
从提示词生成图像
1
openrouter-images
classifier
对 JSON 状态做结构化判断
4
typesafe-system-one、cloudflare-workers-ai-system-one、llama-cpp-classify、openai-decisions

三个细节值得记住:

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)就是全部清单:

协议供应商数量谁在用
openai-completions
28
大多数第三方与聚合服务
anthropic-messages
10
Anthropic 及若干网关
openai-responses
7
OpenAI、xAI 等
google-generative-ai
3
Google Gemini 及代理
azure-openai-responses
1
azure
openai-codex-responses
1
openai-codex
bedrock-converse-stream
1
amazon-bedrock
google-vertex
1
google-vertex
mistral-conversations
1
mistral
pi-messages
1
radius

这张表的行是协议,列是「有多少个供应商的模型走这条协议」。读法是:一个供应商可以占多行,一个协议可以有多家供应商。

比如 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)。这些开关覆盖的差异从「序列化层」一直到「产品层」:

text
序列化层    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 的模型数据有两条来源,在构建期和运行期各占一半:

text
构建期                                  运行期
────────────────────────────────────────────────────────
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 三张表。

脚本的数据源有五个:

源提供什么
https://models.dev/api.json
大部分供应商的模型 ID、上下文长度、定价、能力标记
https://openrouter.ai/api/v1/models
OpenRouter 的聊天、图像(?output_modalities=image)、分类(?output_modalities=decisions)三个分片
https://integrate.api.nvidia.com/v1/models
NVIDIA NIM 的模型 ID 清单
https://ai-gateway.vercel.sh/v1/models
Vercel AI Gateway 的模型与定价
Radius 网关的配置接口
Radius 自己的目录

注意一件事:这些 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),一条聊天模型记录包含:

字段类型干什么
id
string
发给 API 的模型 ID
name
string
显示名,也被 --model 的模糊匹配用
api
string
走哪条协议
provider
ProviderId
归在哪个供应商名下
baseUrl
string
请求打向哪里
input
("text" | "image")[]
支持的输入模态
inputLimits?
object
图像缩放、单请求字节上限、单消息图像数上限等
cost
ModelCost
每百万 token 的输入/输出/缓存读/缓存写价格,外加可选的分档定价
reasoning
boolean
是否支持思考
thinkingLevelMap?
object
Pi 等级 → 供应商词汇
promptCache?
object
两档缓存生命周期(秒)
contextWindow
number
上下文窗口
maxTokens
number
最大输出
samplingParams?
object
默认采样参数
samplingParamsByThinkingLevel?
object
按思考等级的采样覆盖
headers?
object
该模型专属的请求头
compat?
按 api 变化的联合类型
兼容开关

三点值得停下来:

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),它给每个供应商加了一层运行时叠加。

这套机制的参数都在文件顶部,全部可查:

text
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)对每个动态供应商跑两遍:

  1. 先用缓存恢复一遍状态,这次 allowNetwork 是 false——在解析认证之前、在任何网络访问之前;
  2. 如果允许联网,再解析凭据(必要时刷新 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 模式。

bash
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。也可以用环境变量:

bash
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 里,凭据只有两个类型。整个认证系统的形状就是这两个联合分支:

text
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 对象,它就跟着凭据一起走:

json
{
  "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):

json
{
  "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 给出的顺序是:

text
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 个不是:

供应商显示名订阅制登录方式
anthropic
Anthropic (Claude Pro/Max)
是
PKCE + 本地回调
openai
OpenAI (ChatGPT subscription)
是
PKCE + 本地回调
openai-codex
OpenAI (ChatGPT Plus/Pro)
是
浏览器 / 设备码 二选一
github-copilot
GitHub Copilot
是
设备码
xai
xAI (Grok/X subscription)
是
设备码
kimi-coding
Kimi Code (subscription)
是
设备码
meta
Meta (Muse subscription)
是
设备码
openrouter
OpenRouter OAuth
否
PKCE + 本地回调
radius
Radius 网关
否
浏览器 / 设备码 二选一

推导方法: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 凭据来源:

bash
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):

text
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 的思考等级,minimal low medium high xhigh max;
  • 值是供应商实际收到的字符串;
  • 值为 null 表示这个等级该模型不支持;
  • 表里没列出的等级,沿用供应商默认值一直到 high;xhigh 和 max 默认不支持。

举个真实的例子。同一个 Pi 等级 high,在不同模型上发出的东西完全不同:

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

命令行

bash
pi --model sonnet:high
参数行为
--model <pattern>
精确 ID 或模糊匹配,接受 provider/id 和可选的 :思考等级 后缀。多个供应商匹配时优先选有凭据的那个,否则要求你加 --provider
--provider <name>
把 --model 的查找限制到一个供应商,必须配合 --model
--api-key <key>
非持久化的 key 覆盖,必须配合 --model 或 --models。不负责在多个提供同一模型的供应商之间选
--thinking <level>
覆盖 --model 后缀,裁剪到模型能力范围内
--models <patterns>
逗号分隔的模型作用域,同时管启动选择和 Ctrl+P 循环
--list-models [search]
列出可用模型后退出

一个容易忽略的点:--models 里同一个模型如果多个供应商都提供,每个都会加进去并给出警告。这跟 --model 的「选一个」语义不同。

交互式

命令做什么
/model
打开选择器。只显示凭据可用的供应商的模型。Ctrl+S 存为新会话的默认值。打开时会重新加载 models.json
/thinking
设置思考等级。Ctrl+S 存为启动等级。只列出模型支持的档位
/scoped-models
控制 Ctrl+P 循环的范围,并保存选择
/login [provider]
配置认证。不带参数时列出该供应商支持的方式
/logout
移除供应商的已存凭据

Ctrl+P 在可用模型之间循环。

常见故障排查

这四条来自官方 troubleshooting(docs/models.md:205-221),按排查顺序排列,因为前面的原因比后面的常见得多。

症状先查什么
模型不出现
该供应商有没有可用的认证。models.json 里定义的自定义模型能加载,但 Pi 解析不到凭据时它不会出现在 /model。llama.cpp 只列出 router 当前已加载的模型
只有一个 shell 里能认证
key 是不是来自环境变量而非 auth.json。环境变量必须在启动 Pi 的那个进程里存在
远程机器上登录会打开浏览器
用该供应商的无头流程(设备码)。部分供应商支持把最终跳转 URL 或授权码粘回 Pi
兼容端点拒绝请求
检查 models.json 里的 api 类型和兼容开关。上游服务器必须真的支持对应的请求字段和行为

第一条值得展开,因为它是最容易误判的。/model 的选择器只显示凭据可用的供应商的模型,而凭据解析有四层(见 4.3)。所以「模型不出现」的排查顺序应该是:

text
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 拿编辑器补全:

json
{
  "$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(供应商级或模型级)。

字段类型说明
name
string
显示名
baseUrl
string
API 根地址。也会改内置模型的
apiKey
string
字面值、$NAME 环境插值或 !command 输出
api
string
该供应商模型的协议
oauth
"radius"
OAuth 登录流程,需要 baseUrl
headers
object
请求头,取值解析规则同 apiKey
compat
object
兼容开关,见下
authHeader
boolean(默认 false)
是否把 key 作为 Authorization: Bearer 发送
models
object[]
模型列表
modelOverrides
object
对已有模型的元数据覆盖

值的三种写法

apiKey 和 headers 的值支持四种形态:

写法含义
$NAME 或 ${NAME}
读环境变量
!command
执行命令,用标准输出
$$
字面的 $
$!
字面的开头的 !

models.json 里的命令在请求时执行,Pi 不做缓存。这跟 auth.json 里的 ! 命令不同——后者缓存到进程结束。前者的取舍是:每次请求都跑一次命令(贵,但 key 永远新鲜);后者只跑一次(便宜,但长会话里 key 过期了不会更新)。

models vs modelOverrides

这两个字段经常被搞混,区别是替换还是合并:

  • models 里一条 id 匹配到已有模型,就替换它。不写 models 就保留该供应商的全部内置模型。
  • modelOverrides 只改元数据,不替换模型列表。未知的 ID 被静默忽略。对象类型字段(如 cost)逐字段合并,其余字段整体替换。

这个设计让你可以给一个走代理的内置模型改价格和上下文长度,而不用把它的模型列表重抄一遍——它还能作用于通过已验证代理访问的模型。

只改 baseUrl 就是走代理

想让内置供应商走自己的代理,只需要改一个字段:

json
{
  "providers": {
    "anthropic": { "baseUrl": "https://proxy.example.com/v1" }
  }
}

模型和认证逻辑原封不动。这是改端点最省事的形式。

常用模型字段与默认值

字段类型默认说明
id
string
必填
发给 API 的模型 ID
name
string
显示名,也被 --model 的模式匹配
api
string
继承供应商
覆盖协议
baseUrl
string
继承供应商
覆盖端点,优先级高于供应商级
reasoning
boolean
false
是否支持思考
input
("text" | "image")[]
["text"]
支持的输入模态
contextWindow
number
128000
上下文窗口(token)
maxTokens
number
16384
最大输出 token
promptCache
object
short / long 两档的缓存生命周期(秒)
samplingParams
object
合并进每次请求体,仅 OpenAI 兼容协议生效
compat
object
兼容开关,模型级覆盖供应商级

两个默认值需要单独说,因为它们直接影响行为: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),挑几个说明它们各自解决什么问题:

开关解决什么
maxTokensField
用 max_tokens 还是 max_completion_tokens。默认从 URL 自动探测,可以覆盖
supportsDeveloperRole
用 developer 角色还是 system
thinkingFormat
思考参数怎么发,共 11 种取值,默认从 URL 探测
requiresThinkingAsText
思考块必须转成 <thinking> 包裹的文本块
requiresReasoningContentOnAssistantMessages
回放历史时每条 assistant 消息都要带空 reasoning_content
supportsMidConvoSystemMessages
这个具体模型是否接受对话中途的 system 消息。不支持时 Pi 会把后续 system 消息折叠进开头那条
supportsMidConvoToolAdditions
system 消息能否在对话中途引入新工具,依赖上面那个
supportsStrictMode
工具定义里的 strict 字段
sendSessionAffinityHeaders
发送会话亲和头,让相关请求落到同一副本以命中 prompt cache
thinkingTokenBudgetField
用哪个顶层字段限制思考 token:thinking_token_budget(vLLM)、thinking_budget(Qwen/DashScope/SGLang)、thinking_budget_tokens(llama.cpp)

supportsMidConvoSystemMessages 和 supportsMidConvoToolAdditions 是模型级开关而不是供应商级。同一个供应商的不同模型能力不一样,目录只在验证过的模型上打开它们。

图像缩放与 prompt cache

inputLimits.images.resize 控制新图像进入会话历史前的缩放。省略时的保守默认是 2000×2000 像素、编码后 4.5 MiB、JPEG 质量 80(docs/models.md:102,生成脚本里的 DEFAULT_IMAGE_RESIZE 与之一致)。

json
{
  "id": "vision-model",
  "input": ["text", "image"],
  "inputLimits": {
    "images": {
      "resize": { "maxWidth": 1568, "maxHeight": 1568, "maxBytes": 524288, "jpegQuality": 75 }
    }
  }
}

maxBytes 限制的是 base64 编码之后的体积,不是原始文件。图像只编码一次——换模型不会重写历史图像。

promptCache 声明供应商尽力而为的缓存生命周期,单位是秒:

json
{ "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 给了一张「选最小的集成」表,这张表的判断顺序值得记住:

需求用什么
在已支持的协议后面加模型
models.json
改已有供应商的端点或请求头
models.json 或一个小扩展
动态发现模型
带 refreshModels 的供应商
加一个 /login 流程
带 OAuth 配置的供应商
实现一个没人用过的线上协议
带 stream 或 streamSimple 的供应商

按这张表从上往下问,能避免 90% 的过度工程。第一行覆盖的场景最多——只要对方说的协议 Pi 已经支持,你写的那 42 行配置就够了。

注册供应商

在扩展工厂里调 pi.registerProvider()。Pi 会等异步工厂完成才继续启动,所以在那儿注册的供应商对启动期的模型选择和 pi --list-models 都可见(docs/custom-provider.md:21)。

两种注册形式:

  1. 注册完整的 Provider——从 @earendil-works/pi-ai 导入,自己管原生认证、过滤、发现、刷新和流式行为。
  2. 注册名称 + 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 或共享机器,泄漏面会扩大。

虚拟模型:让「选模型」和「用模型」脱钩

虚拟模型是一个可选中的模型,但每次请求时它才决定真正用哪个物理模型。用途是按任务、成本或会话状态路由:简单问题给小模型,难题给大模型,而用户只面对一个选项。

它的核心是选择与调度分离:

选择调度
记在哪
model_change / thinking_level_change 条目
每条 assistant 消息上的 provider、api、model、thinkingLevel
表现为
ctx.model、ctx.thinkingLevel、PI_MODEL、PI_REASONING_LEVEL、/model
每次响应的 assistant 消息

原文举的例子:

text
selected (virtual model, virtual level)  ->  dispatched (physical model, physical level)
jev/auto:low                             ->  anthropic/claude-sonnet-4-5:high

注册长这样:

typescript
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):

text
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)——约束没了,但工具还能用。

什么会丢,什么不会

跨供应商切换后状态
对话历史、工具调用、工具结果
完整保留
token 计数与成本
保留,按各自模型的定价重新折算
思考块文本
通常降级为文本
思考签名
只有同供应商同模型保留
prompt cache
丢失(docs/virtual-models.md:84)
会话记录的模型/思考等级
保留,恢复会话时还原,但不改新会话默认值

prompt cache 丢失是缓存的物理性质:不同供应商的 KV cache 不共享。所以跨供应商切换等于重新付一次全量输入的价格。在一个长会话里反复横跳的开销是可观的——这也是虚拟模型的 route() 建议在 continuation 时返回 previous 的原因之一。

恢复会话时的回退

如果会话记录的模型(可能是虚拟模型)已经不再注册了,Pi 会回退到上一次实际回答的物理模型(docs/virtual-models.md:25)。这个设计让扩展卸载后旧会话仍然可读。

AssistantMessage 记的是物理模型而不是虚拟模型,所以在虚拟模型底下换底层模型,历史照样能正常回放——和手动切模型是同一套机制。

4.8 事实来源

断言来源
42 个内置供应商
packages/ai/src/types.ts:87-129 的 KnownProvider 联合类型
10 种聊天协议
packages/ai/src/types.ts:61-71 的 KnownApi
1 种图像协议 openrouter-images
packages/ai/src/types.ts:75 的 KnownImageApi
4 种分类协议
packages/ai/src/types.ts:79-83 的 KnownClassifierApi
三种模型类型、chat 的 type 可省略
packages/ai/src/types.ts:888-892 ModelTypeMap;types.ts:845
各协议的供应商归属
各 packages/ai/src/providers/<id>.ts 的 Provider<...> 返回类型
Api = KnownApi | (string & {})
packages/ai/src/types.ts:73
API 实现模块的统一契约
packages/ai/src/types.ts:282-324 ApiOptionsMap / ProviderStreams
typesafe 只有分类协议
packages/ai/src/providers/typesafe.ts(仅注册 classifiers)
41 个供应商有默认启动模型
packages/coding-agent/src/core/model-resolver.ts:20 defaultModelPerProvider
模型记录字段与 compat 条件类型
packages/ai/src/types.ts:825-899(BaseModel / Model / ModelTypeMap)
compat 开关覆盖序列化/能力/产品三层
docs/models-configuration.md:109-175
模型目录由生成脚本产出
packages/ai/src/models.generated.ts 文件头;scripts/generate-models.ts
按类型展平目录
packages/ai/src/model-catalog.ts:53-60 flattenModelCatalog
构建期数据源
scripts/generate-models.ts:1835(models.dev)、:1353-1367(OpenRouter 三分片)、:1334(NVIDIA)、:1404(Vercel AI Gateway)、:1383(Radius)
目录过滤工具调用模型、OpenAI 长上下文溢价
scripts/generate-models.ts(tool_call !== true 各处;:402 OPENAI_LONG_CONTEXT_INPUT_THRESHOLD)
运行时从 pi.dev 刷新
packages/coding-agent/src/core/remote-catalog-provider.ts:14-16,81-82,104
4 小时节流、4 秒超时、ETag 复验、304/404/501 处理
同上 :15-16,135-172
缓存结构与文件位置
packages/ai/src/models-store.ts:3-15;packages/coding-agent/src/core/models-store.ts:52 models-store.json
刷新分「先缓存后网络」两阶段
packages/ai/src/models.ts:582-589
OpenRouter 按 key 过滤模型
docs/providers.md:120-122;remote-catalog-provider.ts:195 withPersonalizedCatalog
认证只有 api_key 与 oauth
packages/ai/src/auth/types.ts:17-37,117
凭据解析顺序
docs/models.md:23;packages/ai/src/auth/resolve.ts:89-92
已存凭据屏蔽环境变量
packages/ai/src/auth/resolve.ts:28-31
凭据 env 优先于进程环境
packages/ai/src/auth/resolve.ts:95-100 overlayEnvAuthContext;docs/providers.md:90-102
OAuth 五分钟余量、15 秒刷新超时、双重检查锁
packages/ai/src/auth/resolve.ts:102-103,105-118,169
auth.json 的 !command 与进程内缓存
docs/providers.md:71-84
9 个供应商声明 OAuth、7 个订阅制
grep -n "oauth:" packages/ai/src/providers/*.ts;各文件 isSubscription 字段
设备码 / PKCE / 浏览器三选一
packages/ai/src/auth/oauth/{device-code,anthropic,openai-chatgpt,openai-codex,radius,openrouter}.ts
Meta 的身份令牌与铸造 key
packages/ai/src/auth/oauth/meta.ts:9-13,25,159-164
OpenRouter OAuth 返回常驻 key
packages/ai/src/auth/oauth/openrouter.ts 文件头注释
Bedrock / Vertex 环境凭据
docs/providers.md:176-247
Azure 变量与 azure 供应商 ID
docs/providers.md:136-174
Anthropic 联合认证变量
docs/providers.md:67-69
Decisions API 因 OAuth 登录而不可见
docs/models.md:157
七个思考等级与默认 medium
docs/cli.md:70;packages/coding-agent/src/core/defaults.ts:3
思考等级解析顺序
packages/coding-agent/src/core/model-resolver.ts:571
thinkingLevelMap 语义
docs/models-configuration.md:37;docs/models.md:116
11 种 thinkingFormat
docs/models-configuration.md:123
会话记录模型与等级变更
docs/models.md:35
命令行模型参数
docs/cli.md:62-75
交互式命令
packages/coding-agent/src/core/slash-commands.ts:19-44;docs/models.md:29-33
models.json 位置与字段
docs/configuration.md:16;docs/models-configuration.md:11-103
contextWindow 默认 128000、maxTokens 默认 16384
docs/models-configuration.md:52-53
值解析语法与 models.json 命令不缓存
docs/models.md:65
models 替换 vs modelOverrides 合并
docs/models.md:67;docs/models-configuration.md:61
图像缩放默认 2000×2000 / 4.5 MiB / 质量 80
docs/models.md:102;scripts/generate-models.ts:431-436
Anthropic 缓存 300 / 3600 秒
scripts/generate-models.ts:1022
缓存预热 $0.05 阈值
docs/settings.md:22-23
samplingParams 的协议限制与合并顺序
docs/models.md:138
兼容开关须基于验证
docs/models.md:140;docs/models-configuration.md:109-139
自定义供应商集成选择表
docs/custom-provider.md:9-15,21,30,110-146
供应商扩展运行在 Pi 进程内
docs/custom-provider.md:5
虚拟模型选择与调度分离
docs/virtual-models.md:20-29,65,84
虚拟模型默认值与 ID 约束
docs/virtual-models.md:55-61
图像/分类模型不进入 /model
docs/models.md:159,186
模型选择故障排查四条
docs/models.md:205-221
llama.cpp router 启动参数与模型目录布局
docs/llama-cpp.md:9-44
/login llama.cpp、LLAMA_BASE_URL、HF token 查找顺序
docs/llama-cpp.md:48-83
llama.cpp 的 decision 模型与 chat-as-classifier
docs/llama-cpp.md:89-105
自定义供应商的凭据语法与原生 OAuth 要求
docs/custom-provider.md:76-89
思考签名与供应商绑定
packages/ai/src/api/google-shared.ts:160-161,233;packages/ai/src/api/anthropic-messages.ts:1452-1465
会话消息记录物理模型
packages/ai/src/types.ts:570-601;docs/virtual-models.md:25

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。