π Pi Agent 源码学习课程 阶段二 · 5/14

pi-ai(下):Provider 适配、认证与工具调用协议

阶段二 · 模型层第 4 讲的统一消息模型在这里「落地」:一套内部 Message,如何在十几种厂商 wire 协议之间进出。

如果说上一讲的 Message 体系是 Pi 的「通用语」,本讲就是同声传译室:api/ 目录逐个协议做出站转换,providers/ 注册厂商与模型目录,auth/ 负责凭据解析与 OAuth,models.json 让你不动源码接入自定义端点。

3~4 小时预计时长
40+内置 Provider
10+协议适配器(api)
packages/ai源码位置
Message / Tool 统一内部模型 (第 4 讲) anthropic-messages openai-responses openai-completions google-generative-ai / vertex bedrock / mistral / ... 各家 wire 协议 system / tools / cache 字段各不相同

学习目标

说出 api/ 目录里每个适配器家族的职责,解释 .lazy.ts 懒加载为什么能把 CLI 启动开销压下来。

对比 anthropic-messages 与 openai-responses 的 buildParams,追踪统一 Message 到 wire 格式的每一步转换。

描述 providers/ 注册结构(每厂商一个文件 + *.models.ts 目录 + all.ts 汇总),以及 auth/ 的凭据优先级链与 OAuth PKCE / 设备码流程。

用 models.json 接入一个 OpenAI 兼容端点(Ollama),并用 modelOverrides、环境变量插值与 !command 定制行为;理解虚拟模型与 Provider 扩展两条进阶路径。

讲清 Tool 声明如何经 toToolDeclaration() 归一化,再分别落到 Anthropic / OpenAI 两种工具格式,以及 strict 模式的 schema 限制。

§5.1

api/ 适配层:目录结构与懒加载设计

先打一个比方:api/ 目录就像 JDBC 驱动集合——上层(Agent Loop)只面向 StreamFunction 这一个「接口」编程,下层每个厂商协议一个实现文件。Pi 目前内置的适配器家族如下(均在 packages/ai/src/api/):

适配器文件wire 协议 / 用途
anthropic-messages.tsAnthropic Messages API(也服务 Claude 订阅 OAuth 与大量 Anthropic 兼容网关)
openai-responses.tsOpenAI Responses API(新版接口,input 数组 + function_call item)
openai-completions.tsOpenAI Chat Completions(兼容面最广,Ollama / vLLM / LM Studio / 多数代理都走它)
google-generative-ai.ts / google-vertex.tsGoogle Gemini 与 Vertex AI(共享 google-shared.ts 转换逻辑)
bedrock-converse-stream.tsAWS Bedrock Converse Stream
mistral-conversations.tsMistral Conversations API
azure-openai-responses.tsAzure OpenAI(Responses + Foundry 部署名映射)
openai-codex-responses.ts / pi-messages.ts / typesafe-system-one.ts 等特定产品端点与分类器(classifier)专用 API
transform-messages.ts / strict-tool-limits.ts / constrained-sampling.ts / simple-options.ts / openai-responses-shared.ts跨适配器共享的转换与策略工具(不含协议本身)

5.1.1.lazy.ts:为什么每个适配器旁边都有一个 200 字节的替身

每个 <api>.ts 旁边都有一个 <api>.lazy.ts,例如 anthropic-messages.lazy.ts 全文只有一行核心逻辑:

packages/ai/src/api/anthropic-messages.lazy.tsTypeScript
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";

export const anthropicMessagesApi = (): ProviderStreams =>
  lazyApi(() => import("./anthropic-messages.ts"));

动机很直接:anthropic-messages.ts 约 70 KB、内部 import Anthropic from "anthropic";openai-completions.ts 约 64 KB、拉入整个 openai SDK。而 Pi 的 CLI 启动时要做的事只是列出 40 多个 Provider、展示模型选择器——如果为了一行帮助文本就把所有厂商 SDK 都加载进来,冷启动会显著变慢。

lazy.ts 提供两个原语。lazyApi(load) 把动态 import() 包装成 ProviderStreams(含 stream / streamSimple,以及可选的 fetchDeferred / cancelDeferred);首次真正发请求时才加载实现模块,宿主的 import cache 会去重。lazyStream(model, setup) 则解决「异步 setup 与同步返回流的矛盾」:

  • 同步返回一个 AssistantMessageEventStream,调用方立刻拿到可迭代对象;
  • 后台执行 setup(认证解析、模块加载),完成后把内部流转发给外层;
  • setup 失败不抛同步异常,而是以 error 事件收尾,stopReason 为 error 或(若 signal 已中止)aborted。
packages/ai/src/api/lazy.ts(节选)TypeScript
/**
 * Returns a stream synchronously while running async setup (auth resolution,
 * lazy module loading) behind it. Setup failures terminate the stream with an
 * error event.
 */
export function lazyStream(
  model: Model<Api>,
  setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
  observer?: LazyStreamObserver,
  signal?: AbortSignal,
): AssistantMessageEventStream { ... }

这套设计等价于 JDBC 里 Class.forName("com.mysql.jdbc.Driver") 的延迟加载,再加一层 Spring 的 ObjectProvider/@Lazy:注册表先登记「有这个能力」,真正的重型类(厂商 SDK)等到第一次调用才进方法区。对 CLI 来说,启动路径上的 import 图越瘦,pi --help、/model 打开得越快。

§5.2

统一消息 → 各协议 wire 格式:请求出站转换管道

每个适配器的入口签名都是同构的:stream(model, context, options) => AssistantMessageEventStream。无论哪个协议,出站前都要经过一条固定的「清洗 → 转换 → 装配」管道。下面这张图是本讲的核心,建议对照源码走一遍:

TranscriptContext messages[](system/user/ assistant/toolResult) resolveTranscript() 不支持中途 system 的模型 折叠进首条 system 消息 transformMessages() 图像降级 / 孤儿 tool call 补结果 跨模型清理 thinking / ID 归一化 convertXxx() convertMessages + convertTools(wire 格式) 协议私有的 buildParams() 在这里接管 buildParams() 采样 / 思考级别 / 缓存标记 tool_choice / betas SDK client HTTP POST + SSE retryProviderRequest 重试 processXxxStream() 厂商事件 → 统一 partial content_start / delta / end EventStream AssistantMessageEvent 回填统一 Message 同一条管道在 anthropic-messages.ts、openai-responses.ts、openai-completions.ts 等适配器中重复出现; 前三个阶段(resolve / transform / convert)共享 utils/transcript.ts 与 api/transform-messages.ts,只有 convertXxx 与 buildParams 是协议私有的。 回程对称:processResponsesStream 等函数把厂商 SSE 事件重新归一化为统一 AssistantMessageEvent,Agent Loop 对协议无感知。
图 5-1:请求出站转换管道。实线为同步数据流,蓝色虚线强调 buildParams 是协议分叉点;同名函数可在 api/anthropic-messages.ts 与 api/openai-responses-shared.ts 中对照阅读。

5.2.1共享清洗:transformMessages() 做的四件「脏活」

这个 200 行的共享函数(api/transform-messages.ts)在进入任何协议转换前,把跨厂商的坑统一抹平:

  1. 图像降级:模型的 input 不含 "image" 时,把 user 消息与 toolResult 里的图片替换成 "(image omitted: model does not support images)" 占位文本。
  2. 跨模型 thinking 清理:thinking 签名(signature / encrypted reasoning)只对产出它的模型有效,切换模型回放历史时会直接 400。函数对非同模型的 thinking 块整块丢弃(redacted / 有签名的除外——它们是回放所必需的)。
  3. 工具调用 ID 归一化:OpenAI Responses 生成的 ID 可达 450+ 字符、含 | 等特殊字符;Anthropic 要求 ^[a-zA-Z0-9_-]+$ 且最长 64。函数建立 old→new 映射表,assistant 消息里的 toolCall.id 与后续 toolResult.toolCallId 成对替换。
  4. 孤儿 tool call 补结果:对没有对应 toolResult 的 tool call 合成 { text: "No result provided", isError: true } 的 toolResult——这既满足了所有协议「tool_use 后必须紧跟 tool_result」的硬约束,也保住了 thinking 签名的位置;同时整条跳过 stopReason 为 error/aborted 的 assistant 消息,避免回放残缺轮次。

5.2.2Anthropic 侧:convertMessages() 与 wire 示例

anthropic-messages.ts 的 buildParams() 先取首条 system 消息单独处理:文本进顶层 params.system 数组(每段可挂 cache_control 断点),对话体交给 convertMessages()。角色映射的关键差异:Anthropic 没有 tool 角色——toolResult 消息被转换成 role 为 user 的消息,内容是 tool_result 块;连续多条 toolResult 合并进同一条 user 消息(z.ai 端点的要求)。assistant 的 toolCall 块变成 tool_use 块,thinking 块原样携带 signature。真实 wire 格式(节选):

anthropic-messages · POST /v1/messages 请求体(节选,字段名对照源码 buildParams)JSON
{
  "model": "claude-sonnet-4-5",
  "stream": true,
  "max_tokens": 16384,
  "system": [{ "type": "text", "text": "You are Pi, a coding agent...",
               "cache_control": { "type": "ephemeral" } }],
  "messages": [
    { "role": "user", "content": "列出当前目录的 TypeScript 文件" },
    { "role": "assistant", "content": [
      { "type": "tool_use", "id": "toolu_01A...",
        "name": "ls", "input": { "path": "." } } ] },
    { "role": "user", "content": [
      { "type": "tool_result", "tool_use_id": "toolu_01A...",
        "content": [{ "type": "text", "text": "src/index.ts\nsrc/utils.ts" }],
        "is_error": false } ] }
  ],
  "tools": [{ "name": "ls", "description": "List directory contents",
    "input_schema": { "type": "object", "properties": { "path": { "type": "string" } },
                    "required": ["path"] } }],
  "thinking": { "type": "enabled", "budget_tokens": 1024 }
}

两个细节值得记住:错误 tool result 里的图片会被 splitErrorToolResultImages() 拆出来,图片挪到该轮所有 tool_result 之后(Anthropic 拒绝错误结果里带非文本内容);OAuth 订阅令牌(Claude Pro/Max)时必须在 system 里注入 "You are Claude Code, Anthropic's official CLI for Claude." 身份声明,且工具名会映射为 Claude Code 的命名。

5.2.3OpenAI Responses 侧:input 数组与 fc_ 前缀

openai-responses.ts 把对话装配成扁平的 input 数组(实现在 openai-responses-shared.ts 的 convertResponsesMessages()):system 指令变成 role: "developer"(推理模型)或 "system";assistant 的工具调用变成独立的 { type: "function_call", call_id, name, arguments } item(arguments 是 JSON 字符串);toolResult 变成 { type: "function_call_output", call_id, output }。buildParams() 还处理一批 OpenAI 特有的约束:

  • max_output_tokens 最小 16(低于 16 直接被拒,见源码引用的 issue #6265);
  • prompt_cache_key 用会话 ID 经 clampOpenAIPromptCacheKey() 截断;store: false 默认不落服务端;
  • 推理模型的 effort 走 reasoning.effort,并 include: ["reasoning.encrypted_content"] 以便回放加密思考;
  • item ID 必须以 fc_ 开头——normalizeToolCallId() 把 Pi 内部的 callId|itemId 复合 ID 拆开归一,异模型的调用还要 shortHash 重造 itemId。
openai-responses · POST /v1/responses 请求体(节选,对照 buildParams)JSON
{
  "model": "gpt-5.2",
  "stream": true,
  "store": false,
  "prompt_cache_key": "sess_9f2c...",
  "input": [
    { "role": "developer", "content": "You are Pi, a coding agent..." },
    { "role": "user", "content": "列出当前目录的 TypeScript 文件" },
    { "type": "function_call", "call_id": "call_9f...",
      "name": "ls", "arguments": "{\"path\":\".\"}" },
    { "type": "function_call_output", "call_id": "call_9f...",
      "output": "src/index.ts\nsrc/utils.ts" }
  ],
  "tools": [{ "type": "function", "name": "ls",
    "description": "List directory contents",
    "parameters": { "type": "object", "properties": { "path": { "type": "string" } },
                   "required": ["path"], "additionalProperties": false },
    "strict": true }],
  "reasoning": { "effort": "medium", "summary": "auto" },
  "include": ["reasoning.encrypted_content"]
}

两者对照后你会得出本讲的第一个结论:「兼容 OpenAI」的表面之下,藏着一整套约束差异——这就是 compat 开关体系(supportsStrictMode、supportsMidConvoSystemMessages、promptCacheKeyMode 等十余项)存在的原因,每个开关描述的都是一个「验证过的差异」,而非猜测。

这条管道很像 Spring MVC 的 HttpMessageConverter + Filter 链:transformMessages() 是出站前的公共 Filter(洗掉跨协议的不兼容数据),convertXxx() 是针对每个 Content-Type 的 Converter,buildParams() 则是框架私有的视图装配。理解了「公共清洗在先、协议分叉在后」的分层,读任何一个新适配器都只需要盯 convert 与 buildParams 两处。

§5.3

providers/ 注册结构:每厂商一个文件,all.ts 汇总

providers/ 与 api/ 是两个正交的维度:api 是「说什么协议」,provider 是「谁在说话」。一个 provider 声明自己的 ID、名称、baseUrl、认证方式、模型目录,并绑定一个 api 实现。目录里每厂商两个文件:<name>.ts(注册逻辑,通常不到百行)与 <name>.models.ts(由 catalog/generate.ts 自动生成的模型目录)。以 Anthropic 为例:

packages/ai/src/providers/anthropic.ts(节选)TypeScript
export function anthropicProvider(): Provider<"anthropic-messages"> {
  return createProvider({
    id: "anthropic",
    name: "Anthropic",
    baseUrl: "https://api.anthropic.com",
    auth: {
      apiKey: anthropicApiKeyAuth(),
      oauth: lazyOAuth({
        name: "Anthropic (Claude Pro/Max)",
        isSubscription: true,
        load: loadAnthropicOAuth,
      }),
    },
    models: Object.values(ANTHROPIC_MODELS),
    api: anthropicMessagesApi(),   // ← 来自 anthropic-messages.lazy.ts
  });
}
packages/ai/src/providers/anthropic.models.ts(自动生成,节选)TypeScript
// This file is auto-generated by catalog/generate.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/anthropic.json" with { type: "json" };

export const ANTHROPIC_MODELS: ChatModelCatalog<typeof values, "anthropic"> =
  flattenChatModelCatalog("anthropic", values);

模型元数据真正躺在 providers/data/<name>.json 里(上下文窗口、maxTokens、定价、compat 开关等),*.models.ts 只是把 JSON 变成带类型的导出。all.ts 则是汇总点:它 import 全部 40 多个 xxxProvider() 工厂函数,提供 builtinProviders() 列表与 builtinModels()——先 createModels() 再逐个 setProvider(),得到一个装满内置厂商的 Models 集合。另外 getBuiltinModel(provider, modelId) 能以字面量类型读出单个模型,供不需要完整集合的场景使用。

「新增一个厂商」因此有清晰的清单:写 providers/<id>.ts 注册、(可选)providers/data/<id>.json + *.models.ts 目录、在 all.ts 挂上工厂;若协议是新的还要在 api/ 写适配器与 .lazy.ts;若登录流程是新的则在 auth/oauth/ 加一个文件。

providers/all.ts 的角色就是 Spring Boot 的 AutoConfiguration.imports(旧版 spring.factories):把散落各处的 starter 汇成一个默认装配清单;*.models.ts 由构建脚本生成,则类似注解处理器产出的元数据文件——手写容易漂移,生成保证与上游目录同步。

§5.4

认证体系:凭据优先级链与 OAuth

auth/ 目录分两层:根目录四个文件定义抽象,oauth/ 子目录放各厂商的登录流程实现。

文件职责
types.tsProviderAuth{apiKey?, oauth?}、ApiKeyAuth{login?, check?, resolve}、OAuthAuth{name, login, refresh, toAuth}、CredentialStore 接口
context.tsdefaultProviderAuthContext():从 process.env 读环境变量、用 node:fs 检查文件存在性(浏览器下安全降级)
credential-store.ts默认的 InMemoryCredentialStore:按 provider ID 存一条凭据,所有写操作经 modify() 串行化;应用层注入持久化实现(coding-agent 落到 ~/.pi/agent/auth.json)
resolve.tsresolveProviderAuth():整个凭据解析链的入口;refreshStoredOAuthCredential() 在存储锁内刷新令牌
oauth/pkce.ts、device-code.ts、callback-server.ts 三个通用原语 + anthropic / openai-chatgpt / openai-codex / github-copilot / kimi-coding / meta / openrouter / radius / xai 九个厂商实现

5.4.1凭据优先级链:谁拥有这个 Provider

官方文档(docs/models.md)给出一句话版本:运行时 --api-key 最先,其次 auth.json 存储凭据,再次 models.json 里的 apiKey,最后才是环境变量或云凭据。源码 resolve.ts 里的实现顺序与注释完全对应——「A stored credential owns the provider」(存储凭据拥有该 provider,环境变量只在什么都没存时兜底):

  1. overrides?.apiKey:命令行 / API 传入的一次性 key,直接构造 ApiKeyCredential 走 resolveApiKey();
  2. credentials.read(providerId):读 auth.json。若是 OAuth 凭据且需要刷新(剩余有效期 < 默认 5 分钟),进 refreshStoredOAuthCredential();
  3. 都没有时,走 provider.auth.apiKey.resolve() 的 ambient 链——各 provider 自己决定顺序,例如 Anthropic 是 ANTHROPIC_AUTH_TOKEN → ANTHROPIC_OAUTH_TOKEN → ANTHROPIC_API_KEY → 工作负载联合身份(federation)。

一个容易踩的坑:CI 里设了 ANTHROPIC_API_KEY 却仍用订阅账号的请求——因为本地 auth.json 里存过 OAuth 凭据,它优先级更高。想让环境变量生效,先 /logout。反过来,Models.getAuth() 不会在刷新失败后悄悄回落到环境变量,避免「看起来能跑其实在用另一把钥匙」。

5.4.2OAuth:CredentialStore.modify() 锁内刷新

CredentialStore 的 modify() 是唯一写路径,按 provider ID 串行化(进程内 promise 链,文件存储再叠加文件锁)。刷新令牌之所以必须放进锁里:许多授权服务器在 refresh 时会轮换 refresh token(旧的一次性作废),若两个并发请求各自刷新,后写盘的会覆盖先写盘的,直接把唯一有效的 refresh token 弄丢。源码还特意注释:刷新一旦开始就不再响应 abort——取消的调用方可能丢弃唯一有效的 token,因此只用 15 秒超时兜底。

packages/ai/src/auth/resolve.ts(节选,注释为原文)TypeScript
/**
 * Refresh a stored OAuth credential under the credential-store lock and persist
 * the result before the lock is released. `needsRefresh` is re-checked under
 * the lock, so concurrent callers and processes refresh only once.
 * ... a cancelled caller could discard the only valid refresh token.
 */
export async function refreshStoredOAuthCredential(
  credentials: CredentialStore, providerId: string,
  oauth: OAuthAuth, needsRefresh: (c: OAuthCredential) => boolean,
  signal: AbortSignal,
): Promise<OAuthCredential | undefined> { ... }

5.4.3PKCE:无后端的公钥客户端也能安全换码

PKCE(Proof Key for Code Exchange,RFC 7636)解决的问题是:CLI 这种「公共客户端」没有 client secret,授权码截获攻击下原本不堪一击。做法是在发起授权前先生成一对 verifier / challenge:

packages/ai/src/auth/oauth/pkce.ts(全文核心)TypeScript
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
  // Generate random verifier
  const verifierBytes = new Uint8Array(32);
  crypto.getRandomValues(verifierBytes);
  const verifier = base64urlEncode(verifierBytes);

  // Compute SHA-256 challenge
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const challenge = base64urlEncode(new Uint8Array(hashBuffer));

  return { verifier, challenge };
}

流程:客户端把 challenge(SHA-256 哈希,S256 方式)随授权请求发出,只有持有原始 verifier 的客户端才能在回调时通过验证完成换码——即使授权码在重定向途中被截获,攻击者没有 verifier 也无法使用它。Pi 的回调由 callback-server.ts 在本机临时监听(localhost 回调),随用随关。

不适合开本地端口的场景走设备码流程(RFC 8628):向服务器申请 device_code + user_code,用户在任意浏览器输入码完成授权,客户端轮询 token 端点。device-code.ts 严格按 RFC 实现:服务器未给 interval 时默认 5 秒;收到 slow_down 按 RFC 3.5 节把间隔加 5 秒(若服务器在 interval 字段给了新下限则优先用服务器值——源码注释解释这是为了容忍 WSL/VM 时钟漂移);到期抛出超时错误。

CredentialStore.modify() 的串行化语义,就是数据库「select for update + 提交后释放」的内存版;把 OAuth 刷新放进锁内,等价于用分布式锁保护「读旧 token → 换新 token → 写回」这个必须原子的临界区。凡是涉及外部系统一次性令牌的写回操作,都可以抄这个模式。

§5.5

自定义端点:models.json、modelOverrides 与密钥插值

接入新端点的第一条路径完全不用写代码:~/.pi/agent/models.json。只要端点说的是 Pi 已支持的协议(绝大多数 Ollama、LM Studio、vLLM、SGLang 与代理部署),声明 baseUrl + api + models 即可。官方文档给出的 Ollama 完整示例:

~/.pi/agent/models.json — 官方 Ollama 示例(docs/models.md 原文)JSON
{
  "$schema": "https://pi.dev/schemas/models.schema.json",
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "models": [
        { "id": "qwen2.5-coder:7b" }
      ]
    }
  }
}

几个语义要点(均出自 docs/models.md 与 models-configuration.md):

  • 那个假的 apiKey: "ollama" 只是让模型对 Pi「可用」,Ollama 本身会忽略它;
  • 打开 /model 会重新加载该文件,改完不用重启;
  • providers 下的 key 是 provider ID:命中内置 ID(如 anthropic)就是修改它,新 ID 则新增 provider(必须有 baseUrl 和 provider 或 model 级的 api);
  • models 条目按 id 匹配:新 ID 追加,已有 ID 整体替换;只想改内置模型的元数据请用 modelOverrides,它不会替换模型列表,未知 ID 直接忽略,嵌套对象(如 cost)按字段合并、标量整体替换;
  • 给内置 provider 只设 baseUrl,等于把它的所有模型与认证原样路由到代理:{ "providers": { "anthropic": { "baseUrl": "https://proxy.example.com/v1" } } }。

5.5.1密钥取值:字面量、$NAME 插值与 !command

apiKey 与 headers 的值支持同一套语法:字面量、$NAME / ${NAME} 环境变量插值、以 ! 开头的命令输出(请求时执行、Pi 不缓存);转义用 $$ 与 $! 分别得到字面 $ 与前导 !。典型用法——从 1Password CLI 动态取短期密钥:

~/.pi/agent/models.json — 环境变量与命令取密钥(语法示例)JSON
{
  "providers": {
    "internal-gateway": {
      "baseUrl": "https://llm.internal.example.com/v1",
      "api": "openai-completions",
      "apiKey": "!op read op://vault/llm-gateway/api-key",
      "headers": { "X-Team": "${TEAM_NAME}" },
      "models": [
        { "id": "internal-chat-v2", "contextWindow": 200000, "maxTokens": 8192,
          "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
      ]
    },
    "anthropic": {
      "modelOverrides": { "claude-sonnet-4-5": { "maxTokens": 32768 } }
    }
  }
}

模型条目还能声明 reasoning / thinkingLevelMap / input / inputLimits / promptCache / cost 等元数据;OpenAI 系 API 还支持 samplingParams 与按思考级别的 samplingParamsByThinkingLevel(键是 Pi 的 off/minimal/low/medium/high/xhigh/max,后写的值按 key 覆盖)。安全提示照抄文档:!command 会在请求时在你机器上执行,models.json 里的凭据命令务必保密,不要加载不可信目录下的配置。

models.json 的定位就是 Spring 的 application.yml 外部化配置:同 ID 覆盖、按字段深合并的 override 语义(modelOverrides)对应 profile 覆盖;$NAME/${NAME} 插值对应 @Value("${...}") 属性占位符;!command 则相当于从外部进程(如 Vault /云 KMS CLI)拉取运行期密钥,避免把长期密钥写进文件。

§5.6

进阶路径:Provider 扩展与虚拟模型

当需求超出静态配置——需要动态发现模型、自定义登录流、或实现没人支持的 wire 协议——走第二条路:Provider 扩展。它是 Pi 扩展系统的一个应用:在扩展工厂里调用 pi.registerProvider(),Pi 会等待异步工厂完成后再继续启动,所以注册的模型对启动时的模型选择和 pi --list-models 都可见。官方文档的「最小合适集成」选择表:

需求用
在已支持协议后加模型models.json
改现有 provider 的端点 / 请求头models.json 或小扩展
动态发现模型带 refreshModels 的 provider
自定义 /login 流程原生或 legacy OAuth 配置
实现未支持的 wire 协议带 stream / streamSimple 的 provider
provider 扩展 · 注册 OpenAI 兼容端点(docs/custom-provider.md 原文)TypeScript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.registerProvider("acme", {
    name: "Acme AI",
    baseUrl: "https://api.acme.example/v1",
    apiKey: "$ACME_API_KEY",
    api: "openai-completions",
    models: [
      {
        type: "chat",
        id: "acme-chat",
        name: "Acme Chat",
        reasoning: false,
        input: ["text"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 128000,
        maxTokens: 8192,
      },
    ],
  });
}

两个高频变形:只注册 baseUrl/headers 可以重定向内置 provider(保留其目录与认证,适合代理场景,文档特别提醒不要顺手复制内置模型列表——那会把「小覆盖」变成「目录替换」,随上游漂移);注册完整的 Provider(来自 @earendil-works/pi-ai,即 §5.3 里 createProvider() 的产物)可获得原生认证、模型过滤、refreshModels 发现等全部能力。若协议已有人实现,优先复用 Pi 内置 API 实现并只定制认证与端点——文档直言这比复制一份 stream 实现更安全,能保留 Pi 的消息转换、工具处理、用量统计与兼容性行为。

5.6.1虚拟模型:把「路由策略」包装成一个模型

虚拟模型(virtual model)是 /model 列表里可选的一项,但每次请求前由 route() 决定实际派发给哪个物理模型——用户只选一次「auto」,框架按任务、成本或会话状态自动在大小模型之间切换。Pi 严格区分两对概念:选择(用户选的虚拟模型与级别,记入 model_change 事件,恢复会话时还原)与派发(每次请求真正使用的物理模型,记在 assistant 消息的 provider/model 字段上)。Provider 永远只看到物理模型,回放历史与手动切模型行为一致。

虚拟模型 · 官方最小路由器(docs/virtual-models.md 原文)TypeScript
export default function (pi: ExtensionAPI) {
  pi.registerVirtualModel({
    provider: "router",
    id: "auto",
    name: "Auto",
    thinkingLevels: ["low", "high"],
    route(request, ctx) {
      // Tool follow-ups and retries stay on the model that handled the turn.
      const sticky = request.failed ?? request.previous;
      if (request.reason !== "user" && sticky) {
        return { model: sticky.model, thinkingLevel: sticky.thinkingLevel ?? "medium" };
      }
      const id = request.thinkingLevel === "high" ? "claude-sonnet-4-5" : "claude-haiku-4-5";
      return { model: ctx.modelRegistry.find("anthropic", id)!, thinkingLevel: "medium" };
    },
  });
}

route() 的 reason 分四种:user(用户新消息后的首个请求)、continuation(agent loop 里的后续请求,如工具结果回填后)、retry(失败重试,failed 带失败详情)、direct(agent loop 之外,如压缩摘要)。示例里的「sticky」策略是实用关键:continuation 与 retry 保持上一物理模型,保住 prompt cache 与 thinking 签名的有效性。路由器还可以返回 state(必须 JSON 可序列化),Pi 把它存在会话分支上、随分支与 fork 走、压缩后仍保留。约束:虚拟模型不能路由到另一个虚拟模型;路由抛错或选中无凭据的模型时,该请求以错误结束。

§5.7

工具调用协议:Tool 声明的归一化

第 4 讲定义了统一的 Tool 接口,这里补上「归一化」的完整链路。首先是 toToolDeclaration()——把一个工具从「可执行对象」降维成「给模型看的声明」:

packages/ai/src/utils/transcript.ts(原文节选)TypeScript
/** Strip executable and display-only fields from a tool before
 *  transcript comparison or persistence. */
export function toToolDeclaration(tool: Tool): Tool {
  return {
    name: tool.name,
    description: tool.description,
    parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"],
    ...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
  };
}

这个看似朴素的深拷贝有两层用意:其一,剥离执行相关的字段——进入 transcript / system 消息的工具只有声明,没有实现;其二,JSON.parse(JSON.stringify(...)) 顺手丢掉 TypeBox Schema 上的不可枚举 kind 标记与 undefined 字段,使 declarationsEqual()(序列化后字符串比较)成为精确比较。会话中的工具增删由此可被记录:系统消息携带 toolsAdded / toolsRemoved,getCurrentTools() 按序重放得到当前工具集;一个「改名重定义」会被拆成「移除 + 新增」。

5.7.1为什么统一到 JSON Schema

Tool.parameters 是参数的 JSON Schema(可用 pi-schema 构建器、TypeBox 或任意 JSON Schema 对象生成)。之所以统一在 JSON Schema 而不是 TypeScript 类型或各协议私有格式:一是它是所有厂商工具协议的事实交集——Anthropic 的 input_schema 与 OpenAI 的 parameters 本质都是 JSON Schema;二是它可序列化,能随会话持久化、跨进程传输、被运行时校验(第 7 讲的工具参数校验就建立在它之上);三是 strict 结构化输出只需对 schema 做「收紧」变换(makeStrictJsonSchema(),如补 additionalProperties: false),无需理解厂商方言。

5.7.2同一份声明,两种 wire 形态

出站时各适配器的 convertTools() 把统一声明翻译成协议格式,形态差异不小:

协议工具 wire 形态(简化)
anthropic-messages{ name, description, input_schema: { type:"object", properties, required, $defs? }, strict?, cache_control? }
openai-responses{ type:"function", name, description, parameters, strict? };语法约束工具变 { type:"custom", format:{ type:"grammar", syntax, definition } }
openai-completions{ type:"function", function: { name, description, parameters, strict? } }(多一层 function 包裹)

Anthropic 侧的 convertTools() 有两处独特处理:其一,根级关键字限制——Anthropic 会拒绝根级 anyOf/oneOf/allOf,所以非 strict 时只保留 type/properties/required 加上共享定义($defs/definitions,让属性里的 $ref 仍可解析);其二,strict 黑名单——minimum/maximum/exclusiveMin/Max/multipleOf/maxItems(≠0,1)/uniqueItems/minContains/maxContains/minProperties/maxProperties 以及白名单之外的 format 会让整个请求 400,源码用 isAnthropicStrictUnsupportedKeyword 逐 key 检查后决定是否标 strict: true。

strict 工具还有配额问题:strict-tool-limits.ts 记录了 Claude 的观测上限——最多 20 个 strict 工具、全部 strict 工具合计 16 个可选/联合类型参数。超限时返回特定错误信息,Pi 把该会话标记进一个内存 Set,之后的请求直接以非 strict 发送(工具集很少在会话中途变化,重试 strict 只会再次被拒,还会因工具定义改写而打掉 prompt cache)。该 Set 注册了会话级清理,并设有 10,000 条上限。

packages/ai/src/api/anthropic-messages.ts · convertTools 核心片段(节选)TypeScript
const legacyInputSchema = {
  type: "object" as const,
  properties: schema.properties ?? {},
  required: schema.required ?? [],
  ...(schema.$defs === undefined ? {} : { $defs: schema.$defs }),
  ...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
};
// Keep shared definitions so property $refs still resolve. Other root
// keywords stay out: Anthropic rejects root anyOf/oneOf/allOf with a 400.
const inputSchema =
  strict === true ? { ...parameters, ...legacyInputSchema } : legacyInputSchema;

return {
  name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
  description: tool.description,
  ...(strict === true ? { strict: true } : {}),
  input_schema: inputSchema,
  ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
};
§PRACTICE

动手实践:把 Ollama 模型接进 Pi 并完成一次工具调用

  1. 01安装并启动 Ollama,拉取一个支持工具调用的模型:ollama pull qwen2.5-coder:7b,确认 http://localhost:11434/v1 可访问(Ollama 默认暴露 OpenAI 兼容端点)。
  2. 02编辑 ~/.pi/agent/models.json,写入 §5.5 的官方 Ollama 配置;注意 api 用 openai-completions,apiKey 填占位值 "ollama"。
  3. 03启动 Pi 打开 /model,在 ollama provider 下应能看到 qwen2.5-coder:7b(打开 /model 会热加载 models.json)。
  4. 04选它并发送「列出当前目录的 TypeScript 文件」:模型应发出 ls 工具调用、Pi 执行并回填结果。观察 TUI 中的工具调用块——这就是 §5.7 的 convertTools → wire → function_call_output 全链路。
  5. 05进阶:给内置模型加一条 modelOverrides(如把 anthropic 某模型的 maxTokens 调成 32768);再试 apiKey: "!op read ..." 或 "$MY_KEY" 两种取密钥方式,对比 /model 可用性变化。
  6. 06对照源码复盘:在 api/openai-completions.ts 中找到 convertTools()(约 1477 行)与 convertMessages(),把刚才那次请求的每个字段在源码里指出处。

误区一:「OpenAI 兼容」等于全兼容。Responses API 拒绝 max_output_tokens < 16、要求 item ID 以 fc_ 开头;部分网关会拒绝 strict 字段或 prompt_cache_options。Pi 的 compat 开关只为验证过的差异而设——文档原话是「Do not enable them based only on an endpoint advertising OpenAI or Anthropic compatibility」。接入新端点时先确认请求字段与响应行为,再动 compat。

误区二:历史消息可以原样跨厂商回放。thinking 签名只对产出它的模型有效(跨模型发送会被拒),Pi 在 transformMessages() 里整块丢弃异模型 thinking;工具调用 ID 两家格式互不兼容(长度、字符集、复合结构),必须经 normalizeToolCallId 归一化。以为「消息数组直接转发」就能换模型的,都会在第一个 400 上栽跟头。

误区三:环境变量优先级最高。实际顺序是 --api-key > auth.json > models.json > 环境变量/云凭据。本地存过 OAuth 登录后,CI 里设置的环境变量会被「拥有该 provider」的存储凭据压住;想切换先 /logout。

§CHECK

自测问答

① 在 Pi 中新增一个厂商,需要写哪几类文件?各承担什么职责?

四类,按需取舍:① providers/<id>.ts——用 createProvider() 声明 id、baseUrl、auth、models 与绑定的 api;② providers/data/<id>.json + 自动生成的 <id>.models.ts——模型目录元数据;③ 若协议是新的:api/<name>.ts 适配器 + <name>.lazy.ts 懒加载壳,并在 all.ts 注册工厂;④ 若登录是新的:auth/oauth/<id>.ts 实现登录/刷新/toAuth。若只是 OpenAI 兼容端点加模型,一个 models.json 条目即可,无需任何源码。

② OAuth PKCE 解决什么问题?verifier 与 challenge 分别如何产生?

PKCE(RFC 7636)解决「公共客户端无 client secret 时的授权码截获」问题:CLI 无法保守密钥,若授权码在重定向时被截获即可换 token。方案是授权请求带 challenge(SHA-256 哈希),换 token 时出示原始 verifier;截获者拿不到 verifier,授权码对他无用。Pi 的 generatePKCE() 用 crypto.getRandomValues 生成 32 随机字节做 verifier(base64url 编码),再用 crypto.subtle.digest("SHA-256", verifier) 得到 challenge(S256)。

③ 为什么工具参数最终统一为 JSON Schema?Anthropic strict 模式有哪些限制?

JSON Schema 是各厂商工具协议的事实交集(Anthropic input_schema 与 OpenAI parameters 本质都是它),可序列化、可持久化、可运行时校验,strict 化只需统一变换(makeStrictJsonSchema)。Anthropic strict 限制:拒绝根级 anyOf/oneOf/allOf(Pi 只发 type/properties/required/$defs);一批约束关键字会被 400——minimum、maximum、exclusiveMin/Max、multipleOf、maxItems(≠0/1)、uniqueItems、min/maxContains、min/maxProperties,以及白名单外的 string format;配额上最多 20 个 strict 工具、合计 16 个可选/联合参数,超限后 Pi 按会话降级为非 strict。

④ 凭据优先级链的完整顺序是什么?为什么设计成「存储凭据拥有 provider」?

顺序:运行时 --api-key > auth.json 存储凭据 > models.json 的 apiKey > 环境变量/云凭据(ambient)。「拥有」的语义是:一旦显式登录存储了凭据,环境变量就不再兜底——避免用户以为主动指定的 key 生效、实际却静默落到另一把钥匙;也让 OAuth 刷新失败直接报错而非悄悄降级到旧环境变量。副作用是 CI 中想让环境变量生效必须先 /logout。

⑤ .lazy.ts 懒加载的动机是什么?lazyStream 如何同时满足「同步返回流」与「异步 setup」?

动机是启动开销:协议实现与厂商 SDK(anthropic / openai 等 npm 包)体积大(单文件 60–70 KB 源码 + 整包 SDK),而 CLI 启动只需注册表与模型目录;懒加载让重型模块等到第一次真正发请求才进内存。lazyStream 立即返回一个外层 AssistantMessageEventStream(同步),后台跑 setup(认证解析、动态 import),完成后把内部流的事件逐个转发到外层;setup 失败不抛同步异常,而是推入 error 事件(signal 已中止则为 aborted)并结束流,保证调用方契约稳定。