Pi 源码拆解(六):pi-ai 的多 Provider 适配:协议实现、兼容配置与模型目录

📅
2 分钟阅读
·

本文是「Pi 源码拆解」系列第 6 篇。系列目录:

前五篇讨论分层、运行时、会话与上下文管理、客户端/服务端拆分和终端 UI。本文分析 pi-ai 的多 provider 适配层。接入 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 使用五个统一字段和成本字段,但字段换算由各 provider 分别实现:

  • 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 使用固定版本的官方 SDK(package.json 中 @anthropic-ai/sdk 为 0.91.1、openai 为 6.26.0,没有 ^ 前缀),并通过 .lazy.ts 动态 import 包装 SDK(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 将 API 协议实现和 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 事件终止流,因此调用方始终处理相同形状的接口。

多数 provider 复用协议实现:xAI、Groq、OpenRouter、DeepSeek 都使用 openai-completions。GitHub Copilot 配置三种 API;每个模型声明自己的 api 字段,运行时据此派发(packages/ai/src/providers/github-copilot.ts:28-32)。

pi-ai 分层架构:统一类型层、协议实现层、Provider 层、Models 集合层

三个协议实现的差异如下:

  • 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。代码生成 名称_时间戳_计数 格式的 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。

OpenAI 兼容服务的请求参数

不同 OpenAI-compatible 服务在 system 或 developer role、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_tokens 而非 max_completion_tokens,并关闭 strict mode;DeepSeek 重放 assistant 消息时必须携带空的 reasoning_content;xAI 和 z.ai 不接受 reasoning_effort;OpenRouter 中只有模型名带 anthropic/ 前缀时使用 Anthropic 风格的 cache_control 标记。

compat 分层解析:自动探测给默认值,model.compat 逐字段覆盖,最终落到请求参数

新增服务的差异能由现有 OpenAICompletionsCompat 字段表示时,可通过补充 compat 配置处理。字段无法表示的差异仍需修改协议实现。compat 标志随模型 catalog 在构建期维护。

重试、错误体与上下文溢出检测

重试分为两层。底层 retryProviderRequestpackages/ai/src/utils/provider-retry.ts:105-125)调用 SDK 时统一设置 maxRetries: 0(例如 packages/ai/src/api/anthropic-messages.ts:557)。SDK 内置退避等待不响应 AbortSignal,用户按 Esc 取消时可能无法立即终止。pi-ai 自行实现退避:读取 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 中断。上层 retryAssistantCallpackages/ai/src/utils/retry.ts:162-211)按策略重新执行整个 assistant 回合。退避过程被中断时,结果统一为 stopReason=“aborted” 的消息。

可重试判定依据错误消息,而非 HTTP 状态码。packages/ai/src/utils/retry.ts:26-89 包含约 36 条正则,每条注释关联 issue 编号。例如 OpenRouter 的「Provider returned error」(#2264)、Anthropic 流提前结束(#4433)、Bedrock 的 HTTP/2 无响应(#3594)。NON_RETRYABLE 列表包含 insufficient_quota、out of budget、billing 等配额和计费错误(packages/ai/src/utils/retry.ts:7-24),这些错误不消耗重试次数。该规则表将 issue 编号与可重试模式一起维护。

上下文溢出通过三条路径检测(packages/ai/src/utils/overflow.ts:132-161):

  1. 错误消息正则。20 余条规则覆盖各 provider 的错误文案,并有 NON_OVERFLOW 排除项。Bedrock 可能将限流报为「Too many tokens」;没有排除规则会被判定为上下文溢出(packages/ai/src/utils/overflow.ts:74-77)。
  2. z.ai 的静默溢出。请求正常返回时,如果 usage.input 加 cacheRead 超过 contextWindow,则由 usage 推断溢出(packages/ai/src/utils/overflow.ts:143-148)。
  3. 小米 MiMo 的截断溢出。服务端将输入截到接近窗口上限,返回 stopReason=“length”,output 为 0,且 input 达到窗口的 99% 以上(packages/ai/src/utils/overflow.ts:150-158)。

文件注释指出,部分 provider 不返回错误,因此溢出检测只能使用启发式规则。错误体归一化(packages/ai/src/utils/error-body.ts)处理各 SDK 的字段差异。状态码分别从 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":...} 等内容并覆盖实际错误消息,所以读取前要检查它是否为 plain object(packages/ai/src/utils/error-body.ts:112-117)。Anthropic 等 SDK 已将错误体合并进 message;messageCarriesBody 标记防止重复输出同一段错误。

请求前的消息转换

第 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 要求 ID 不超过 64 个字符且使用安全字符集。归一化后,映射表会同步更新后续 toolResult。
  • 为没有对应 toolResult 的 tool call 生成 isError 占位结果,满足 provider 对「每个 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,避免执行不完整参数。

模型目录的数据源与 override

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)。这些 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 相关机制也由 pi-ai 处理。Claude Pro/Max、ChatGPT Plus、GitHub Copilot 可使用 OAuth 订阅授权替代 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 剩余不足五分钟后进入锁,进入锁后再次检查;若其他请求已刷新 token,则直接使用新 token,避免重复刷新。为提高 prompt cache 命中率,同一 sessionId 映射到 prompt_cache_key 或 session 亲和头(packages/ai/src/api/openai-responses.ts:232-239, 283);compaction 摘要请求显式设置 cacheRetention: "none",该机制见第 3 篇。

多 Provider 适配的维护位置

pi-ai 的多 provider 适配包括三类机制:共享协议实现与 compat 配置、关联 issue 编号的错误分类规则,以及构建期生成并允许手工 override 的模型 catalog。它们将 provider 差异集中到协议实现、compat、错误分类和 catalog 生成逻辑中。

37 家 provider 的差异仍需持续维护。上游变更错误文案时,重试正则可能需要更新;上游元数据有误时,需要补充 override。


864 字 · 57 段落
ximing

Follow onGitHub

相关文章