模型协议层 pi-ai
pi-ai 用一套类型与事件统一九种模型 API:MiniMax 实际走的三种 BYOK 形态与 Codex 通道、流式事件怎么归一、思考参数怎么映射,以及落在 ai 包的补丁。
Agent 循环里那一句“交给 streamFn 请求模型”,背后是 @earendil-works/pi-ai。它把 Anthropic、OpenAI、Google、Bedrock 等厂商的接口统一成一套 Model、Context、AssistantMessage 类型和一套流式事件,上层只认这套协议,不关心底下是哪家的 SDK。包里 60 个文件 34346 行,其中一半是自动生成的模型目录 models.generated.ts(17267 行)。
MiniMax 对它用得相当克制:只走其中四种 API 形态,其余 provider 虽然编译进来了却走不到;另一方面又通过 MINIMAX_CHANGES.md 给它加了一批观测与出站控制的接缝。
怎么用
用户直接接触 pi-ai 的地方有三处:
- BYOK 的协议格式。
mcode provider add的--api-format缺省是anthropic-messages(packages/tui/src/cli/program.ts:187),可选值只有三种,定义在packages/tui/src/provider/contract.ts:1:
export const MCODE_PROVIDER_API_FORMATS = [
'anthropic-messages',
'openai-completions',
'openai-responses',
] as const;第四种 openai-codex-responses 只给 Codex OAuth 用,不能手工配置成通用 BYOK,原因见模型系统。
- 兼容开关。兼容 OpenAI 或 Anthropic 接口的第三方服务常有各自的怪癖,pi-ai 用模型上的
compat字段描述它们。MiniMax 允许在config.yaml里给自定义模型写这些字段,按类型逐个读取,布尔字段只接受真正的布尔值(packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts:234)。示意:
custom_provider:
my-provider:
api: openai-completions
models:
my-model:
compat:
thinkingFormat: qwen
supportsDeveloperRole: false- 思考强度。
/model里选的思考档位最终变成 pi-ai 的reasoning选项,映射规则见下文“思考参数”。
九种 API 与注册表
third_party/pi-mono/packages/ai/src/types.ts:6 列出了九种已知 API,Api 类型同时留了开放字符串的口子(src/types.ts:17)。third_party/pi-mono/packages/ai/src/providers/register-builtins.ts:347 把它们注册进一张以 API 名为键的表,每个 provider 模块都是懒加载的:第一次调用时才 import() 对应文件,加载失败也编码成流里的错误事件(register-builtins.ts:185)。下文 provider 文件都在 third_party/pi-mono/packages/ai/src/providers/ 下。
| API | provider 文件 | 底层 | MiniMax 走得到吗 |
|---|---|---|---|
anthropic-messages | anthropic.ts | @anthropic-ai/sdk | 走:BYOK 缺省形态,MiniMax 自家模型也走它 |
openai-completions | openai-completions.ts | openai | 走:BYOK |
openai-responses | openai-responses.ts | openai | 走:BYOK |
openai-codex-responses | openai-codex-responses.ts | 自己的 SSE 请求与 WebSocket | 走:Codex OAuth(公开构建的 CLI 默认隐藏入口,见下文) |
azure-openai-responses | azure-openai-responses.ts | openai 的 AzureOpenAI | 走不到 |
mistral-conversations | mistral.ts | @mistralai/mistralai | 走不到 |
google-generative-ai、google-vertex | google.ts、google-vertex.ts | @google/genai | 走不到 |
bedrock-converse-stream | amazon-bedrock.ts | @aws-sdk/client-bedrock-runtime | 走不到 |
“走不到”是从解析代码看出来的:自定义 provider 的 api 不在三种 BYOK 格式或 Codex 之列时一律回落到 anthropic-messages(model-resolver-byok.ts:142),内置路线的 API 来自 pi 的模型目录,查不到时同样缺省为 anthropic-messages(packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts:69)。pi 的目录里 MiniMax 自家模型就登记在 anthropic-messages 下、指向 MiniMax 的 Anthropic 兼容端点(third_party/pi-mono/packages/ai/src/models.generated.ts:5769 起)。
入口 streamSimple(third_party/pi-mono/packages/ai/src/stream.ts:58)按 model.api 查表,再把调用转给该 provider;调用方没给 API Key 时才去读环境变量(stream.ts:22)。MiniMax 每次都显式传 Key(packages/agent-core/src/pi-turn-runner/agent.ts:79),环境变量这条路基本用不上。
统一的流式事件
所有 provider 都产出同一种事件(src/types.ts:383):
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };每个事件都带着拼到当前为止的 partial,消费者不必自己累加。承载它的是 third_party/pi-mono/packages/ai/src/utils/event-stream.ts:4 的 EventStream:一个队列加异步迭代器,另挂一个 result() Promise;AssistantMessageEventStream 在收到 done 或 error 时结束(event-stream.ts:69)。流函数的契约写在 src/types.ts:236:
// Contract:
// - Must return an AssistantMessageEventStream.
// - Once invoked, request/model/runtime failures should be encoded in the
// returned stream, not thrown.
// - Error termination must produce an AssistantMessage with stopReason
// "error" or "aborted" and errorMessage, emitted via the stream protocol.也就是说错误不抛异常,而是以一条 stopReason 为 error 或 aborted 的消息收尾。Agent 循环正是靠这一点判断“这一步失败了”。
以 Anthropic 为例,third_party/pi-mono/packages/ai/src/providers/anthropic.ts:593 起的循环把原生 SSE 事件逐个翻译:
| Anthropic 原生事件 | pi 事件 |
|---|---|
message_start | 记下 responseId 与初始用量 |
content_block_start(text、thinking、redacted_thinking、tool_use) | text_start、thinking_start、toolcall_start |
content_block_delta(text_delta、thinking_delta、input_json_delta) | 对应的 *_delta;工具参数边收边做容错的 JSON 解析 |
signature_delta | 累加进 thinkingSignature,不单独发事件 |
content_block_stop | 对应的 *_end |
message_delta | 更新 stopReason 与用量 |
异常在 anthropic.ts:761 统一捕获:先把原始异常交给宿主的 onProviderError(MiniMax 补丁),再把消息标成 error 或 aborted 推出 error 事件。OpenAI Chat Completions 这边,思考内容可能出现在 reasoning_content、reasoning 或 reasoning_text 字段,取第一个非空的(third_party/pi-mono/packages/ai/src/providers/openai-completions.ts:320)。各家 SDK 的自动重试默认都关掉了(如 anthropic.ts:582),重试统一交给 agent-core 的传输重试。
跨模型的历史也在这一层处理。third_party/pi-mono/packages/ai/src/providers/transform-messages.ts:64 的 transformMessages 在同一段历史换了模型时:别的模型的思考块改成普通文本,加密的 redacted 思考直接丢弃,工具调用 ID 按目标 API 的规则规范化,缺结果的工具调用补一条空结果(transform-messages.ts:155)。
思考参数
pi-ai 的思考档位是 minimal 到 max 六档,外加 off(src/types.ts:65)。每个模型可以带一张 thinkingLevelMap,把 pi 的档位映射成该模型自己的取值,null 表示不支持该档(src/types.ts:604)。getSupportedThinkingLevels 与 clampThinkingLevel(third_party/pi-mono/packages/ai/src/models.ts:50、:61)据此把请求的档位夹到最近的可用档:先往高处找,再往低处找。各协议的落法不同:
Anthropic。streamSimpleAnthropic(anthropic.ts:802)先看调用方有没有要求思考,要求了再分三种情况,第一种是 MiniMax 的补丁(anthropic.ts:813):
if (!options?.reasoning) {
return streamAnthropic(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions);
}
// Current Claude default-thinking models do not need an explicit `thinking`
// parameter. Keep the agent's reasoning effort on output_config only.
if (isDefaultThinkingModelId(model.id)) {
const effort = mapThinkingLevelToEffort(model, options.reasoning);
return streamAnthropic(model, context, {
...base,
thinkingEnabled: true,
effort,
} satisfies AnthropicOptions);
}默认思考模型只发 output_config.effort;声明了 forceAdaptiveThinking 的模型发 thinking.type 为 adaptive 再加 effort;其余老模型走预算制,adjustMaxTokensForThinking(third_party/pi-mono/packages/ai/src/providers/simple-options.ts:30)给 minimal 到 high 的默认预算依次是 1024、2048、8192、16384 个 token,xhigh 与 max 按 high 算,预算超过输出上限时压缩预算、给正文留出 1024 个 token。档位到 effort 的映射先查 thinkingLevelMap,查不到就把 minimal 与 low 映射成 low,medium、high 按原名,其余一律 high(anthropic.ts:782)。对支持推理的模型,没开思考时显式发 thinking.type 为 disabled(anthropic.ts:1065)。
OpenAI Chat Completions。档位先夹紧再作为 reasoningEffort 传入(openai-completions.ts:442),真正发什么字段由 compat.thinkingFormat 决定(openai-completions.ts:561):
thinkingFormat | 请求里的字段 |
|---|---|
openai(缺省) | reasoning_effort |
openrouter | reasoning.effort |
deepseek | thinking.type,支持时再加 reasoning_effort |
together | reasoning.enabled,支持时再加 reasoning_effort |
zai | thinking.type |
qwen | 顶层 enable_thinking |
qwen-chat-template | chat_template_kwargs.enable_thinking |
string-thinking | 顶层字符串 thinking |
ant-ling | 映射值非空时才发 reasoning.effort |
OpenAI Responses。有档位时发 reasoning.effort 与 summary: "auto",并要求返回加密的推理内容以便多轮续用;没开思考时,若模型支持就显式发 thinkingLevelMap.off 的值,缺省为 none(third_party/pi-mono/packages/ai/src/providers/openai-responses.ts:264)。
Codex。与 Responses 类似,effort 经 thinkingLevelMap 转换后放进 reasoning(third_party/pi-mono/packages/ai/src/providers/openai-codex-responses.ts:472)。例如回移进来的 GPT-5.6 Luna 把 minimal 映射成 low,并声明支持 xhigh 与 max(models.generated.ts:7918)。
对自定义 provider,MiniMax 的模型系统在此之上再生成一份请求补丁,用来覆盖 pi-ai 的默认行为,见模型系统。
Codex 通道
Codex 是用 ChatGPT 账号登录的 OpenAI 编码模型,provider 为 openai-codex,API 为 openai-codex-responses,默认地址是 https://chatgpt.com/backend-api(openai-codex-responses.ts:52)。请求体(openai-codex-responses.ts:438)不在服务端存储(store: false),系统提示词放进 instructions,要求返回加密推理内容,用会话 ID 作 prompt_cache_key,允许并行工具调用。
传输有 SSE 和 WebSocket 两种。缺省的 auto 先试 WebSocket,在收到任何事件之前失败就退回 SSE,并记住这个会话以后都走 SSE;一旦已经开始输出,就不再切换。MiniMax 的两条补丁都落在这里(openai-codex-responses.ts:177):
function resolveCodexTransport(options?: OpenAICodexResponsesOptions): NonNullable<StreamOptions["transport"]> {
const requestedTransport = options?.transport ?? "auto";
return requestedTransport === "auto" && options?.fetch ? "sse" : requestedTransport;
}
function createSSEHeaderTimeout(): { signal: AbortSignal; clear: () => void; error: () => Error | undefined } {
const controller = new AbortController();
let error: Error | undefined;
const timeout = setTimeout(() => {
error = new Error(`Codex SSE response headers timed out after ${DEFAULT_SSE_HEADER_TIMEOUT_MS}ms`);
controller.abort(error);
}, DEFAULT_SSE_HEADER_TIMEOUT_MS);WebSocket 的建连无法使用宿主注入的 fetch,企业代理与证书配置会被绕过,所以宿主注入了 fetch(台账说桌面端就是如此)而传输仍是 auto 时,直接走 SSE。SSE 等响应头的期限 DEFAULT_SSE_HEADER_TIMEOUT_MS 是 30 秒(openai-codex-responses.ts:57),原来的 10 秒在上游短暂变慢时会打断正常的长任务;WebSocket 建连期限是 15 秒,自身重试次数缺省为 0。
登录用的是 third_party/pi-mono/packages/ai/src/utils/oauth/openai-codex.ts,支持浏览器与设备码两种方式;MiniMax 在外面包了自己的 CodexOAuthManager,借 pi-coding-agent 的 AuthStorage 把凭据存进数据目录的 codex-auth.json(packages/local-runtime-v2/src/service/model-system/codex-oauth.ts:395)。这个入口受 beta.codexOAuth 控制(同文件 :391),而 TUI 的嵌入式宿主把它强制设成“是否内部构建”(packages/tui/src/runtime/embedded-host.ts:51);公开仓库的构建脚本把构建变体定为 standard(scripts/build.mjs:108),按它构建出来的 mcode 里 Codex 入口是隐藏的。
落在 ai 包的补丁
补丁台账里有 13 条涉及 ai 包,对照代码如下:
| 日期(2026 年) | 补丁 | 代码位置 |
|---|---|---|
| 06-16 | 构建只编译已提交的模型目录 | 公开仓库的 package.json 没有 scripts,无从核对 |
| 06-19 | 测试:自适应思考模型断言加入 Fable 5 | 测试 |
| 07-08 | 导出 convertResponsesMessages 与 convertResponsesTools | third_party/pi-mono/packages/ai/src/index.ts:29;v1 运行时用它构造 Responses 计数请求(packages/local-runtime/src/context/responses-input-tokens-body.ts:3) |
| 07-15 | GPT-5.6 目录与 max 思考档 | 如 models.generated.ts:7918 |
| 07-22 | stream 选项新增 fetch | src/types.ts:144;simple-options.ts:13 把它传下去 |
| 07-26 | 注入 fetch 时 Codex 改走 SSE | openai-codex-responses.ts:177 |
| 07-28 | OAuth 刷新可注入 fetch | third_party/pi-mono/packages/ai/src/utils/oauth/types.ts:56 的 OAuthRequestOptions |
| 07-31 | 回放时保留只有签名的 thinking 块 | anthropic.ts:1167 |
| 08-08 | onProviderError | src/types.ts:122、third_party/pi-mono/packages/ai/src/utils/provider-error.ts:7;agent-core 用它记录原始错误(packages/agent-core/src/pi-turn-runner/metrics.ts:482) |
| 08-18 | onProviderStreamEvent | src/types.ts:130、provider-error.ts:36;开发用的上下文检查器消费它(packages/local-runtime-v2/src/service/llm-context-inspector/capture-recorder.ts:120) |
| 08-18 | Codex 登录的授权码交换走宿主 fetch | utils/oauth/openai-codex.ts:167;登录回调新增 fetch(utils/oauth/types.ts:52) |
| 08-26 | Codex SSE 响应头超时 30 秒 | openai-codex-responses.ts:57 |
| 08-27 | Claude 默认思考模型不发显式 thinking | anthropic.ts:164 |
两个观测接缝的实现放在 provider-error.ts 里,这个文件与 types.ts 里 onProviderError 的注释都是中文,和 pi 其余的英文注释明显不同,从代码推断是本地补丁加的。它们的共同原则是只观测、不改变结果:回调抛错被吞掉,没注册回调时 provider 直接迭代原始流,没有额外开销(provider-error.ts:36)。
默认思考模型的判定函数也是补丁的一部分(anthropic.ts:164):
export const DEFAULT_THINKING_MODEL_IDS = [
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
"claude-mythos-preview",
] as const;
export function isDefaultThinkingModelId(value: unknown): boolean {
if (typeof value !== "string") return false;
const normalizedId = value.trim().toLowerCase();
// BYOK owns the model's context configuration. Ignore only the exact [1m]
// decoration for default-thinking classification; preserve the wire model id.
const baseId = normalizedId.replace(/\[1m\]$/u, "");
return (DEFAULT_THINKING_MODEL_IDS as readonly string[]).includes(baseId);
}它从包入口导出(index.ts:12),MiniMax 的模型系统与请求补丁都复用它,不必再维护一份模型名单。台账说它由“两个本地运行时”共享,但在公开仓库里只有 v2 的两个文件 import 了它(packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts:1 与 packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts:1)。
07-31 那条是挑回来的上游修复。原来的序列化逻辑先跳过所有空的 thinking 块,再检查签名,于是 Anthropic 返回的“内容为空但签名有效”的块在回放时被丢掉,下一次请求可能签名校验失败。现在的判断(anthropic.ts:1167)只在“内容为空且没有签名”时才跳过。
下一篇:模型系统:目录、BYOK 与连通性测试——模型键怎样解析成 pi-ai 的 Model,BYOK 怎样先测后存,API Key 存在哪里。