pi-ai(下):Provider 适配、认证与工具调用协议
阶段二 · 模型层第 4 讲的统一消息模型在这里「落地」:一套内部 Message,如何在十几种厂商 wire 协议之间进出。
如果说上一讲的 Message 体系是 Pi 的「通用语」,本讲就是同声传译室:api/ 目录逐个协议做出站转换,providers/ 注册厂商与模型目录,auth/ 负责凭据解析与 OAuth,models.json 让你不动源码接入自定义端点。
学习目标
说出 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 限制。
api/ 适配层:目录结构与懒加载设计
先打一个比方:api/ 目录就像 JDBC 驱动集合——上层(Agent Loop)只面向 StreamFunction 这一个「接口」编程,下层每个厂商协议一个实现文件。Pi 目前内置的适配器家族如下(均在 packages/ai/src/api/):
| 适配器文件 | wire 协议 / 用途 |
|---|---|
anthropic-messages.ts | Anthropic Messages API(也服务 Claude 订阅 OAuth 与大量 Anthropic 兼容网关) |
openai-responses.ts | OpenAI Responses API(新版接口,input 数组 + function_call item) |
openai-completions.ts | OpenAI Chat Completions(兼容面最广,Ollama / vLLM / LM Studio / 多数代理都走它) |
google-generative-ai.ts / google-vertex.ts | Google Gemini 与 Vertex AI(共享 google-shared.ts 转换逻辑) |
bedrock-converse-stream.ts | AWS Bedrock Converse Stream |
mistral-conversations.ts | Mistral Conversations API |
azure-openai-responses.ts | Azure 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 全文只有一行核心逻辑:
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。
/**
* 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 打开得越快。
统一消息 → 各协议 wire 格式:请求出站转换管道
每个适配器的入口签名都是同构的:stream(model, context, options) => AssistantMessageEventStream。无论哪个协议,出站前都要经过一条固定的「清洗 → 转换 → 装配」管道。下面这张图是本讲的核心,建议对照源码走一遍:
buildParams 是协议分叉点;同名函数可在 api/anthropic-messages.ts 与 api/openai-responses-shared.ts 中对照阅读。5.2.1共享清洗:transformMessages() 做的四件「脏活」
这个 200 行的共享函数(api/transform-messages.ts)在进入任何协议转换前,把跨厂商的坑统一抹平:
- 图像降级:模型的
input不含"image"时,把 user 消息与 toolResult 里的图片替换成"(image omitted: model does not support images)"占位文本。 - 跨模型 thinking 清理:thinking 签名(signature / encrypted reasoning)只对产出它的模型有效,切换模型回放历史时会直接 400。函数对非同模型的
thinking块整块丢弃(redacted / 有签名的除外——它们是回放所必需的)。 - 工具调用 ID 归一化:OpenAI Responses 生成的 ID 可达 450+ 字符、含
|等特殊字符;Anthropic 要求^[a-zA-Z0-9_-]+$且最长 64。函数建立 old→new 映射表,assistant 消息里的toolCall.id与后续toolResult.toolCallId成对替换。 - 孤儿 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 格式(节选):
{
"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。
{
"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 两处。
providers/ 注册结构:每厂商一个文件,all.ts 汇总
providers/ 与 api/ 是两个正交的维度:api 是「说什么协议」,provider 是「谁在说话」。一个 provider 声明自己的 ID、名称、baseUrl、认证方式、模型目录,并绑定一个 api 实现。目录里每厂商两个文件:<name>.ts(注册逻辑,通常不到百行)与 <name>.models.ts(由 catalog/generate.ts 自动生成的模型目录)。以 Anthropic 为例:
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
});
}
// 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 由构建脚本生成,则类似注解处理器产出的元数据文件——手写容易漂移,生成保证与上游目录同步。
认证体系:凭据优先级链与 OAuth
auth/ 目录分两层:根目录四个文件定义抽象,oauth/ 子目录放各厂商的登录流程实现。
| 文件 | 职责 |
|---|---|
types.ts | ProviderAuth{apiKey?, oauth?}、ApiKeyAuth{login?, check?, resolve}、OAuthAuth{name, login, refresh, toAuth}、CredentialStore 接口 |
context.ts | defaultProviderAuthContext():从 process.env 读环境变量、用 node:fs 检查文件存在性(浏览器下安全降级) |
credential-store.ts | 默认的 InMemoryCredentialStore:按 provider ID 存一条凭据,所有写操作经 modify() 串行化;应用层注入持久化实现(coding-agent 落到 ~/.pi/agent/auth.json) |
resolve.ts | resolveProviderAuth():整个凭据解析链的入口;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,环境变量只在什么都没存时兜底):
overrides?.apiKey:命令行 / API 传入的一次性 key,直接构造ApiKeyCredential走resolveApiKey();credentials.read(providerId):读auth.json。若是 OAuth 凭据且需要刷新(剩余有效期 < 默认 5 分钟),进refreshStoredOAuthCredential();- 都没有时,走
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 秒超时兜底。
/**
* 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:
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 → 写回」这个必须原子的临界区。凡是涉及外部系统一次性令牌的写回操作,都可以抄这个模式。
自定义端点:models.json、modelOverrides 与密钥插值
接入新端点的第一条路径完全不用写代码:~/.pi/agent/models.json。只要端点说的是 Pi 已支持的协议(绝大多数 Ollama、LM Studio、vLLM、SGLang 与代理部署),声明 baseUrl + api + models 即可。官方文档给出的 Ollama 完整示例:
{
"$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 动态取短期密钥:
{
"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)拉取运行期密钥,避免把长期密钥写进文件。
进阶路径:Provider 扩展与虚拟模型
当需求超出静态配置——需要动态发现模型、自定义登录流、或实现没人支持的 wire 协议——走第二条路:Provider 扩展。它是 Pi 扩展系统的一个应用:在扩展工厂里调用 pi.registerProvider(),Pi 会等待异步工厂完成后再继续启动,所以注册的模型对启动时的模型选择和 pi --list-models 都可见。官方文档的「最小合适集成」选择表:
| 需求 | 用 |
|---|---|
| 在已支持协议后加模型 | models.json |
| 改现有 provider 的端点 / 请求头 | models.json 或小扩展 |
| 动态发现模型 | 带 refreshModels 的 provider |
自定义 /login 流程 | 原生或 legacy OAuth 配置 |
| 实现未支持的 wire 协议 | 带 stream / streamSimple 的 provider |
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 永远只看到物理模型,回放历史与手动切模型行为一致。
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 走、压缩后仍保留。约束:虚拟模型不能路由到另一个虚拟模型;路由抛错或选中无凭据的模型时,该请求以错误结束。
工具调用协议:Tool 声明的归一化
第 4 讲定义了统一的 Tool 接口,这里补上「归一化」的完整链路。首先是 toToolDeclaration()——把一个工具从「可执行对象」降维成「给模型看的声明」:
/** 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 条上限。
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 } : {}),
};
动手实践:把 Ollama 模型接进 Pi 并完成一次工具调用
- 01安装并启动 Ollama,拉取一个支持工具调用的模型:
ollama pull qwen2.5-coder:7b,确认http://localhost:11434/v1可访问(Ollama 默认暴露 OpenAI 兼容端点)。 - 02编辑
~/.pi/agent/models.json,写入 §5.5 的官方 Ollama 配置;注意api用openai-completions,apiKey填占位值"ollama"。 - 03启动 Pi 打开
/model,在ollamaprovider 下应能看到qwen2.5-coder:7b(打开 /model 会热加载 models.json)。 - 04选它并发送「列出当前目录的 TypeScript 文件」:模型应发出
ls工具调用、Pi 执行并回填结果。观察 TUI 中的工具调用块——这就是 §5.7 的 convertTools → wire → function_call_output 全链路。 - 05进阶:给内置模型加一条
modelOverrides(如把anthropic某模型的maxTokens调成 32768);再试apiKey: "!op read ..."或"$MY_KEY"两种取密钥方式,对比/model可用性变化。 - 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。
自测问答
① 在 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)并结束流,保证调用方契约稳定。