模型协议层 pi-ai

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

作者 David更新于 第 7 篇(共 33 篇)

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/ 下。

APIprovider 文件底层MiniMax 走得到吗
anthropic-messagesanthropic.ts@anthropic-ai/sdk走:BYOK 缺省形态,MiniMax 自家模型也走它
openai-completionsopenai-completions.tsopenai走:BYOK
openai-responsesopenai-responses.tsopenai走:BYOK
openai-codex-responsesopenai-codex-responses.ts自己的 SSE 请求与 WebSocket走:Codex OAuth(公开构建的 CLI 默认隐藏入口,见下文)
azure-openai-responsesazure-openai-responses.tsopenai 的 AzureOpenAI走不到
mistral-conversationsmistral.ts@mistralai/mistralai走不到
google-generative-ai、google-vertexgoogle.ts、google-vertex.ts@google/genai走不到
bedrock-converse-streamamazon-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
openrouterreasoning.effort
deepseekthinking.type,支持时再加 reasoning_effort
togetherreasoning.enabled,支持时再加 reasoning_effort
zaithinking.type
qwen顶层 enable_thinking
qwen-chat-templatechat_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 与 convertResponsesToolsthird_party/pi-mono/packages/ai/src/index.ts:29;v1 运行时用它构造 Responses 计数请求(packages/local-runtime/src/context/responses-input-tokens-body.ts:3)
07-15GPT-5.6 目录与 max 思考档如 models.generated.ts:7918
07-22stream 选项新增 fetchsrc/types.ts:144;simple-options.ts:13 把它传下去
07-26注入 fetch 时 Codex 改走 SSEopenai-codex-responses.ts:177
07-28OAuth 刷新可注入 fetchthird_party/pi-mono/packages/ai/src/utils/oauth/types.ts:56 的 OAuthRequestOptions
07-31回放时保留只有签名的 thinking 块anthropic.ts:1167
08-08onProviderErrorsrc/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-18onProviderStreamEventsrc/types.ts:130、provider-error.ts:36;开发用的上下文检查器消费它(packages/local-runtime-v2/src/service/llm-context-inspector/capture-recorder.ts:120)
08-18Codex 登录的授权码交换走宿主 fetchutils/oauth/openai-codex.ts:167;登录回调新增 fetch(utils/oauth/types.ts:52)
08-26Codex SSE 响应头超时 30 秒openai-codex-responses.ts:57
08-27Claude 默认思考模型不发显式 thinkinganthropic.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 存在哪里。

本页目录