Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

pi-ai(一):统一模型调用层

TL;DR:pi-ai 是 Pi 最底层的统一 LLM 调用库,以普通 npm 包的形式运行在调用方进程内,不是独立服务。它用一套统一的 Model + Context + 事件流 屏蔽 OpenAI、Anthropic、Google 等厂商在消息格式、工具协议、流式事件、推理参数、usage 和错误上的差异。内部按三层组织:Models 集合负责找 Provider、解析认证、合并请求选项;Provider 把模型目录、认证方式和 API 实现装成一个运行时单元;api/* 适配器才真正构造请求、解析 SSE 并翻译成统一事件。它支持声明工具、解析模型产出的 Tool Call,但不执行工具,也不驱动多轮循环,这些属于 pi-agent-core。

OpenAI、Anthropic、Google 这些服务的 API 并不相同:请求字段不同,system prompt 的放法不同,工具 schema 的格式不同,流式响应的事件不同,thinking/reasoning 参数不同,usage 和错误结构也不同,图片等多模态内容的编码方式还各不一样。如果上层 Agent 直接面对这些差异,就得为每家厂商各写一遍调用逻辑。pi-ai 把差异关进各个适配器里,对上只暴露一套统一的数据结构和一条统一的事件流。

本质上就一句话:**用一套统一的模型、消息、工具、流式事件协议,屏蔽掉不同 LLM 的协议差异。**这也是读它源码时不失真的关键心法。

底层被屏蔽的线协议包括 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini、Bedrock Converse、Mistral 等。

在 Pi 中的位置与运行形态

pi-coding-agent   产品层:ModelRuntime、auth.json/models.json、会话、system prompt、工具、扩展、TUI/RPC
      │  提供 streamFn(内部调 modelRuntime.streamSimple)
      ▼
pi-agent-core     循环层:组装上下文,调用 streamFn,执行工具,决定是否继续下一轮
      │
      ▼
pi-ai             模型层:Provider/Model/Auth、协议适配、消息转换、统一事件流、usage/cost
      │
      ▼
Anthropic / OpenAI / Google / Bedrock / Mistral / 各类 OpenAI 兼容网关

三层的边界可以这样记:pi-coding-agent 决定用哪个模型、当前有哪些工具、凭据存在哪里;pi-agent-core 决定什么时候请求模型、什么时候执行工具、要不要继续;pi-ai 只接收已经构建好的上下文,把它翻译成目标厂商的请求,再把响应翻译回统一事件。所以 pi-ai 不拼装完整的 system prompt、不执行工具、不管理会话树和 compaction、不做工具审批。

运行形态上,pi-ai 是一个纯库:没有常驻进程,没有本地 server,所有对象(Models、Provider、事件流)都是调用方进程里的内存对象,一次 stream() 调用就是一次直连厂商的 HTTP(个别是 WebSocket)请求。它有三种使用层级:

入口谁管认证和 Provider 集合适用场景
@earendil-works/pi-ai/api/<api-id> 直接调适配器调用方自己传 key,没有集合适配器调试、已有独立认证体系的宿主
createModels() / builtinModels() 得到 ModelsModels 通过所属 Provider 解析认证并分发独立使用 pi-ai 的应用
pi-coding-agent 的 ModelRuntime在 Models 之上叠加 auth.json、models.json、远端目录、扩展 ProviderPi CLI、SDK、RPC

ModelRuntime 在 coding-agent 包里,实现了 Models 接口,但不属于 pi-ai,细节见 模型与认证。

一句话:pi-ai 完成"一次独立的模型请求":可以声明工具、可以产出 tool call,但不执行工具,也不自动发起下一次请求。

核心数据模型

pi-ai 的数据结构刻意保持为纯 JSON:模型、上下文、消息都没有挂函数,可以直接 JSON.stringify 存盘、跨进程传递、换一个模型继续。理解它只需要抓住下面几个概念。

**Model 描述"一个模型怎么被调用"。**它带 id、上下文窗口、最大输出、价格、是否支持图片和推理等元信息,其中最容易混的是三个字段:provider 说明谁提供服务、用哪套认证(如 deepseek);api 说明请求遵循哪种线协议(如 openai-completions);compat 修正"同一协议、不同实现"之间的偏差,比如某个 OpenAI 兼容服务不支持 developer role、不支持 strict schema 或 streaming usage。多个 Provider 可以共用一个 API 实现(xAI、Groq、OpenRouter 等都走 openai-completions),一个 Provider 也可以混用多种 API(GitHub Copilot、OpenCode 按模型分发)。从 0.99 起 Model 还有 type:chat(默认,可省略)、image、classifier,决定它能被哪个操作接受。

Context 是一次请求的输入,形如 { systemPrompt?, messages, tools? }。1.x 的一个重要变化是:systemPrompt 和 tools 只是"开头一条 system 消息"的简写。公共入口(Models.stream() 等)会调用一次 normalizeContext(),把它变成只有 messages 的 TranscriptContext,往下的 Provider 和适配器只看得到这份 transcript。transcript 里除了开头那条,还可以在对话中途插入新的 system 消息,用来修改 prompt 片段(sections,按名字打补丁)或增删工具(toolsAdded / toolsRemoved)。当前生效的 prompt 和工具由 getCurrentSystemPrompt() / getCurrentTools() 回放全部 system 消息得出。这样设计的好处是:改 prompt、加工具不用重写历史,也就不会毁掉 prompt cache 的前缀(见后文"工具调用"一节)。

Message 有四种角色:system、user、assistant、toolResult。Assistant 的内容由三种块组成:文本 text、思考 thinking、工具调用 toolCall。最终的 AssistantMessage 还记录实际的 provider/api/model、usage(输入/输出/缓存读写 token 与费用)、stopReason、responseId、durationMs(1.1 起由事件流用单调时钟统一计时,所有适配器都有)等元数据。

StopReason 取值为 stop、length、toolUse、error、aborted、deferred,加上流式过程中的 pending。其中 error/aborted 由 error 事件给出,其余由 done 事件给出,这是下一篇流式架构的核心约定。

**Tool 就是 name + description + JSON Schema。**1.5 起 pi-ai 不再依赖 TypeBox,parameters 可以是 pi-schema 构建器生成的 schema、TypeBox schema 或手写对象,只要是 JSON Schema 就行。相应地,ToolCall.arguments 和 ToolResultMessage.details 被限制为 JSON 兼容值,保证整段对话可序列化。

一次请求的主链路

把 pi-ai 的工作按时间分成三个阶段看最清楚。

**构建期:生成静态模型目录。**内置模型目录由脚本从 models.dev 的 v2 目录生成,每个 Provider 在 catalog/ 下有一份 TypeScript 规则(过滤哪些模型、补哪些 compat),产物是 src/providers/*.models.ts。所以 pi-ai 离线也能同步查到模型,启动时不必访问网络。

**启动期:装配 Models。**应用调用 builtinModels()(注册全部内置 Provider)或 createModels() + setProvider(anthropicProvider()) 逐个注册。每个 Provider 工厂做的事都一样:把"已生成的模型目录 + 认证策略 + API 实现"装成内存里的 Provider 对象。此时 SDK 还没有被加载。

**请求期:一次 models.stream() 真正发出请求。**以调用 OpenAI 模型为例:

models.stream(model, context, options)          ← 同步返回一条 AssistantMessageEventStream
  ├─ normalizeContext(context)                   ← Context → TranscriptContext
  └─ lazyStream #1(后台异步)
       ├─ requireChatProvider(model)             ← 按 model.provider 找 Provider
       ├─ getAuth(model)                         ← 解析 key / OAuth / env,合并 headers、baseUrl、identity
       └─ provider.stream(requestModel, transcript, requestOptions)
            └─ 按 model.api 选 ProviderStreams = openAIResponsesApi()
                 └─ lazyStream #2:await import("./openai-responses.ts")
                      └─ 真实适配器:转换消息 → 构造 payload → 发请求 → 把 SSE 翻译成统一事件

这条链路可以看成一条"把异步准备工作藏进统一事件流"的流水线。它有两层 lazyStream():第一层在 Models 里,容纳异步的认证解析(OAuth 可能要刷新 token);第二层在 lazyApi() 里,容纳协议模块的动态 import。两层都遵守同一个契约:先同步返回一条流,再在后台准备;准备阶段任何失败(未知 Provider、未配置认证、模块加载失败)都被转成这条流上的终止性 error 事件,而不是让调用点抛异常。调用方始终只看到一条事件流,感知不到中间发生了认证等待、模块加载和 Provider 分发。

非流式入口只是等同一条流的结果:complete() = stream().result(),completeSimple() = streamSimple().result()。stream() 走 provider.stream(),接受目标 API 的全部专属选项;streamSimple() 走 provider.streamSimple(),接受统一的 reasoning 等选项再由适配器映射。两条链结构完全相同。

一句话:Models 管归属和认证,Provider 管装配和按协议分发,*.lazy.ts 管按需加载,api/*.ts 管真实协议,事件流把成功、失败、取消统一收口。

Provider、API 与模型目录如何装配

**createProvider 是唯一的装配点。**内置 Provider 和自定义 Provider 用的是同一个函数:给它 id、auth、models 和一个 API 实现。单协议 Provider 直接传一个 ProviderStreams;混合协议 Provider 传一个以 model.api 为键的映射,请求时按模型的 api 字段查表,查不到就返回一条错误流。Provider 级的请求改写(例如把 baseUrl 里的租户占位符替换掉)通过包装 ProviderStreams 实现,Cloudflare 的 Provider 就是这么做的。接入一个兼容 OpenAI 的本地服务,只需要描述模型、声明认证(无 key 的本地服务也要提供一个"总是已配置"的 resolve,让每个 Provider 都有明确的认证语义),然后复用 openAICompletionsApi()。只有现有适配器加 compat 都表达不了的新线协议,才需要写新的 api/<id>.ts。早期全局 registerApiProvider() 注册表的做法已经废弃,旧 API 只留在 @earendil-works/pi-ai/compat 里供迁移。

**懒加载是为了包体和启动速度。**每个 API 有一个 .lazy.ts 入口,形如 lazyApi(() => import("./openai-responses.ts")):调用 openAIResponsesApi() 只创建一个 loader 闭包,真实模块和它静态引入的 openai SDK 要等第一次请求该协议的模型时才加载,宿主的模块缓存保证只加载一次。配合子路径导出:@earendil-works/pi-ai/models 只含运行时骨架;providers/<id> 只带该 Provider 的目录和懒加载包装;providers/all 才带全部目录。浏览器打包时没用到的厂商 SDK 不会进包;Bedrock 和 OAuth 这类 Node-only 代码还通过打包器看不透的 import 隔离开。

模型目录有静态和动态两种来源,读永远是同步的。getModels() / getModel() 同步返回"最后已知的目录";访问网络是显式的异步动作 models.refresh()。动态 Provider(llama.cpp 服务、OpenRouter 实时列表)在 createProvider 里给一个 fetchModels,refresh() 时带着该 Provider 的有效凭据去拉。拉到的结果可以通过 ModelsStore 持久化(pi-ai 只定义接口,默认内存实现),记录模型列表、lastModified、checkedAt 和 ETag,供下次离线启动恢复和条件请求。refresh({ allowNetwork: false }) 只从存储恢复,force: true 跳过新鲜度检查。

**远端目录覆盖在 coding-agent 层实现。**Pi CLI 启动时以随包发布的内置目录为基线,再从 pi.dev 拉取更新的目录覆盖上去:只有远端 Last-Modified 比本地目录的生成时间新才采用,同一模型按"类型 + id"合并、远端优先;默认每 4 小时检查一次,用 ETag 做条件请求,结果写进 ModelsStore 缓存,离线时继续可用,pi update --models 可以强制刷新。这样新模型上线不必等 Pi 发版。Radius 网关比较特殊:内置一份 radius.pi.dev 的公开目录供同步查找,配置认证后用网关返回的目录整体替换。

认证架构

1.x 的原则是认证归 Provider 所有。每个 Provider 带一个 ProviderAuth,里面可以有 apiKey(怎么解析 key)和 oauth(怎么登录、刷新)。调用方不用手动取 key 再传进来,Models 在请求路径上通过所属 Provider 解析。

这里区分两个概念:持久化的是 Credential(api_key 类型带 key 和可选的 Provider 级 env,oauth 类型带 access/refresh/expires);真正发请求用的是派生出来的 ModelAuth(apiKey、headers、baseUrl)。分开的好处是 OAuth 或复杂 Provider 可以从凭据派生请求头和 endpoint,比如 GitHub Copilot 每个账号的 baseUrl 就来自 OAuth 凭据。

解析优先级是:本次请求显式传的 apiKey 最高;其次是 CredentialStore 里该 Provider 的凭据(OAuth 过期先刷新);只有什么都没存时,才去查环境变量、AWS profile、gcloud ADC 这类环境来源。存储的凭据"拥有"这个 Provider:OAuth 刷新失败时会报错让用户重新登录,绝不静默退回到环境变量里的 key,避免悄悄换账号或换计费来源。同一进程里需要不同配置时,可以用请求级 options.env 覆盖进程环境变量。

CredentialStore 的契约很小:read、list(只列非敏感元数据)、modify(唯一的写路径,串行化的读-改-写)和 delete。OAuth 刷新放在 modify 里做双重检查,所以多个并发请求甚至多个进程不会重复刷新同一个已轮换的 refresh token。pi-ai 默认是内存实现,Pi 产品层注入基于 auth.json 的实现;在 Pi CLI 里优先级是 --api-key > auth.json > models.json 的 apiKey > 环境变量。

OAuth 流程(Anthropic Claude 订阅、Sign in with ChatGPT、GitHub Copilot、OpenRouter 等)由 Provider 提供 login / refresh / toAuth 三个操作,Models.login() 负责编排和持久化。登录 UI 通过与 Provider 无关的 AuthInteraction.prompt() / notify() 注入,所以终端、桌面、浏览器宿主能复用同一套流程。

请求头合并顺序是固定的:Provider 认证头 → model.headers → 显式 options.headers → transformHeaders(只属于 Models,执行后从 options 中移除,Provider 永远收不到)。1.4 新增的 identity(ClientIdentity)控制 User-Agent 和 OpenRouter、NVIDIA NIM、OpenCode 的应用归属头,作为最低优先级的默认值;可以在 createModels({ identity }) 上设默认值,也可以按请求覆盖。

最后要分清:pi-ai 的 auth 只回答"这个 Provider 配置好没有、请求该带什么 key/header/baseUrl、token 要不要刷新"。工具审批属于上层 agent,API key 的组织/项目/额度权限由厂商服务端执行,都不是 pi-ai 的事。

工具调用:pi-ai 做什么,不做什么

工具的完整生命周期分属三层:pi-coding-agent 注册工具、决定当前激活哪些、执行扩展工具;pi-agent-core 驱动"请求模型 → 执行工具 → 写回结果"的循环;pi-ai 只负责两件事——把工具 schema 翻译成各家协议的格式,把模型返回的调用翻译成统一的 ToolCall。

在这个范围内 pi-ai 做得比较细。第一,schema 适配:比如 Gemini 不接受 const,会被转成单值 enum;StringEnum() 生成 {type: "string", enum} 这种所有厂商都接受的形式。第二,约束采样:工具可以声明 strict: "prefer"(支持就用厂商的 strict schema,否则退化为普通工具调用)或 strict: "require"(不支持直接失败),OpenAI 的 GPT-5+ 模型还支持 Lark/regex 语法约束的自定义工具。第三,流式参数:参数随 toolcall_delta 增量解析,UI 可以在参数还没传完时就显示要写的文件路径(解析机制见 流式事件架构)。第四,参数校验工具:toolcall_end 给出的参数是完整的但未校验;执行前由上层调用 validateToolCall()。1.5 起校验改用 pi-schema(compile(schema, { coerce: true }).parse()),对所有 JSON Schema 一视同仁地做宽松强制转换:"42" 转数字、单值包成数组、null 对应省略的可选属性。这对 MCP 工具这类手写 schema 很重要。如果最终参数文本根本不是合法 JSON,ToolCall 会带上 argumentsError,校验直接失败并把原始文本告诉模型,而不是拿解析了一半的字段去执行。

pi-ai 不做的事同样明确:不查找工具实现、不执行、不构造 ToolResult、不自动发起下一次请求。校验失败时正确的做法是把错误作为 isError: true 的工具结果写回上下文,让模型自己改参数重试。

**对话中途增删工具与 prompt cache。**prompt cache 按请求前缀复用,而工具列表通常位于历史消息前面,改了顶层 tools 后面的缓存就全失效。1.x 用 transcript 里的 system 消息记录工具变化,再由各适配器按目标模型的能力选择最省缓存的表达:Anthropic 支持原生中途工具变更的模型(inline-tools beta)让顶层工具列表保持不变,新工具以 tool_addition 块就地定义,移除用 tool_removal;OpenAI Responses 支持 additional_tools 的模型把所有工具都声明在输入项里,后续新增就地追加,移除只能用文字告知;只支持 tool search 或 Kimi 风格的模型把新工具锚定在出现的位置;其余模型一律收到 collapseSystemMessages() 的结果,也就是回放后的完整 prompt 和工具列表放在开头。模型是否支持中途 system 消息由 compat 字段(如 supportsMidConvoSystemMessages)声明,由生成的目录给已验证的模型打开。这套机制降低的是缓存失效的范围,不保证缓存永不失效。

跨 Provider 上下文交接

Context 可序列化,所以同一段对话可以从 Claude 切到 GPT 再切到 Gemini。问题是每家都有自己的专属状态:thinking 签名、加密推理、工具调用 ID 格式。每次请求前,适配器都会用 transformMessages() 把历史改造成目标模型能接受的样子。

判断标准不是"同一个 Provider",而是同一个模型:assistant 消息的 provider、api、model 三者都与目标模型一致才算。规则如下:user 和 system 消息原样通过;同模型的 assistant 消息原样保留,包括 thinking 和签名,以便原生回放;不同模型的 assistant 消息丢弃 thinking 块(1.3 之前是降级成普通文本,后来发现新模型会把旧模型的推理当成自己回复的一部分,甚至开始把推理写进可见答案里),文本保留但去掉专属字段,tool call 去掉 thoughtSignature,ID 按目标协议规范化,对应的 tool result 的 ID 同步改写。

除了逐条变换,还有两个修补:如果某个 tool call 没有对应结果(例如用户中途打断),就补一条 isError 的 "No result provided" 结果,避免目标 API 因为"调用没有结果"拒绝整段历史;stopReason 为 error 或 aborted 的 assistant 消息在重放时整条跳过,因为它们可能含有不完整的推理或调用,会触发诸如"reasoning 后面没有 item"的 API 错误。另外,模型不支持图片时,历史里的图片会被替换成占位文字。

所以跨模型交接是保留对话的因果链,丢弃无法安全重放的专属状态,不是无损迁移。

Thinking、缓存与请求选项

两套入口对应两种需求。streamSimple() 用统一的推理等级 minimal | low | medium | high | xhigh | max(模型侧还有 off),适配器根据模型的 thinkingLevelMap 映射成 Anthropic 的 thinking/adaptive effort、OpenAI 的 reasoning.effort、Google 的 thinking 参数或兼容服务的字段,并用 clampThinkingLevel() 夹到模型支持的范围;xhigh/max 是部分模型才有的等级,用 getSupportedThinkingLevels() 查询。需要协议专属控制(如 Anthropic 的 thinkingBudgetTokens、OpenAI 的 reasoningSummary)时用 stream(),并用 hasApi() 把模型收窄到具体 API 以获得完整类型。pi-agent-core 和 coding-agent 都走 streamSimple(),保持与厂商无关。

缓存相关选项是统一字段名,不是统一语义。cacheRetention(none | short | long,默认 short)、sessionId、transport(sse | websocket | websocket-cached | auto,WebSocket 目前用在 OpenAI Codex 适配器上)由适配器映射成 cache_control、prompt_cache_key、prompt_cache_retention 或会话亲和头,不支持的厂商直接忽略。有的模型还支持中途改推理强度而不破坏缓存(compat 里的 supportsMidConvoEffort)。

观察与调试钩子:onPayload 在适配器构造好请求体后调用,可以查看甚至替换 payload(结构随 model.api 而变,不是统一 DTO);onResponse 在拿到 HTTP 响应、开始读 body 之前调用;onProviderStreamEvent 收到适配器解析后、归一化之前的原始事件,用来读取 pi 没保留的厂商字段,它是按顺序 await 的,慢回调会拖慢流。此外还有 telemetry(传入父 span,Models 会创建 pi.ai.request span 并记录首包时延、token、成本等指标,不记录任何 prompt 内容)、timeoutMs、maxRetries 和 maxRetryDelayMs。

图片模型、分类模型与 deferred

0.99 起图片生成并入同一个 Provider / Models 体系,不再有单独的 ImagesModels 集合:type: "image" 的模型用 models.generateImages() 调用,Provider 通过 createProvider({ images }) 按 api 注册实现。分类模型(type: "classifier")用 models.classify(),返回统一的 choice/score/bool 结果。它们不进文本事件流,因为都是一次性任务,没有工具循环,也没有同样的增量协议;没写 type 的查询(getModels() 等)只返回 chat 模型,保持旧代码兼容。

文本侧还支持少数厂商的异步 deferred response:done 的原因是 deferred,之后用 fetchDeferred() / cancelDeferred() 取回或取消。不要把它和 deferred tools 混为一谈:前者是模型响应延后获取,后者是工具 schema 延后加载。

常见追问

为什么不直接用 Vercel AI SDK 或 LangChain 这类现成抽象? pi-ai 要的是一个面向 Agent 的、可序列化的上下文和一条能表达 thinking、增量工具参数、中途工具变更、精确 usage/cost 的事件流,并且对 prompt cache 和跨模型交接有细粒度控制;这些都需要贴着各家协议自己写适配器,换取的是维护几十个 Provider 的成本。

provider、api、compat 为什么要拆成三个字段? 因为"谁收钱/怎么认证"、"说哪种协议"、"协议实现有什么偏差"是三个独立维度。拆开之后,新接一个 OpenAI 兼容服务通常只要写目录和 compat,不用写新适配器。

认证为什么在请求路径上解析,而不是启动时一次性拿好 key? OAuth token 会过期,必须在发请求前检查并刷新;同时还要处理多进程并发刷新、刷新失败不降级等语义。放在 Models 的请求路径上,配合 CredentialStore.modify 的锁,调用方就完全不用关心这些。

pi-ai 会自动重试吗? 只有可选的请求级重试(maxRetries,默认 0 即关闭;厂商 SDK 自带的重试被置为 0,由适配器包一层可中止的版本),并用 maxRetryDelayMs 限制服务端要求的等待时长,超过就直接失败。是否重试一整轮由上层决定:pi-ai 提供 utils/retry.ts 这样的错误分类和 utils/overflow.ts 上下文溢出识别,coding-agent 据此决定重试还是先压缩上下文(见 错误处理与重试)。

为什么模型列表的读取是同步的? 上层(模型选择器、启动逻辑)大量地方需要立即拿到模型;把网络刷新做成显式的 refresh(),读取就永远是对内存快照的同步访问,离线也能工作,不会因为一次查询意外触发网络。

源码入口(v1.5):packages/ai/src/models.ts、packages/ai/src/types.ts、packages/ai/src/api/lazy.ts、packages/ai/src/api/transform-messages.ts、packages/ai/src/auth/resolve.ts、packages/coding-agent/src/core/remote-catalog-provider.ts