pi源码阅读「1」
2026-10-08
4827 字
24 分钟

前言#

学习自己用的agent的源码,可以让我更好的使用它,那么就学习一下pi吧,这里直接看代码了,就不参考什么文章或者是在线文档了

Pi的提示词#

首先我们要定位到初始提示词位置/packages/coding-agent/src/core/system-prompt.ts

/** Build the ordered, independently replaceable sections of the structured system prompt. */
export function buildSystemPromptSections(input: BuildSystemPromptOptions): SystemPromptSections {
const options = normalizeBuildSystemPromptOptions(input);
const {
customPrompt,
selectedTools,
hiddenTools,
toolSnippets,
toolGuidelines,
promptGuidelines,
appendSystemPrompt,
sections: customSections,
cwd,
contextFiles,
skills,
} = options;
for (const name of Object.keys(customSections)) {
if (!SYSTEM_PROMPT_SECTION_NAME.test(name) || name === "preamble") {
throw new Error(`Invalid system prompt section name: ${name}`);
}
}
const declaredTools = selectedTools.filter((name) => !hiddenTools.includes(name));
const promptSections: Record<string, string> = {};
if (customPrompt) {
promptSections.preamble = customPrompt;
} else {
promptSections.preamble =
"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
const visibleTools = declaredTools.filter((name) => !!toolSnippets[name]);
const tools =
visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";
promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`;
promptSections.rules = buildRules(declaredTools, toolGuidelines, promptGuidelines);
promptSections.docs = `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: ${getReadmePath()}
- Additional docs: ${getDocsPath()}
- Examples: ${getExamplesPath()} (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
}
if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;
if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);
// A hidden reader is still reachable through another tool, so skills stay but the hint names no tool.
const readers = ["read", "bash"] as const;
const skillFileReadTool =
readers.find((tool) => declaredTools.includes(tool)) ??
(readers.some((tool) => selectedTools.includes(tool)) ? "indirect" : undefined);
if (skillFileReadTool && skills.length > 0) {
const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim();
if (skillsPrompt) promptSections.skills = skillsPrompt;
}
promptSections.cwd = cwd.replace(/\\/g, "/");
for (const [name, content] of Object.entries(customSections)) {
if (content) promptSections[name] = content;
}
const sections: SystemPromptSections = { preamble: promptSections.preamble };
for (const [name, content] of Object.entries(promptSections)) {
if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;
}
return sections;
}

一眼丁真看到

“You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.”

经典设置场景,这里的提示词是动态拼接的,看看工具

const declaredTools = selectedTools.filter((name) => !hiddenTools.includes(name));

这里是直接把隐藏的工具给排除了,初始提示词不会有这些,注意

In addition to the tools above, you may have access to other custom tools depending on the project.

这里说注意场景不同可以调用其他的工具去帮助用户,其实是pi的工具调用机制和hook在发力

promptSections.rules = buildRules(declaredTools, toolGuidelines, promptGuidelines);

把已声明工具、工具使用指南、提示词指南组合成规则段落,这里就包括了一些插件定义的工具,比如ffgrep/fffind等

然后是docs部分,其实这里就是为什么很多人说pi可以自己改pi,因为他把自己的源码位置给agent让他自己知道了(),并不是天生的

然后下面是AGENT.md的内容,注意排位是~/.pi/agent/AGENTS.md > ~/<PWD>/.pi/AGENTS.md,里面写的东西全部都会被加载进去

再下面是你的skills加载内容,举例

<skill>
<name>bun-dev</name>
<description>This skill should be used when working with Bun runtime, bun:sqlite, Bun.serve, bun:test, or when &quot;Bun&quot;, &quot;bun:test&quot;, or Bun-specific patterns are mentioned.</description>
<location>/Users/zsm/.pi/agent/skills/bun-dev/SKILL.md</location>
</skill>

这段描述如果自己写过skill的话应该很熟悉,就是SKILL.md里面写的

---
name: bun-dev
description: This skill should be used when working with Bun runtime, bun:sqlite, Bun.serve, bun:test, or when "Bun", "bun:test", or Bun-specific patterns are mentioned.
metadata:
version: "1.0.0"
---

这样也就是为什么skill需要渐进式批露的原因之一,当然了因为pi里面有插件系统,一些插件的描述也会加进去,在下面就是mcp了

如果你在mcp.json里面配了简要描述,也会写进去

  • mcp__obs (codemode): agentic-obs:本地录屏

这就是一个完整的初始提示词结构了,如果你什么都没有加,提示词应该长这样

You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
<tools>
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call
- write: Create or overwrite files
In addition to the tools above, you may have access to other custom tools depending on the project.
</tools>
<rules>
- Use bash for file operations like ls, rg, find
- Use read to examine files instead of cat or sed.
- You can inspect PI_* environment variables for current model and session details.
- Use edit for precise changes (edits[].oldText must match exactly)
- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls
- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.
- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.
- Use write only for new files or complete rewrites.
- Be concise in your responses
- Show file paths clearly when working with files
</rules>
<docs>
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation:
- Additional docs:
- Examples: /pi/packages/coding-agent/examples (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)
</docs>
<cwd>
</cwd>

那我们输入的提示词会被怎么拼接嘞

system
└── preamble / tools / rules / docs / project_context / skills / cwd / 扩展段
user:帮我看看目录下原生pi的默认提示词在那个文件夹
assistant:思考 + toolCall(read/bash)
toolResult:文件内容
user:对目录下代码进行审计
...

假设skill变了,他的system提示词不是整段重新发送,而是做renderSystemMessageUpdate

/**
* Render a later system message for APIs that accept system messages mid-conversation.
* Section changes are framed by name so the model can relate them to the leading prompt.
* This framing is request-time only and may change between versions.
*/
export function renderSystemMessageUpdate(message: SystemMessage): string {
const parts: string[] = [];
const text = contentText(message.content);
if (text.length > 0) parts.push(text);
for (const [name, value] of Object.entries(message.sections ?? {})) {
parts.push(
value === null
? `Removed system prompt section "${name}".`
: `Updated system prompt section "${name}":\n\n${value}`,
);
}
return parts.join("\n\n");
}

Pi工具#

前面说到了工具的事情,那就看看他的工具是怎么定义的吧,顺着hiddenTools我们找到agent-session.ts文件,里面对隐藏的tools做了定义和处理,而在packages/coding-agent/src/core/extensions/types.ts中做了契约定义,而具体的判断在ackages/coding-agent/src/extensions/codemode/tool.ts

从头来看,在packages/coding-agent/src/core/tools/tool-definition-wrapper.ts中写明了tools的定义

/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
export function wrapToolDefinition<TDetails = unknown>(
definition: ToolDefinition<any, TDetails>,
ctxFactory?: ToolContextFactory,
): AgentTool<any, TDetails> {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
outputSchema: definition.outputSchema,
constrainedSampling: definition.constrainedSampling,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) =>
definition.execute(
toolCallId,
params,
signal,
onUpdate,
ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext),
),
};
}
export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDefinition<any, unknown> {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
outputSchema: tool.outputSchema,
constrainedSampling: tool.constrainedSampling,
prepareArguments: tool.prepareArguments,
executionMode: tool.executionMode,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
};
}

定义了作者、AgentTool以及将什么东西发送过去,用适配器整个包起来,让SDK传入的裸AgentTool也能进同一个注册器

再看agent-session.ts文件中定义的激活和注册在_refreshToolRegistry函数,代码太长就不贴了

  1. 合并三个来源:内置 _baseToolDefinitions、扩展registerTool的、SDK _customTools。
  2. 按 _isAllowedTool(name) 过滤(—tools /—exclude-tools,agent-session.ts:1534;MCP 工具默认不被 allowlist 误杀)。
  3. 建两个平行 map:_toolDefinitions(定义 +sourceInfo,供宿主查询)和 _toolRegistry(包好的AgentTool,供执行)。两者必须同步。
  4. 决定 activeToolNames:默认激活 + defaultActive !==false + allowlist 命中 + _pendingToolNames。

这就是大概的流程,里面的exposure与hiddenDeclarations也不太一样,这个看下面的部分

那么我们要知道的是,tools如何发给模型的呢?pi中是system message的增量存在transcript里,可以在transcript.ts中看到

export function createInitialSystemMessage(
systemPrompt: string | undefined,
tools: Tool[] | undefined,
): SystemMessage | undefined {
const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
const hasTools = tools !== undefined && tools.length > 0;
if (!hasSystemPrompt && !hasTools) return undefined;
return {
role: "system",
content: systemPrompt ?? "",
...(hasTools ? { toolsAdded: tools } : {}),
timestamp: 0,
};
}

初始工具被写入到toolsAdded,再看agent-loop.ts里面写的

function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] {
let systemIndex = -1;
for (let i = pendingMessages.length - 1; i >= 0; i--) {
if (pendingMessages[i].role === "system") {
systemIndex = i;
break;
}
}
const pending = pendingMessages[systemIndex] as SystemMessage | undefined;
const baseline = pending
? pendingMessages.map((message, index) =>
index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message,
)
: pendingMessages;
const changes = getToolStateChanges(
getCurrentTools([...context.messages, ...baseline]),
(context.tools ?? []).map(toToolDeclaration),
);
const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0;
if (pending) {
// Keep the caller's message object when it already declares no tool changes.
if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length) return pendingMessages;
return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message));
}
if (unchanged) return pendingMessages;
const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes);
const insertIndex = pendingMessages.findIndex((message) => message.role !== "system");
const index = insertIndex === -1 ? pendingMessages.length : insertIndex;
return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
}

在每次请求前,把context.tools与transcript已声明的差量写成新的system message的toolsAdded/toolsRemoved

不过好玩的是,anthropic-messages.ts中为了兼容claudecode模型,还做了映射

function convertTools(
tools: Tool[],
isOAuthToken: boolean,
supportsEagerToolInputStreaming: boolean,
supportsStrictTools: boolean,
cacheControl?: CacheControlEphemeral,
): BetaTool[] {
if (!tools) return [];
return tools.map((tool, index) => {
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools, isAnthropicStrictUnsupportedKeyword);
const parameters = getJsonSchemaToolParameters(tool, strict);
const schema = parameters as { properties?: unknown; required?: string[] };
const legacyInputSchema = {
type: "object" as const,
properties: schema.properties ?? {},
required: schema.required ?? [],
};
const inputSchema =
strict === true
? {
...(parameters as Record<string, unknown>),
...legacyInputSchema,
}
: legacyInputSchema;
return {
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
description: tool.description,
...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
...(strict === true ? { strict: true } : {}),
input_schema: inputSchema,
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
};
});
}

会话中途如果加减工具也是当作增量去写入,和上面的提示词一样的处理。工具调用怎么被解析出来呢

以A/为例

  1. content_block_start 且 type === “tool_use” → 建toolCall block,arguments先给{},另开partialJson: "" 暂存。
  2. content_block_delta 且 type === “input_json_delta” → partialJson += delta,立刻 parseStreamingJson()做增量容错解析,发 toolcall_delta。
  3. content_block_stop → 再解析一次定稿,删掉partialJson 临时字段,发 toolcall_end。

那么核心调用肯定是loop里面的过程了,先看看保护机制,假设你工具调用的时候token用的要压缩了,会怎么样呢

if (toolCalls.length > 0) {
// A "length" stop means the output was cut off by the token limit, so
// every tool call in the message may carry truncated arguments. Fail
// them all instead of executing potentially borked calls.
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
}

这里会直接停掉,然后错误结果让模型重发。因为流式解析会让半截json也”验证通过”,执行它等于执行错的参数。

看看调用一次工具的流程prepareToolCall函数

const tool = tools.find((t) => t.name === toolCall.name);

首先在找这个tools在不在

function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {
if (!tool.prepareArguments) {
return toolCall;
}
const preparedArguments = tool.prepareArguments(toolCall.arguments);
if (preparedArguments === toolCall.arguments) {
return toolCall;
}
return {
...toolCall,
arguments: preparedArguments as Record<string, any>,
};
}

对模型的不规范输出做处理,再看看validateToolArguments

export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
const args = structuredClone(toolCall.arguments);
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);
if (coerced !== args) {
if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
throw new Error(errorMessage);
}

做了转换处理,再看看executePreparedToolCall

async function executePreparedToolCall(
prepared: PreparedToolCall,
signal: AbortSignal | undefined,
onUpdate: ToolUpdateSink,
): Promise<ExecutedToolCallOutcome> {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
const startedAt = performance.now();
const elapsed = () => Math.round(performance.now() - startedAt);
try {
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) return;
updateEvents.push(Promise.resolve(onUpdate(partialResult)));
},
);
const durationMs = elapsed();
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: result.isError === true, durationMs };
} catch (error) {
const durationMs = elapsed();
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
durationMs,
};
} finally {
acceptingUpdates = false;
}
}

跑出的东西是边跑边推的,为了保证返回前关闭就加了个acceptingUpdates,避免工具返回后回调还在飞

function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}

这里规定了全部结束才停,因为一些工具调用的时候会并发,比如你去curl一个网站的时候

再看codemode,这个是比较重量级的,你知道的,他是在调用其他的工具去执行的,不过这个走的是nested-tool-calls.ts中的NestedToolCallRunner这个链路有点小复杂

链路:ToolDefinition.execute(…, ctx) → ctx.executeTool()(runner.ts 用Object.defineProperties 挂上去,保证 getter 惰性、能做 stale-instance 校验)→AgentSession._executeNestedToolCall→ 复用 runToolCall()。

tool_search / codemode → 改变"能看到什么"
exposure + hiddenDeclarations → 决定"声明什么"
transcript system messages → 记录"声明过什么"(可回放)
agent-loop executeToolCalls → 决定"怎么跑"
runToolCall → 嵌套调用复用同一条管道
before/afterToolCall hooks → 拦截点(权限、结果改写)

exposure#

我们要知道pi的几种工具情况

exposuredeclaredcallable注册即激活语义
directactive 时active 时是普通工具,模型和脚本都能用
model-onlyactive 时永不是只给模型用的编排/交互工具
codemode仅显式激活时始终(只要注册了)否脚本工具,codemode 描述里会列出
deferred仅显式激活时始终否同上,但 codemode 描述不列,靠 tool_search 发现
hidden永不永不否注册但不可达
  • declared? — 是否出现在发给模型的工具声明里
  • callable? — 是否能被 ctx.executeTool() / codemode 脚本调用

这个设计就很巧妙,比如我有一个mcp服务,mcp下面本质是tools调用,如果我把一个tools关闭了,如果我把他永久删除在配置里面,但是上下文里面出现过,那么就会自相矛盾,llm去recall的时候可能会炸开,但是我设置横hidden就没有这种问题了

而前面说的hiddenDeclarations就不能这样,exposure 挂在定义上、激活前就确定了,而后者是在每次激活时由激活的那批工具的钩子算出来的,就像一些初始化处理,我不想每次都有那么多的tools或者是subagent定义去占上下文,可以写一个钩子脚本把这些工具都包进去,让agent只看这个钩子(但是好像会影响llm的思考速度,还在优化)

Pi的模型兼容#

首先看这个可以看看packages/ai/README.md,官方写的接入供应商文档,还是很权威的

这个时候就会想,为什么我们需要对不同的厂家模型做适配呢?

我们的一个普通信息在pi中是这样的,前面提示词应该有说到

{ role: “user”, content: “帮我读一下 main.ts”, timestamp: 1748697600000 }

但是不同的模型长的样子不一样

claude:{ role: “user”, content: [{ type: “text”, text: “帮我读一下 main.ts” }] } gpt:{ role: “user”, content: “帮我读一下 main.ts” } google:{ role: “user”, parts: [{ text: “帮我读一下 main.ts” }] } aws:{ role: “user”, content: [{ text: “帮我读一下 main.ts” }] } …

那么也就是消息格式、流式传输、思考模式和缓存处理都不一样,那么处理起来还是比较麻烦的,不过好事是大部分模型厂家都选择了claude和openai的协议()

看看providers/anthropic.ts其实加入一个适配并不麻烦

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(),
});
}

我感觉核心的点主要是compat 是数据,不是分支,比如openai-completions有gpt、ds、xai等等都在用,是怎么做选择的呢

function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat {
const provider = model.provider;
const baseUrl = model.baseUrl;
const isZai =
provider === "zai" ||
provider === "zai-coding-cn" ||
baseUrl.includes("api.z.ai") ||
baseUrl.includes("open.bigmodel.cn");
const isTogether =
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com");

这里是直接通过url去判断,然后发送过去逐字节处理

function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat {
const detected = detectCompat(model);
if (!model.compat) return detected;
return {
supportsStore: model.compat.supportsStore ?? detected.supportsStore,
supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming,
supportsFinishReason: model.compat.supportsFinishReason ?? detected.supportsFinishReason,

model.compat 部分设置时,未设置的字段回落到探测值,那么我们实际找的也就是一家的不同点了

那么thinking如何解决呢,这里方法其实我感觉是笨方法吧,就是每个都写一种情况,比如

if (compat.thinkingFormat === "zai" && model.reasoning) {
const zaiParams = params as Omit<typeof params, "reasoning_effort"> & {
thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean };
reasoning_effort?: string;
};
zaiParams.thinking = options?.reasoningEffort ? { type: "enabled", clear_thinking: false } : { type: "disabled" };
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort];
const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort;
if (typeof effort === "string") {
zaiParams.reasoning_effort = effort;
}
}
}

那么就看看加载部分,在api/<id>.ts定义了四十多个厂家,全部加载进去包爆炸的,看看anthropic-messages.lazy.ts

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

懒加载是个好东西,但是mcp为什么没有懒加载,nmd

但这里有个类型层面的困难()必须同步返回AssistantMessageEventStream(agent-loop 靠它),而 auth 解析 + 动态 import 都是异步的

lazy.ts里面写了

export function lazyStream(
model: Model<Api>,
setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
): AssistantMessageEventStream {
const startedAt = Date.now();
const outer = new AssistantMessageEventStream();
setup()
.then((inner) => forwardStream(outer, inner))
.catch((error) => {
const message = createSetupErrorMessage(model, error, startedAt);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
}

立刻返回一个流,内部转发生迟。setup失败也不是throw,而是转成协议内的error事件——所以调用方永远不需要try/catch.这样在一定层面解决了异步的问题

pi还有一个更大的特点,比如我用gpt工作感觉他笨蛋了想换个模型,可以直接/model切换成其他的,但是在供应商不同、思考参数不同的情况下,是如何解决的呢,在readme的Cross-Provider Handoffs中已有描述,如果协议一样,直接继续发送,如果不用,就把thinking的东西降级成<thinking>...</thinking> 标发送过去,user的输入和工具的结果都直接传,如果后来的模型不能看图片呢?就换成image omitted: model does not support images占位符就行了

而模型数据是从models.dev等权威机构获取的,并不是直接写入进去,太麻烦了

比较好的写法还有

export type Api = KnownApi | (string & {});
export type ImageApi = KnownImageApi | (string & {});
export type ClassifierApi = KnownClassifierApi | (string & {});

(string & {}) 是 TS 的开放联合惯用法:保留 KnownApi 的字面量自动补全,同时允许任意自定义字符串。加一个新 API 不需要改 types.ts——只要写一个导出stream/streamSimple 的模块,传给 createProvider({ api }) 即可。

自定义Provider加载#

其实过程都是差不多的,中转站的模型配置写在~/.pi/agent/models.json中,加载方法在model-config.ts文件中,加载后装配在provider-composer.ts文件中

比如我在中转站用cc,就会因为协议被路由到anthropic-messages.ts做处理,而gpt就会到openai-responses.ts,而你的参数、模型等就是写什么是什么,pi不会去主动获取验证

并且一些模型厂家,比如minimax的3.1flash在官网的model中获取不到,这种情况还得自己写进去,绷不住了()

其实看到这里,也就知道该如何写一个针对pi的ccswitch了吧,如果知道了pi的怎么写,其实其他的也都知道了()

最后#

还是得多看看啊,可以给pi调优一点,要不然一些东西还是太难受了

pi源码阅读「1」
https://www.zhuangsanmeng.xyz/posts/piym1/
作者
zsm
发布于
2026-10-08
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

pi代码审计