|
| 1 | +# 第十章 · Provider 与认证 |
| 2 | + |
| 3 | +> 第二章讲了 pi-ai 如何用 `stream()` 统一 30+ 厂商。这一章往下钻一层: |
| 4 | +> **一个请求的认证是怎么被解析出来的**——从环境变量、凭据存储,到 OAuth 自动刷新。 |
| 5 | +
|
| 6 | +--- |
| 7 | + |
| 8 | +## 10.1 Provider 是运行时的最小单元 |
| 9 | + |
| 10 | +第二章把注意力放在 `stream()` 和 `Model` 上。但真正**持有状态**的是 Provider: |
| 11 | + |
| 12 | +```plantuml |
| 13 | +@startuml |
| 14 | +skinparam backgroundColor transparent |
| 15 | +
|
| 16 | +rectangle "Models 集合" as M #FFF3E0 { |
| 17 | + rectangle "anthropicProvider" as P1 #E8F5E9 |
| 18 | + rectangle "openaiProvider" as P2 #E3F2FD |
| 19 | + rectangle "openrouterProvider" as P3 #F3E5F5 |
| 20 | +} |
| 21 | +
|
| 22 | +note bottom of P1 |
| 23 | + 每个 Provider 自己拥有: |
| 24 | + · 模型目录 (catalog) |
| 25 | + · 认证 (API key / OAuth) |
| 26 | + · stream 行为 |
| 27 | +end note |
| 28 | +
|
| 29 | +M --> P1 |
| 30 | +M --> P2 |
| 31 | +M --> P3 |
| 32 | +
|
| 33 | +@enduml |
| 34 | +``` |
| 35 | + |
| 36 | +一句话区分: |
| 37 | + |
| 38 | +- **Provider** = 运行时单元,拥有目录、认证、流式行为 |
| 39 | +- **Models** = 集合,把请求路由到"拥有该模型"的 Provider |
| 40 | +- **API 实现** = 底层线路协议(`anthropic-messages` / `openai-responses` / `openai-completions`),被多个 Provider 共享 |
| 41 | + |
| 42 | +所以 xAI、Groq、Cerebras、OpenRouter 等大多复用 `openai-completions` 这一套 API 实现,差异只在 Provider 层的认证和 baseUrl。 |
| 43 | + |
| 44 | +### 构建一个 Models 集合 |
| 45 | + |
| 46 | +```typescript |
| 47 | +import { createModels } from '@earendil-works/pi-ai'; |
| 48 | +import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic'; |
| 49 | +import { openaiProvider } from '@earendil-works/pi-ai/providers/openai'; |
| 50 | + |
| 51 | +const models = createModels(); |
| 52 | +models.setProvider(anthropicProvider()); |
| 53 | +models.setProvider(openaiProvider()); |
| 54 | + |
| 55 | +// 或者:一次注册所有内置 Provider(体积大,按需选择) |
| 56 | +// import { builtinModels } from '@earendil-works/pi-ai/providers/all'; |
| 57 | +// const models = builtinModels(); |
| 58 | +``` |
| 59 | + |
| 60 | +按需注册(tree-shaking 友好)还是全量注册,是**包体积**和**便利性**的取舍——见第二章 2.3 的懒加载策略。 |
| 61 | + |
| 62 | +## 10.2 认证解析的优先级 |
| 63 | + |
| 64 | +当你调用 `models.stream()`,集合会通过"拥有该模型的 Provider"解析认证,再合并进请求。**显式传入的值永远最优先**: |
| 65 | + |
| 66 | +```plantuml |
| 67 | +@startuml |
| 68 | +skinparam backgroundColor transparent |
| 69 | +skinparam ActivityBackgroundColor #f8f9fa |
| 70 | +skinparam ActivityBorderColor #dee2e6 |
| 71 | +
|
| 72 | +start |
| 73 | +:models.stream(model, ctx, options); |
| 74 | +
|
| 75 | +if (options.apiKey 显式传入?) then (yes) |
| 76 | + :直接使用显式 key; |
| 77 | + note right: 显式值胜过一切 |
| 78 | +else (no) |
| 79 | + if (CredentialStore 有存储凭据?) then (yes) |
| 80 | + :使用存储凭据; |
| 81 | + note right |
| 82 | + 存储凭据"拥有"该 Provider |
| 83 | + 有存储就不再看环境变量 |
| 84 | + end note |
| 85 | + else (no) |
| 86 | + if (环境变量已设置?) then (yes) |
| 87 | + :使用环境变量; |
| 88 | + else (no) |
| 89 | + :解析失败 → stream error; |
| 90 | + endif |
| 91 | + endif |
| 92 | +endif |
| 93 | +
|
| 94 | +:合并 headers / baseUrl; |
| 95 | +:发起请求; |
| 96 | +stop |
| 97 | +@enduml |
| 98 | +``` |
| 99 | + |
| 100 | +关键规则:**存储凭据一旦存在,就"独占"该 Provider**。环境变量只在没有存储凭据时才被查询;OAuth 刷新失败绝不会静默回退到环境变量的 key——这是防止"看似成功、实则用错身份"的安全设计。 |
| 101 | + |
| 102 | +### 不发请求也能查认证 |
| 103 | + |
| 104 | +`getAuth()` 让你在不真正调用 LLM 的情况下检查解析结果: |
| 105 | + |
| 106 | +```typescript |
| 107 | +// 传 Provider ID:Provider 级认证 |
| 108 | +const providerAuth = await models.getAuth(model.provider); |
| 109 | + |
| 110 | +// 传 model:额外并入该模型的静态 model.headers |
| 111 | +const modelAuth = await models.getAuth(model); |
| 112 | + |
| 113 | +if (modelAuth) { |
| 114 | + console.log(`configured via ${modelAuth.source}`); |
| 115 | + // e.g. "ANTHROPIC_API_KEY" / "OAuth" / "stored credential" |
| 116 | + console.log(modelAuth.auth.headers); |
| 117 | +} else { |
| 118 | + console.log('not configured'); |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +两种重载都会解析凭据、必要时刷新过期 OAuth,可能返回 `apiKey` / `headers` / `baseUrl`。未配置的 Provider 解析为 `undefined`;真正坏掉时抛 `ModelsError`(`"oauth"`=刷新失败但保留凭据以便重登,`"auth"`=key 解析或存储故障)。请求路径会把同样的失败作为 stream error 暴露。 |
| 123 | + |
| 124 | +## 10.3 环境变量:最省事的入口 |
| 125 | + |
| 126 | +Node 环境下,内置 Provider 会自动读取约定的环境变量(浏览器里没有环境变量,需显式传 `apiKey`): |
| 127 | + |
| 128 | +| Provider | 环境变量 | |
| 129 | +|----------|---------| |
| 130 | +| OpenAI | `OPENAI_API_KEY` | |
| 131 | +| Anthropic | `ANTHROPIC_API_KEY` 或 `ANTHROPIC_OAUTH_TOKEN` | |
| 132 | +| Google | `GEMINI_API_KEY` | |
| 133 | +| DeepSeek | `DEEPSEEK_API_KEY` | |
| 134 | +| xAI | `XAI_API_KEY` | |
| 135 | +| Groq | `GROQ_API_KEY` | |
| 136 | +| OpenRouter | `OPENROUTER_API_KEY` | |
| 137 | +| Mistral | `MISTRAL_API_KEY` | |
| 138 | + |
| 139 | +> 完整表格见 [pi-ai README](https://github.com/earendil-works/pi/blob/main/packages/ai/README.md#environment-variables)。Amazon Bedrock 解析环境里的 AWS 凭据链(`AWS_PROFILE`、access key、`AWS_BEARER_TOKEN_BEDROCK` 等);Vertex AI 解析显式 key 或 gcloud ADC + 项目/区域。 |
| 140 | +
|
| 141 | +## 10.4 CredentialStore:凭据存储 |
| 142 | + |
| 143 | +交互式输入的 API key、OAuth token 都存在 `CredentialStore` 里——每个 Provider 一条带类型标签的凭据。pi-ai 默认给一个内存实现,应用注入持久化实现: |
| 144 | + |
| 145 | +```typescript |
| 146 | +import { createModels, type CredentialStore } from '@earendil-works/pi-ai'; |
| 147 | + |
| 148 | +const models = createModels({ credentials: myFileBackedStore }); |
| 149 | +``` |
| 150 | + |
| 151 | +接口很小,只有五个动作: |
| 152 | + |
| 153 | +```typescript |
| 154 | +interface CredentialStore { |
| 155 | + read(providerId): Promise<Credential | undefined>; |
| 156 | + list(): Promise<Array<{ providerId; type }>>; // 只返回非敏感元数据 |
| 157 | + modify(providerId, fn): Promise<void>; // 唯一写入路径 |
| 158 | + delete(providerId): Promise<void>; |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +三个设计要点: |
| 163 | + |
| 164 | +1. **`modify` 是唯一写入路径**——它是一次串行化的 read-modify-write。OAuth token 刷新就跑在 `modify` 里,所以并发请求、多个进程都不会重复刷新同一个已轮换的 token。 |
| 165 | +2. **`list()` 不得解析 secret**,也不能执行配置的 key 命令——枚举必须廉价且安全。 |
| 166 | +3. API key 凭据用和 pi 的 `auth.json` 相同的判别式,可携带 Provider 作用域的 env/config 值(如 `CLOUDFLARE_ACCOUNT_ID`)。 |
| 167 | + |
| 168 | +## 10.5 OAuth:登录与自动刷新 |
| 169 | + |
| 170 | +部分 Provider 支持 OAuth,而非静态 API key: |
| 171 | + |
| 172 | +- **Anthropic**(Claude Pro/Max 订阅) |
| 173 | +- **OpenAI Codex**(ChatGPT Plus/Pro 订阅) |
| 174 | +- **GitHub Copilot**(Copilot 订阅) |
| 175 | +- **OpenRouter**(PKCE 流程,铸造一个用户可控的 API key) |
| 176 | + |
| 177 | +每个这样的 Provider 在 `provider.auth.oauth` 上带一个 `OAuthAuth`,有三个操作: |
| 178 | + |
| 179 | +```plantuml |
| 180 | +@startuml |
| 181 | +skinparam backgroundColor transparent |
| 182 | +
|
| 183 | +rectangle "OAuthAuth" { |
| 184 | + rectangle "login(interaction)" as L #E8F5E9 |
| 185 | + rectangle "refresh(credential)" as R #E3F2FD |
| 186 | + rectangle "toAuth(credential)" as T #FFF3E0 |
| 187 | +} |
| 188 | +
|
| 189 | +note bottom of L: 用中立的 prompt/notify 协议\n驱动登录,返回凭据 |
| 190 | +note bottom of R: 刷新即将过期的凭据\n(OpenRouter 是 no-op) |
| 191 | +note bottom of T: 从凭据派生请求认证\n(Copilot 的 baseUrl 来自这里) |
| 192 | +
|
| 193 | +@enduml |
| 194 | +``` |
| 195 | + |
| 196 | +**刷新是自动的**:`models.getAuth(providerId)` 和请求路径都会在凭据存储的锁内刷新过期 token,所以并发请求、多进程都不会重复刷新。OpenRouter 因为拿到的是永久 key,其 refresh 是空操作。 |
| 197 | + |
| 198 | +驱动登录的入口是 `models.login()`: |
| 199 | + |
| 200 | +```typescript |
| 201 | +const models = createModels({ credentials: myStore }); // 持久化 store |
| 202 | +models.setProvider(anthropicProvider()); |
| 203 | + |
| 204 | +await models.login('anthropic', 'oauth', { |
| 205 | + prompt: async (p) => { |
| 206 | + // p.type: 'text' | 'secret' | 'select' | 'manual_code' |
| 207 | + return await askUser(p.message); |
| 208 | + }, |
| 209 | + notify: (event) => { |
| 210 | + if (event.type === 'auth_url') console.log(`打开: ${event.url}`); |
| 211 | + if (event.type === 'device_code') |
| 212 | + console.log(`Code: ${event.userCode} at ${event.verificationUri}`); |
| 213 | + }, |
| 214 | +}); |
| 215 | + |
| 216 | +// 之后请求自动解析并刷新 token |
| 217 | +await models.complete(models.getModel('anthropic', 'claude-sonnet-4-5')!, ctx); |
| 218 | + |
| 219 | +// 登出 |
| 220 | +await models.logout('anthropic'); |
| 221 | +``` |
| 222 | + |
| 223 | +CLI 里最快的登录方式: |
| 224 | + |
| 225 | +```bash |
| 226 | +npx @earendil-works/pi-ai login # 交互式选择 Provider |
| 227 | +npx @earendil-works/pi-ai login anthropic # 指定 Provider |
| 228 | +``` |
| 229 | + |
| 230 | +凭据保存到当前目录的 `auth.json`。 |
| 231 | + |
| 232 | +## 10.6 跨厂商 Handoff |
| 233 | + |
| 234 | +Pi 支持在**同一段对话中途切换 Provider**,同时保留上下文——包括 thinking block、工具调用、工具结果。当一个 Provider 的消息发给另一个 Provider 时,库会自动做兼容转换: |
| 235 | + |
| 236 | +```plantuml |
| 237 | +@startuml |
| 238 | +skinparam backgroundColor transparent |
| 239 | +
|
| 240 | +participant "用户" as U |
| 241 | +participant "Claude\n(anthropic)" as C |
| 242 | +participant "GPT-5\n(openai)" as G |
| 243 | +participant "Gemini\n(google)" as Ge |
| 244 | +
|
| 245 | +U -> C: "25 * 18 = ?" |
| 246 | +C --> U: [thinking] + "450" |
| 247 | +U -> G: "算得对吗?" |
| 248 | +note right of G |
| 249 | + Claude 的 thinking 被转为 |
| 250 | + <thinking> 标签文本 |
| 251 | +end note |
| 252 | +G --> U: "对" |
| 253 | +U -> Ge: "原题是什么?" |
| 254 | +Ge --> U: "25 * 18" |
| 255 | +
|
| 256 | +@enduml |
| 257 | +``` |
| 258 | + |
| 259 | +转换规则: |
| 260 | + |
| 261 | +| 消息类型 | 处理方式 | |
| 262 | +|---------|---------| |
| 263 | +| user / toolResult 消息 | 原样透传 | |
| 264 | +| 同 Provider/API 的 assistant 消息 | 原样保留 | |
| 265 | +| **不同 Provider** 的 assistant 消息 | thinking block 转为 `<thinking>` 标签文本 | |
| 266 | +| 工具调用与普通文本 | 原样保留 | |
| 267 | + |
| 268 | +这让"先用快模型、复杂推理再切强模型"或"在 Provider 故障时切换"成为可能。所有 Provider 都能消费其他 Provider 的消息,包括带部分内容的 aborted 消息。 |
| 269 | + |
| 270 | +## 10.7 自定义 Provider |
| 271 | + |
| 272 | +本地推理服务器(Ollama、vLLM、LM Studio)或任何 OpenAI/Anthropic 兼容端点,用 `createProvider()` 接入: |
| 273 | + |
| 274 | +```typescript |
| 275 | +import { createModels, createProvider, envApiKeyAuth } from '@earendil-works/pi-ai'; |
| 276 | +import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'; |
| 277 | + |
| 278 | +const ollama = createProvider({ |
| 279 | + id: 'ollama', |
| 280 | + name: 'Ollama', |
| 281 | + baseUrl: 'http://localhost:11434/v1', |
| 282 | + // 无 key 的本地服务:resolve 返回空认证即可 |
| 283 | + auth: { apiKey: { name: 'Ollama', resolve: async () => ({ auth: {} }) } }, |
| 284 | + models: [ollamaModel], |
| 285 | + api: openAICompletionsApi(), |
| 286 | +}); |
| 287 | + |
| 288 | +const models = createModels(); |
| 289 | +models.setProvider(ollama); |
| 290 | +``` |
| 291 | + |
| 292 | +有真实 key 的 Provider 用 `envApiKeyAuth(displayName, envVars)` 得到标准行为(存储凭据优先,然后第一个命中的环境变量): |
| 293 | + |
| 294 | +```typescript |
| 295 | +const proxy = createProvider({ |
| 296 | + id: 'my-proxy', |
| 297 | + auth: { apiKey: envApiKeyAuth('My proxy API key', ['MY_PROXY_API_KEY']) }, |
| 298 | + models: [/* ... */], |
| 299 | + api: openAICompletionsApi(), |
| 300 | +}); |
| 301 | +``` |
| 302 | + |
| 303 | +### compat:抹平兼容差异 |
| 304 | + |
| 305 | +`openai-completions` 被很多 Provider 实现,细节各有不同。默认会按 baseUrl 自动检测;未知端点可用 `compat` 字段覆盖: |
| 306 | + |
| 307 | +```typescript |
| 308 | +interface OpenAICompletionsCompat { |
| 309 | + supportsStore?: boolean; // 是否支持 store 字段 |
| 310 | + supportsDeveloperRole?: boolean; // developer 角色 vs system |
| 311 | + supportsReasoningEffort?: boolean; // reasoning_effort |
| 312 | + maxTokensField?: 'max_completion_tokens' | 'max_tokens'; |
| 313 | + thinkingFormat?: 'openai' | 'deepseek' | 'qwen' | /* ... */; |
| 314 | + // ...更多 |
| 315 | +} |
| 316 | +``` |
| 317 | + |
| 318 | +常见场景:Ollama、vLLM、SGLang 等不认识 reasoning 模型用的 `developer` 角色,就设 `compat.supportsDeveloperRole: false`,让 system prompt 作为 `system` 消息发送。 |
| 319 | + |
| 320 | +## 10.8 本章检查清单 |
| 321 | + |
| 322 | +- [ ] 能区分 Provider、Models、API 实现三者的职责 |
| 323 | +- [ ] 说得出认证解析的优先级:显式 > 存储凭据 > 环境变量 |
| 324 | +- [ ] 理解"存储凭据独占 Provider"这条安全规则 |
| 325 | +- [ ] 知道 `modify` 为什么是 CredentialStore 唯一写入路径 |
| 326 | +- [ ] 理解 OAuth 自动刷新如何避免并发重复刷新 |
| 327 | +- [ ] 知道跨厂商 handoff 时 thinking block 如何被转换 |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +**上一章**:[第九章 · 扩展系统](09-extension-system.md) |
| 332 | +**下一章**:[第十一章 · Session 树与上下文构建](11-session-tree.md) — 对话历史如何组织成树 |
0 commit comments