Skip to content

第 10 章 · 认证解析与凭据管理

本章目标:理解 pi-ai 的认证解析顺序,掌握环境变量、CredentialStore 持久化与 transformHeaders 请求头变换。

10.1 每个 Provider 自管认证

pi-ai 没有中心化的认证服务——认证属于 Provider。每个 Provider 决定:API Key 从哪来(存储的凭据、环境变量、AWS profile 等环境源)、如何刷新 OAuth、失败时怎么报错。

10.2 解析优先级

调用 models.stream() 时认证按以下顺序合并:

text
显式传入 options.apiKey          ← 永远最高
        ▼ 未提供则
Provider 内部解析:
  1. CredentialStore 中该 provider 的存储凭据
  2. 环境变量(如 ANTHROPIC_API_KEY)
  3. 环境源(AWS profile / gcloud ADC 等)
typescript
// 方式一:全自动 —— Provider 从环境变量解析(最常用)
await models.complete(model, context);

// 方式二:显式指定 —— 覆盖一切其他来源(多租户/代理场景)
await models.complete(model, context, { apiKey: "sk-explicit" });

存储凭据「独占」Provider

一旦某 Provider 在凭据库中存了凭据,环境变量不再被咨询;OAuth 刷新失败也不会静默回退到 env key——这是刻意的安全设计:避免旧 token 失效后悄悄降级到另一套身份。

10.3 检查认证状态(不发请求)

getAuth() 可以在不发请求的情况下检查配置情况:

typescript
// provider 级或 model 级两种重载
const auth = await models.getAuth(model);

if (auth) {
  console.log(`配置来源: ${auth.source}`);   // 如 "ANTHROPIC_API_KEY"、"OAuth"
  console.log(auth.auth.headers);           // 将被合并的请求头
} else {
  console.log("未配置任何凭据");
}

返回 undefined 表示未配置;若凭据损坏(如 OAuth 刷新失败)会抛出 ModelsError 并保留原凭据供重新登录。

10.4 CredentialStore:持久化你的密钥

默认是内存实现——进程重启即失。生产应用应注入持久化存储:

typescript
import {
  createModels,
  type CredentialStore,
} from "@earendil-works/pi-ai";
import fs from "node:fs/promises";

// 实现最小契约:read / list / modify / delete
const fileStore: CredentialStore = {
  async read(providerId) {
    const all = JSON.parse(await fs.readFile("creds.json", "utf-8").catch(() => "{}"));
    return all[providerId];
  },
  async list() {
    const all = JSON.parse(await fs.readFile("creds.json", "utf-8").catch(() => "{}"));
    // 只暴露非敏感元数据:providerId 与 type
    return Object.entries(all).map(([providerId, c]) => ({
      providerId,
      type: (c as any).type,
    }));
  },
  // 唯一的写入路径:串行化读改写(OAuth 刷新也走这里,防止并发双刷)
  async modify(providerId, fn) {
    const all = JSON.parse(await fs.readFile("creds.json", "utf-8").catch(() => "{}"));
    all[providerId] = await fn(all[providerId]);
    await fs.writeFile("creds.json", JSON.stringify(all, null, 2));
  },
  async delete(providerId) {
    const all = JSON.parse(await fs.readFile("creds.json", "utf-8").catch(() => "{}"));
    delete all[providerId];
    await fs.writeFile("creds.json", JSON.stringify(all, null, 2));
  },
};

// 注入集合;builtinModels() 接受相同选项
const models = createModels({ credentials: fileStore });

10.5 transformHeaders:最终请求头变换

在认证头、模型头、显式头全部合并之后、真正发出之前,还有一道变换机会:

typescript
const response = await models.completeSimple(model, context, {
  headers: { "X-Client": "my-app" },            // 显式头(覆盖认证/模型头)
  transformHeaders: async (headers) => ({
    ...headers,
    // 注入每次请求唯一的追踪 ID
    "X-Request-ID": crypto.randomUUID(),
  }),
});

合并顺序:

text
provider 认证头 → model.headers → options.headers → transformHeaders → 发出

用它替代手动 getAuth

需要动态加签(如短期 token)时,用 transformHeaders 而不是先调 getAuth() 再手动拼——后者会导致认证被解析两次。

10.6 动态 API Key

构造 Agent 时还能传 getApiKey 钩子处理过期令牌:

typescript
const agent = new Agent({
  // 每次请求前回调,适合自动续期的 OAuth token
  getApiKey: async (provider) => refreshMyToken(provider),
  initialState: { systemPrompt: "...", model },
  streamFn: models.streamSimple.bind(models),
});

10.7 本章小结

  • 认证归 Provider 所有;优先级:显式 apiKey > 存储凭据 > 环境变量 > 环境源;
  • 存储凭据独占 Provider:env 不再兜底、刷新失败不静默降级;
  • getAuth() 可无副作用地检查配置来源;
  • 自定义 CredentialStore 四方法即可接入任意持久层,modify 是唯一写路径且防并发双刷;
  • transformHeaders 是发出前的最后一道头变换,优先于一切。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 同时设置了存储凭据和 ANTHROPIC_API_KEY 环境变量,实际会用哪个?

2. CredentialStore 中唯一允许的写入路径是?

3. transformHeaders 执行时,哪些头已经合并进来了?

4. 想在不发请求的前提下确认 Anthropic 的 key 是否已配置,应该调用?

🛠️ 动手实践

  1. 实现 localStorage 版本的 CredentialStore(浏览器端思路),并注入 createModels 验证重启后凭据仍在。
  2. 分别只设置 env、只存凭据、两者都设三种情况,用 getAuth 打印 source 对比验证优先级。
  3. 用 transformHeaders 给所有请求加 X-Trace-Id,并在服务端(或 onPayload 回调)确认其生效。

凭据无忧后,第 11 章解锁推理型模型的思考模式。