本文是「Pi 源码拆解」系列第 6 篇。系列目录:
- 2026
- 06-19 Pi 源码拆解(一):极简 Coding Agent Harness 的分层设计
- 06-19 Pi 源码拆解(二): Agent 运行时机制
- 06-20 Pi 源码拆解(三):Session和Context管理
- 06-20 Pi 源码拆解(四):实验性客户端/服务端会话拆分
- 06-21 Pi 源码拆解(五):pi-tui 的行数组差分渲染
- 06-21 Pi 源码拆解(六):pi-ai 的 Provider 工程:协议复用、compat 与模型 catalog(本篇)
前五篇讨论分层、运行时、会话与上下文管理、客户端/服务端拆分和终端 UI,本文分析最底层的 pi-ai。接入 37 家 provider 需要持续维护流式协议、usage 口径、错误分类、thinking 参数和模型元数据等差异。pi-ai 用共享协议实现、compat 标志、错误正则和 catalog override 组织这些差异。范围先收窄:pi-ai 只收支持 tool calling 的模型,不做工具调用的模型不进 catalog。
统一抽象的形状
统一层很小。消息只有三种:user、assistant、toolResult(packages/ai/src/types.ts:433)。流式事件 13 种:start、text/thinking/toolcall 各 start/delta/end、done、error(packages/ai/src/types.ts:501-513)。StopReason 统一成 6 个值(packages/ai/src/types.ts:391),但 AssistantMessage 同时保留 rawStopReason 存上游原始值(packages/ai/src/types.ts:411)。统一层不丢信息,跨 provider 排障时能看到 Anthropic 的 end_turn、Google 的 STOP、OpenAI 的 stop 各自的原貌。
流式接口的契约同样在这一层定死:StreamFunction 不许抛异常,请求、模型、运行时的一切失败都要编码进返回流,以 stopReason 为 error 或 aborted 的 AssistantMessage 收尾(packages/ai/src/types.ts:312-324)。这条契约是第 2 篇「失败是一等消息」的地基:运行时能把失败留在 transcript 里让模型自己看到,前提就是适配层不把失败藏在异常里。
Usage 是统一五字段加成本,但换算逐家做,这正是统一的代价所在:
- Anthropic 不给 totalTokens,要自己把 input、output、cacheRead、cacheWrite 加起来(
packages/ai/src/api/anthropic-messages.ts:740-742)。 - OpenAI Responses 的
input_tokens包含 cached 部分,要减掉(packages/ai/src/api/openai-responses-shared.ts:547-549)。 - Google 把
thoughtsTokenCount算进 output(packages/ai/src/api/google-generative-ai.ts:226-227)。 - Bedrock 流里的 totalTokens 可能缺失,缺失时退回 input 加 output(
packages/ai/src/api/bedrock-converse-stream.ts:599)。
「统一的 Usage」统一的是字段名和口径定义,每个字段仍需由对应 provider 的上游 usage 字段换算得到。
pi-ai 不自己实现 HTTP 层,而是固定官方 SDK 版本(package.json 里 @anthropic-ai/sdk 0.91.1、openai 6.26.0 等,没有 ^ 前缀),外面包一层 .lazy.ts 动态 import(packages/ai/src/api/lazy.ts:68-75),首次调用才加载对应模块,加载失败也编码成错误事件流而不是抛异常。这与 Vercel AI SDK 自己实现 fetch 层的路线不同。代价是 SDK 升级要人工核对行为差异,provider-retry.ts:22 的注释写得很直接:「Mirrors the pinned OpenAI/Anthropic SDK retry policy; review when either SDK is upgraded」。
API 实现与 Provider 分离
pi-ai 把「线缆协议」和「provider」分成两个概念。协议实现 10 个:openai-completions、openai-responses、azure-openai-responses、openai-codex-responses、anthropic-messages、google-generative-ai、google-vertex、bedrock-converse-stream、mistral-conversations、pi-messages(packages/ai/src/types.ts:16-26)。Provider 有 37 家(packages/ai/src/types.ts:34-72),每家就是 catalog 加 auth 加一组 lazy API wrapper(packages/ai/src/models.ts:75-120)。lazy wrapper 的职责比看起来多:它同步返回事件流,把 auth 解析和模块加载放到流后面异步跑,这两步任何一步失败都以 error 事件终止流(packages/ai/src/api/lazy.ts:41-61),调用方拿到的接口形状永远一致。多数 provider 不拥有自己的协议:xAI、Groq、OpenRouter、DeepSeek 全部复用 openai-completions。GitHub Copilot 是混合派发的范例:一个 provider 挂三种 API,每个模型声明自己的 api 字段,按模型派发(packages/ai/src/providers/github-copilot.ts:28-32)。
协议实现中有三个具体差异:
- Anthropic 的流式解析不走 SDK 的高层封装,pi 手写了 SSE 解码器(
packages/ai/src/api/anthropic-messages.ts:295-444),并校验 message_start 与 message_stop 成对,流提前结束就抛「stream ended before message_stop」(packages/ai/src/api/anthropic-messages.ts:482-484)。这个错误串后面会再次出现,它进了重试正则库。 - Azure 的 encrypted reasoning 只在终止事件给出,要从 response.completed 回填到之前的 reasoning block,保持 store:false 的多轮重放无状态(
packages/ai/src/api/openai-responses-shared.ts:515-532),注释里带着对应 issue 编号。 - Google 的 function call 不流式,在一个 chunk 里完整到达,而且没有 ID,要合成
名称_时间戳_计数(packages/ai/src/api/google-generative-ai.ts:185-191),再一口气补发 toolcall_start/delta/end 三个事件(packages/ai/src/api/google-generative-ai.ts:202-209)。各协议实现分别补足统一事件流需要的字段、事件或 ID。
compat 标志矩阵
不同 OpenAI-compatible 服务在 role 使用 system 或 developer、max tokens 字段、thinking 参数和流中是否包含 finish_reason 等方面存在差异。pi-ai 将这些差异放入 OpenAICompletionsCompat 的 22 个可选字段(packages/ai/src/types.ts:519-574),其中 thinking 参数格式有 10 种变体(openai、openrouter、deepseek、together、zai、qwen、chat-template 等,packages/ai/src/types.ts:541-551)。
解析分两层。先 detectCompat 按 provider 名和 baseUrl 自动探测(packages/ai/src/api/openai-completions.ts:1395-1486),实现就是一串 baseUrl.includes("api.x.ai") 这样的判断;再 model.compat 逐字段覆盖(packages/ai/src/api/openai-completions.ts:1492-1523)。探测给默认值,数据给修正值。从探测结果能读到各家方言的具体形状:Moonshot 不认 max_completion_tokens 要退回 max_tokens 且关 strict mode,DeepSeek 重放 assistant 消息时必须带上空的 reasoning_content 字段,xAI 和 z.ai 不吃 reasoning_effort,OpenRouter 只有挂 anthropic/ 前缀的模型才走 Anthropic 风格的 cache_control 标记。
当新增服务的差异可由现有 OpenAICompletionsCompat 字段表达时,通常只需补充 compat 配置;超出该范围时仍需修改协议实现。compat 标志随模型 catalog 下发,可以在构建期统一维护。
错误处理是运维知识的代码化
重试分两层。底层 retryProviderRequest(packages/ai/src/utils/provider-retry.ts:105-125):所有 SDK 一律以 maxRetries: 0 调用(比如 packages/ai/src/api/anthropic-messages.ts:557),因为 SDK 内置退避的 sleep 不响应 AbortSignal,用户按 Esc 时进程会卡在看不懂的等待里。pi 自己实现退避:尊重 retry-after 头但 60 秒封顶,超过直接上抛交给外层策略决定,不在底层闷头睡(packages/ai/src/utils/provider-retry.ts:37-49);没有 retry-after 就指数退避,封顶 8 秒、向下抖 25%(packages/ai/src/utils/provider-retry.ts:65-66);sleep 可被 AbortSignal 打断。上层 retryAssistantCall(packages/ai/src/utils/retry.ts:162-211)按策略重跑整个 assistant 回合,退避中被打断也归一成 stopReason=“aborted” 的正常消息,调用方不用关心取消发生在哪个时刻。
可重试判定不看 HTTP 状态码,看错误消息:约 36 条正则(packages/ai/src/utils/retry.ts:26-89),每条的注释带 issue 编号。OpenRouter 的「Provider returned error」(#2264)、Anthropic 流提前结束(#4433)、Bedrock 的 HTTP/2 无响应(#3594)都在里面。反向名单同样重要:配额和计费类错误(insufficient_quota、out of budget、billing)进 NON_RETRYABLE 名单快速失败(packages/ai/src/utils/retry.ts:7-24),确定性错误不浪费重试预算。该正则表将 issue 编号与可重试模式放在一起维护,形成可追踪的错误分类规则。
上下文溢出的检测有三条路径(packages/ai/src/utils/overflow.ts:132-161):
- 错误消息正则,20 余条覆盖各家文案,同时带 NON_OVERFLOW 排除项:Bedrock 会把限流报成「Too many tokens」,没有这层排除就会被误判成溢出(
packages/ai/src/utils/overflow.ts:74-77)。 - z.ai 式静默溢出:请求正常返回,但 usage.input 加 cacheRead 超过 contextWindow,从 usage 反推(
packages/ai/src/utils/overflow.ts:143-148)。 - 小米 MiMo 式截断溢出:服务端把输入截到恰好填满窗口,返回 stopReason=“length” 且 output 为 0、input 填满窗口 99% 以上(
packages/ai/src/utils/overflow.ts:150-158)。
文件注释说明有些 provider 不报错,因此溢出检测只能是启发式。错误体归一化(packages/ai/src/utils/error-body.ts)处理相邻的问题:各 SDK 将 HTTP 错误信息放在不同字段,代码分两路逐字段探测。状态码走 statusCode(Mistral)、status(openai)、$metadata.httpStatusCode(Bedrock)(packages/ai/src/utils/error-body.ts:61-67),错误体走 body(Mistral)、error(openai)、$response.body(Bedrock)。AWS SDK v3 的 $response.body 是 stream 包装对象,直接 stringify 会得到 {"_events":...} 等内容并覆盖真正的错误消息,因此取 body 前需检查它是否为 plain object(packages/ai/src/utils/error-body.ts:112-117)。Anthropic 这类已将错误体折进 message 的走另一条路径:messageCarriesBody 标记避免同一段错误打印两遍。这些注释记录了不同 SDK 暴露状态码和错误体的字段差异。
transformMessages:每次请求前的整备
第 3 篇讲过 handoff 的概念:AssistantMessage 自己携带 api、provider、model 和不透明的签名,会话因此可以中途换模型甚至换 provider。概念归会话层,机制落在这一层:每次请求前 transformMessages 做五项变换(packages/ai/src/api/transform-messages.ts:64-223)。
- 模型不支持视觉时,图片降级为占位文本,连续图片只留一个占位。
- 跨模型时 redacted thinking 整块丢弃,普通 thinking 转纯文本;同模型则带签名原样送回。
- tool call ID 归一化:OpenAI Responses 的 ID 有 450 多个字符且含
|,Anthropic 要求 64 字符内的安全字符集,归一化后映射表同步改写后续 toolResult。 - 孤儿 tool call(没有对应 toolResult)合成 isError 的占位结果修补,满足各家「每个 tool call 必须有结果」的硬约束。
- stopReason 为 error 或 aborted 的 assistant 消息整段不回放,这类半截消息会触发上游报错(比如 OpenAI 的「reasoning without following item」)。
配套的 parseStreamingJson 是多级降级(packages/ai/src/utils/json-parse.ts:104-124):先直接 parse,失败用 repairJson 修控制字符和非法转义,再失败走 partial-json 截断解析,最后兜底 {}。它为流式展示和收尾提供尽力解析,完整消息中等价于完整解析;截断消息中则可能产生不完整的参数。因此执行层收到 stopReason="length" 的响应时会拒绝该批 tool call,避免执行可能残缺的参数。
模型 catalog 的构建与维护
catalog 走构建期生成(packages/ai/scripts/generate-models.ts)。三路数据源合并:models.dev 主源、OpenRouter、Vercel AI Gateway(packages/ai/scripts/generate-models.ts:2077-2086),冲突时 models.dev 优先,只收 tool_call === true 的模型(packages/ai/scripts/generate-models.ts:1105 等处)。订阅制产品的定价也要手工补:models.dev 对 Kimi Coding 这类订阅产品报零成本,脚本里用等价的 Moonshot API 费率写死一组估算值(packages/ai/scripts/generate-models.ts:303-310)。上游数据有错,靠手工 override 修正:GitHub Copilot 部分模型实际是 1M 上下文(packages/ai/scripts/generate-models.ts:2094-2095)、OpenCode 目录把 Sonnet 4/4.5 误标 1M 而实际是 200K(packages/ai/scripts/generate-models.ts:2110-2116)、models.dev 把 gpt-5-pro 的 output 上限标成了 input 的子限制(packages/ai/scripts/generate-models.ts:2136-2140)。catalog 脚本中的 override 与错误分类规则一样,都需要随上游变更持续维护。
发布是原子的:staging 目录写完,manifest 校验通过后 rename 替换旧目录,失败回滚(packages/ai/scripts/generate-models.ts:2630-2730)。消费侧 JSON 是单一事实源,TypeScript 字面量类型从 JSON 推导(packages/ai/src/model-catalog.ts:5-20),model ID 的联合类型和每个模型精确的 API 类型都是生成的,写错模型 ID 在编译期报错。
两个 coding agent 特化值得单独提。OAuth 订阅登录:Claude Pro/Max、ChatGPT Plus、GitHub Copilot 都能用订阅授权代替 API key(packages/ai/src/auth/oauth/),订阅是包月计价,对高频调用的 coding agent 是实际的成本差异。token 刷新走 CredentialStore.modify 这个唯一写路径,写操作按 provider 串行成 promise 链(packages/ai/src/auth/credential-store.ts:34-44),刷新本身用锁内双检(packages/ai/src/auth/resolve.ts:98-136):乐观检查发现 token 剩余不足五分钟,进锁后再查一次,已被别的请求刷新过就直接用,避免并发重复刷新。prompt cache 亲和:同一个 sessionId 映射成 prompt_cache_key 或 session 亲和头(packages/ai/src/api/openai-responses.ts:232-239, 283),让同一会话的请求落到缓存热的后端;compaction 摘要请求反过来显式置 cacheRetention: "none",这个在第 3 篇已经讲过。
收尾
本文涉及三类多 provider 适配机制:共享协议实现与 compat 配置,带 issue 编号的错误分类规则,以及构建期生成并允许手工 override 的模型 catalog。这些做法可作为维护多 provider 集成时的参考。
代价也很明确:37 家 provider 的差异需要持续维护。上游变更错误文案时,重试正则可能需要更新;上游元数据有误时,需要补充 override。pi-ai 将这类维护集中在 compat、错误分类和 catalog 生成逻辑中。

