# 模型协议层 pi-ai

> pi-ai 用一套类型与事件统一九种模型 API：MiniMax 实际走的三种 BYOK 形态与 Codex 通道、流式事件怎么归一、思考参数怎么映射，以及落在 ai 包的补丁。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.net/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.net/manual/minimax-code/pi-ai
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

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`：

```ts
export const MCODE_PROVIDER_API_FORMATS = [
  'anthropic-messages',
  'openai-completions',
  'openai-responses',
] as const;
```

第四种 `openai-codex-responses` 只给 Codex OAuth 用，不能手工配置成通用 BYOK，原因见[模型系统](https://daiw.net/manual/minimax-code/model-system)。

- **兼容开关**。兼容 OpenAI 或 Anthropic 接口的第三方服务常有各自的怪癖，pi-ai 用模型上的 `compat` 字段描述它们。MiniMax 允许在 `config.yaml` 里给自定义模型写这些字段，按类型逐个读取，布尔字段只接受真正的布尔值（`packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts:234`）。示意：

```yaml
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`），环境变量这条路基本用不上。

```mermaid
flowchart LR
  A["Agent 循环"] --> B["streamFn（agent-core 的包装链）"]
  B --> C["streamSimple"]
  C --> D["按 model.api 查注册表"]
  D --> E["provider 模块（懒加载）"]
  E --> F["厂商 SDK 或 fetch"]
  F -. 原生流事件 .-> E
  E -. AssistantMessageEvent .-> A
```

## 统一的流式事件

所有 provider 都产出同一种事件（`src/types.ts:383`）：

```ts
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`：

```text
// 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`）：

```ts
	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 的默认行为，见[模型系统](https://daiw.net/manual/minimax-code/model-system)。

## 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`）：

```ts
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 包的补丁

[补丁台账](https://daiw.net/manual/minimax-code/pi-mono)里有 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`）：

```ts
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 与连通性测试](https://daiw.net/manual/minimax-code/model-system)——模型键怎样解析成 pi-ai 的 `Model`，BYOK 怎样先测后存，API Key 存在哪里。
