Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

模型与认证:凭据、模型目录与模型解析

TL;DR:Pi 把"用哪个模型"拆成三件互不越界的事。凭据存储(AuthStorage,落在 auth.json)只负责安全地存取 API key 和 OAuth token;模型目录(ModelRuntime,对扩展暴露为 ModelRegistry)把 pi-ai 内置目录、pi.dev 远程目录、models.json 和扩展注册的 provider 合成一张表,并判断哪些模型"有凭据可用";模型解析(model-resolver)把 --model sonnet:high、默认设置、enabledModels 这类输入变成一个具体模型和 thinking level。每次请求时再按"运行时 --api-key → auth.json → models.json → 环境变量"现取凭据,OAuth 快过期就在文件锁里刷新一次。0.99 起新增的虚拟模型(实验性)让扩展在每次请求前把用户选中的模型路由到一个物理模型,选择和实际调用分开记录。

整体:三件事,一个运行时对象

一个 coding agent 发请求前要回答三个问题:

  1. 凭据在哪:Anthropic 的 key 可能在环境变量里,也可能是 /login 存下的 OAuth token,或是这次 --api-key 临时给的。
  2. 有哪些模型、各有什么能力:上下文窗口、价格、是否支持 reasoning、走哪种 API 协议、baseUrl 是什么。
  3. 用户这次想用哪个:用户输入的是 sonnet 这样的模糊名字,或者什么都没写。

它们的变化频率和信任级别都不同。凭据是机密,必须加锁写、不能进日志;模型目录是公开数据,可以联网刷新、可以缓存;解析是纯函数,输入字符串和模型列表,输出选择。Pi 让三者各自独立:

AuthStorage (auth.json)          凭据:只存取,不判断优先级
      │  作为 CredentialStore 注入
      ▼
ModelRuntime                     目录 + 可用性 + 每次请求的认证解析
  ├─ pi-ai 内置 provider(叠加 pi.dev 远程目录,缓存在 models-store.json)
  ├─ models.json(自定义 provider / 覆盖)
  ├─ 扩展 registerProvider / registerVirtualModel
  └─ 运行时 API key 覆盖(内存,不落盘)
      ▲
      │  getModels / hasConfiguredAuth
model-resolver                   --model / 默认 / enabledModels / 恢复会话 → (model, thinkingLevel)

它们都运行在 Pi 进程内,不是独立服务。ModelRuntime 在 createAgentSessionServices() 里每个 runtime 创建一个,AgentSession 发请求时通过它拿到 provider 和凭据。

1.x 的结构变化值得在面试里提一句:旧版 AuthStorage 自己有 getApiKey(),把运行时覆盖、auth.json、环境变量、models.json 兜底的优先级全部写在里面;ModelRegistry 则自己加载 models.json。1.x 把"认证怎么解析"下沉到了 pi-ai 的 Models 集合(resolveProviderAuth),每个 provider 自己声明支持哪些认证方式(API key 解析器、OAuth 的 login/refresh)。AuthStorage 退化成一个纯粹的 CredentialStore(read / modify / list / delete),运行时覆盖由外面一层 RuntimeCredentials 叠加。coding-agent 内部直接用 ModelRuntime,ModelRegistry 只是保留给扩展的同步兼容外观(ctx.modelRegistry.find()、getAvailable())。这样 SDK 用户直接用 pi-ai,也能得到和 Pi 完全一样的认证行为。

凭据:AuthStorage 只管存,优先级在解析时定

auth.json 是一个以 provider id 为键的 JSON,值有两种:{type: "api_key", key} 和 {type: "oauth", access, refresh, expires, ...}。AuthStorage 的职责只有三点:

  • 并发安全的读写。与 settings 一样用 proper-lockfile,但凭据修改是异步的 modify(provider, fn):在锁内读最新内容、交给 fn 算出新凭据、写回后再释放锁。读操作按文件 revision 判断是否需要重读,同一路径的多个实例共享一份读缓存。
  • 值引用延迟解析。存进去的 key 可以是 $ENV_NAME 或 !command 这类引用,读取时才用 resolveConfigValue() 解析。/login 存原值,真正发请求时才执行命令或展开环境变量,所以密码管理器里的 key 轮换后不需要重新 login。
  • 可替换后端:文件、内存(AuthStorage.inMemory(),给 SDK 和测试用)、只读包装。

凭据优先级在每次请求时由 pi-ai 的 resolveProviderAuth() 决定:

  1. 运行时覆盖:--api-key 通过 setRuntimeApiKey() 放进内存,不落盘。
  2. auth.json 中存储的凭据:OAuth 走刷新逻辑,API key 走解析器。
  3. models.json 里该 provider 配置的 apiKey / headers(由 provider-composer 叠加进 provider 的认证定义)。
  4. 环境变量或环境里的云凭据(ANTHROPIC_API_KEY、AWS profile、Google ADC 文件)。

有一条容易被忽略的规则:存储的凭据独占该 provider。只要 auth.json 里有这个 provider 的记录,就不会再看环境变量;OAuth 刷新失败也不会悄悄退回环境变量里的 key。原因是静默回退会让"我登录的是订阅账号"变成"其实在用按量付费的 key 扣钱",用户却毫不知情,所以宁可直接报错。

OAuth 刷新是这部分最精细的地方。token 剩余有效期不足 5 分钟时,刷新在 credentials.modify() 的锁内进行,并且进锁后会重新检查一次是否仍需刷新。这样多个请求、多个 Pi 进程同时发现 token 过期时,只有第一个真正去刷新,后面的直接读到新 token。另外,刷新一旦开始就不响应调用方的取消,只受 15 秒超时约束。很多 provider 的 refresh token 是一次性的,换到新 token 却没写盘,就等于丢掉了唯一有效的 refresh token,用户只能重新登录。

一句话:AuthStorage 是带锁的保险箱;"用哪份凭据"在每次请求时由 provider 声明的认证方式决定,并且已存储的凭据不会静默回退到环境变量。

模型目录:四个来源合成一张表

ModelRuntime 按 provider 逐个合成模型表,合成后交给 pi-ai 的 Models 集合:

  • pi-ai 内置目录:构建时生成的 provider 和模型元数据(上下文窗口、价格、reasoning 能力、API 类型)。不依赖网络,离线也能用。
  • pi.dev 远程目录:内置 provider 外面包一层远程目录,从 pi.dev 拉更新的模型数据叠加上去,结果缓存在 <agent-dir>/models-store.json,约每 4 小时刷新一次。新模型发布后不必等 Pi 发版就能选到。PI_OFFLINE 会关掉这类自动联网,pi update --models 可以强制刷新。OpenRouter 更进一步,按用户 key 的权限裁剪可见模型和价格。
  • models.json:用户的自定义层。可以新增 provider(Ollama、vLLM 这类 OpenAI 兼容端点,只需 baseUrl、api 和模型列表),也可以只给内置 provider 改 baseUrl 走代理(模型和认证保持不变),或者用 modelOverrides 改某个已有模型的元数据而不替换整个列表。打开 /model 时会重新读这个文件。1.x 为它发布了 models.schema.json。
  • 扩展:pi.registerProvider() 用配置描述一个 provider,或者注册一个完整的原生 provider(自定义流式协议、模型发现、认证流程),以及 pi.registerVirtualModel()。

合成规则是"能不叠就不叠":一个内置 provider 如果既没有 models.json 配置也没有扩展覆盖,就原样使用,保证它的 login、refresh、stream 行为和 pi-ai 完全一致;有覆盖时才交给 composeModelProvider() 叠加。叠加失败会记成该 provider 的错误,并退回原始定义,不会因此让整张表不可用。

可用和存在是两个概念。目录里有几百个模型,但 /model 和 Ctrl+P 只显示 provider 有可用认证的那部分。ModelRuntime 维护一份可用性快照:对每个 provider 并行做 checkAuth,结果分为运行时、已存储、配置、环境四种来源,供 /login 列表显示状态。登录或登出后只刷新这一个 provider 的快照,并用序号丢弃过期的并发刷新结果。

除了聊天模型,1.x 的目录里还有分类器模型(回答结构化的是非题、选择题和打分)和图像模型。它们不出现在 /model 里,模型通过 codemode 脚本调用,扩展则通过 ctx.modelRegistry.classify() 和 generateImages() 使用(见 MCP 与 Codemode)。

模型解析:从输入到 (model, thinkingLevel)

启动时选模型的优先级(main.ts 加 findInitialModel):

  1. --model(可带 --provider,或写成 provider/pattern),可加 :<thinking> 后缀;显式 --thinking 优先于后缀。
  2. 新会话且配置了 scoped models(--models 或 enabledModels)时:如果保存的默认模型在范围内就用它,否则用范围内的第一个。
  3. settings.json 里保存的 defaultProvider/defaultModel,前提是该 provider 有认证。
  4. 有认证的 provider 中,按内置的"每个 provider 的推荐默认模型"表找第一个可用的;都没有就用第一个可用模型。

thinking level 也有自己的优先级链:CLI 指定 > scoped 模式里的后缀 > modelThinkingLevels["provider/id"] > defaultThinkingLevel > 默认 medium。最终会按模型能力夹紧(clampThinkingLevel):不支持 reasoning 的模型只能是 off,不支持 max 的模型会降到它支持的最高级。Pi 的 thinking level 是一个 provider 无关的七级刻度(off / minimal / low / medium / high / xhigh / max),pi-ai 负责把它翻译成各家的参数,可能是 token budget,也可能是 effort 档位。thinkingBudgets 设置可以改各级的 budget。

scoped models 是 Ctrl+P 循环的范围。它的模式支持 glob(anthropic/*、*sonnet*:high),同一个 id 出现在多个 provider 时会全部加入并给出警告。模式里没写 thinking level 时,循环到这个模型会沿用当前会话的级别,而不是重置。

恢复会话时,模型来自会话里最新的 model_change 记录(见 会话树与上下文压缩)。如果该模型已不在目录里,或者它的 provider 没有认证了,Pi 会警告并回退:先用本次启动已经确定的模型(CLI 或默认),没有的话再走推荐默认表,最后取第一个可用模型。恢复时不会改动新会话的默认设置。例如 Azure provider 改名后,旧会话就会走这条回退路径。

场景:pi --model sonnet:high 从参数到请求

  1. 构建目录。main.ts 创建 ModelRuntime:读内置目录,用 models-store.json 缓存叠加远程目录,加载 models.json;扩展加载后再补上扩展注册的 provider。同时异步做一次可用性检查。
  2. 解析 sonnet:high。resolveCliModel() 在全部模型里找,不只是有认证的那些,这样第一次使用时可以靠 --api-key 完成设置。先把整个串当 id 精确匹配,失败后再做模糊匹配,也失败。于是按最后一个冒号拆出合法的 thinking 后缀 high,对 sonnet 递归:没有精确 id,模糊匹配到所有 id 或名字里含 sonnet 的模型,可能来自 anthropic、openrouter、bedrock 等多个 provider。provider 不止一个时,用"有没有认证"做 tie-break,只保留有认证的候选;再优先取别名(claude-sonnet-4-5 优于 claude-sonnet-4-5-20250929),然后按 id 取最新。如果同一个 id 在两个都有认证的 provider 上并列,Pi 直接报 ambiguous,要求写成 provider/id,而不是按目录顺序悄悄挑一个。如果用户写了 provider/xxx 但目录里没有这个 id,Pi 会以该 provider 的默认模型为模板构造一个"自定义 id"模型并警告,这样新模型上线当天就能用。
  3. 建会话。createAgentSession() 拿到 (anthropic/claude-sonnet-x, high),按模型能力夹紧 thinking level,写入会话的 model_change 和 thinking_level_change 记录。shell 工具的子进程从 PI_PROVIDER、PI_MODEL、PI_REASONING_LEVEL 环境变量读到这一选择。
  4. 发请求。agent loop 每轮调用 ModelRuntime.streamSimple()。prepareRequest() 现取认证:没有 --api-key,就读 auth.json;里面是 OAuth 且剩余不足 5 分钟时,先在锁内刷新,否则看环境变量里的 ANTHROPIC_API_KEY。然后合并 models.json 里配置的 headers 和 baseUrl,交给 provider 的 streamSimple,由它把 high 翻译成 Anthropic 的 thinking 参数。凭据不在启动时缓存,每次请求都现取,所以长会话中途 token 过期或用户在另一个终端 /login,下一轮请求就会生效。

/login:provider 自带认证流程

/login 列出 provider 及其支持的认证方式,用户选 OAuth 或 API key。流程由 provider 自己实现(provider.auth.oauth.login),Pi 只提供交互能力:打开浏览器、显示 device code、让用户粘贴回调 URL。最后一项是为远程或 headless 机器准备的,因为那里本地回调端口收不到浏览器的跳转。拿到凭据后,pi-ai 在 credentials.modify() 的锁内写入 auth.json;ModelRuntime 随后只为这个 provider 重新合成、离线刷新模型列表、刷新可用性快照,于是 /model 立刻就能看到新模型。同一个 provider 的 login、logout、设置运行时 key 会排进同一个串行队列,避免"登录写到一半时又登出"。如果凭据已经写盘、但本地快照同步失败,Pi 会报 CredentialSynchronizationError,明确告诉用户凭据其实已经保存。/logout 只删除 auth.json 里的记录,不会取消环境变量,也不会去 provider 那边吊销 token。

虚拟模型:选择与调用分开

虚拟模型(0.99 起,标记为实验性)解决的问题是:用户只想选一个"自动"模型,背后由策略按任务、成本、对话阶段决定每次请求实际用哪个模型。扩展用 pi.registerVirtualModel({provider, id, thinkingLevels, route}) 注册,它和普通模型一样出现在 /model、--model、scoped models 和设置里。

每次请求前,Pi 调用 route(request, ctx),返回一个物理的 (model, thinkingLevel)。request.reason 说明这次请求的起因:user(用户刚发消息)、continuation(工具结果之后的后续轮)、retry(失败重试,附带失败信息)、direct(压缩摘要等 agent loop 之外的调用)。文档建议 continuation 返回 previous、retry 返回 failed,也就是一轮之内不换模型,这样 prompt cache 和 thinking 签名才不会失效。路由器可以返回可 JSON 序列化的 state,Pi 把它存在会话树的当前分支上,fork 和 /tree 导航时各分支看到各自的状态。

关键设计是选择和调用分开记录:model_change 记录用户选的虚拟模型,每条 assistant 消息记录实际调用的物理模型。provider 永远只收到物理模型;上下文用量和压缩判断都按实际路由到的那个物理模型的窗口计算;恢复会话时如果虚拟模型已不存在(扩展被卸载),就回退到最后一次实际回答的物理模型。约束也很明确:虚拟模型不能路由到另一个虚拟模型;返回的模型没有凭据或 route() 抛错时,这次请求以错误结束。

常见追问

  • 为什么不把凭据放进 settings.json? 两者的信任级别和生命周期不同。设置可以分享、可以放进项目 .pi/,凭据绝不能;凭据需要锁内原子刷新,设置不需要。把它们分成两个文件,项目配置就不可能意外携带或覆盖凭据。
  • 为什么解析 --model 时用全部模型,而不是只用有认证的? 首次使用时用户往往就是靠 --api-key 提供凭据的。认证只在多个 provider 撞名时做 tie-break;带 --api-key 时认证甚至不参与选择,因为这个 key 会给选中的那个 provider 用。
  • 模型目录联网刷新会不会拖慢启动? 启动时用缓存和内置目录就能工作,联网刷新有超时,可以完全关掉(PI_OFFLINE)。目录数据不可用时最坏情况也只是看不到最新模型,不会阻塞请求。
  • 虚拟模型和在扩展里拦截请求改模型有什么区别? 虚拟模型是一等公民:它出现在选择器里,会话里有清楚的"选择/实际调用"两条记录,压缩和上下文统计都知道实际用的是哪个模型。拦截请求偷偷换模型,会让会话历史、成本统计、恢复行为都对不上。
  • SDK 嵌入时怎么隔离凭据? 向 ModelRuntime.create() 传入 credentials: AuthStorage.inMemory({...}) 或自定义的 CredentialStore,同时把 modelsPath 设为 null,这样完全不读写用户的 ~/.pi/agent。

源码入口(v1.5):packages/coding-agent/src/core/model-runtime.ts、packages/coding-agent/src/core/auth-storage.ts、packages/coding-agent/src/core/model-resolver.ts、packages/coding-agent/src/core/virtual-models.ts、packages/ai/src/auth/resolve.ts、packages/coding-agent/src/core/model-registry.ts