青雲的博客
深入浅出 Pi 第二部:模型请求不是一次 fetch 第 08 章

密钥与 OAuth 怎样落到一次请求

从 CredentialStore、认证策略和 OAuth 锁内刷新追到 Models.prepareRequest,说明持久化凭据如何在请求时变成 apiKey、headers 与 baseUrl。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

模型条目选定后,Pi 仍不会把 auth.json 的内容原样塞进请求。存储层保存的是按 provider id 索引的 ApiKeyCredential | OAuthCredential;请求层需要的 ModelAuth 只有 apiKeyheadersbaseUrl。两种结构之间隔着 provider 自己的认证策略,因为 AWS profile、ADC、订阅 OAuth 和普通 bearer key 无法用同一段字符串替换完成。

存储值还不是请求认证

静态部分是 provider 声明的 ApiKeyAuthOAuthAuth:前者负责从存储值与 ambient 环境解析 AuthResult,后者把刷新网络动作与 toAuth() 的无副作用转换拆开。动态部分直到请求开始才执行,顺序也很严格。

请求时才确定优先级

flowchart TD
  accTitle: 一次请求的认证解析顺序
  accDescr: 显式请求密钥优先,其次读取 provider 对应的存储凭据,只有未存储时才查询环境等 ambient 来源
  O["request options.apiKey"] -->|已提供且 provider 支持| A["AuthResult"]
  O -->|未提供| S["CredentialStore.read(providerId)"]
  S -->|OAuth| L["锁内复查并按需 refresh"]
  S -->|API key| K["provider apiKey.resolve"]
  S -->|没有条目| E["ambient env / profile / file"]
  L --> A
  K --> A
  E --> A
  A --> M["合并 request headers / env / baseUrl"]
  M --> P["provider stream"]

“拥有”很重要:若已经存有一种 provider 不支持的凭据类型,解析会返回未配置,不会悄悄改用环境变量;OAuth 刷新失败同样不会遮掩。临近过期的 OAuth token 会进入 CredentialStore.modify(),在锁内重新检查,再只刷新一次并持久化轮换后的凭据。默认提前五分钟触发这条路径。

状态查询和请求解析也不是同一个动作。CredentialStore.list() 只允许返回 provider id 与 credential type,契约特别要求不能为了列举账号而执行配置中的取 key 命令;真正需要秘密值时才调用 getAuth()。这让模型选择界面可以显示“存有凭据”,却不会因为刷新列表就触发 OAuth 网络请求或把 key 暴露给展示层。是否最终可用仍以请求时解析为准。

显式 options.apiKey 的优先级还有一个条件:provider 必须声明 API-key handler,Pi 才知道如何把它变成请求认证。对只走另一种认证机制的 provider,任意字符串不能绕过策略。已存 API key 也可能只带 provider-scoped env,由 handler 继续从环境补齐 key;所以存储对象为空字段并不自动等于认证失败。

解析完成后,Models.applyAuth() 才建立本次请求副本。显式 options 的 apiKey 和 headers 按字段胜出,认证得到的 baseUrl 写入 request model,provider-scoped env 被合并;原始 catalog model 不被永久改写。随后 lazyStream 才调用 provider,认证错误也会进入统一流的失败终态。

headers 还会经历两次有意的合并。getAuth(model) 先把模型条目自带 headers 叠到 provider 认证结果上,applyAuth() 再让请求级 headers 覆盖;配置中的 null 可用于压掉同名默认头。这个顺序既支持网关或特定模型的静态头,也给一次调用保留最终决定权。baseUrl 则通过 model 副本覆盖,避免一次 OAuth 账号的 endpoint 污染共享目录。

coding-agent 的 RuntimeCredentials 还能叠加不持久化的 provider key;读取时 runtime override 优先,modify 仍交给底层 store。文件存储只负责持久化与锁,源码也明确把认证编排留给 ModelRuntimeModels;新建 auth.json 时权限为 0600

实验不读取真实凭据

下面的实验只读固定源码,故意不打开任何真实凭据文件:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/ai/src/auth/resolve.ts |
  nl -ba | sed -n '42,86p;95,145p'
git -C "$repo" show v0.83.0:packages/ai/src/models.ts |
  nl -ba | sed -n '463,517p'

依赖已安装时,packages/ai 下的 node ../../node_modules/vitest/dist/cli.js --run test/models-runtime.test.ts 使用内存 store 覆盖显式 key、错误凭据类型和并发 OAuth 刷新。认证材料落定以后,请求还要把通用 Context 与 options 收紧成某个 API 真正接受的 payload。