Agent-一个门面收敛多家 LLM:PI Agent AI 模块解析
当OpenClaw 火了之后,PI Agent作为其底层的Agent模块,因其简洁性也受到了社区中大量人员的喜爱,创始人对该项目也做了优化并依次出现了pi-ai,pi-agent,code-agent等项目。其中pi-ai是处于最底层用于封装各大llm厂商的交互逻辑并对外提供统一的调用接口,pi-agent是基于pi-ai之上的一个agent实现,而code-agent则是agent在代码编写场景的真实应用。现在我们就来学习一下pi-ai模块。
OpenClaw 走红后,作为其底层 Agent 模块的 PI Agent 也因为简洁性受到了社区大量关注。在持续优化中,PI Agent 内部逐渐形成了三个子项目:最底层的 pi-ai(封装 LLM 交互)、中间的 pi-agent(通用 Agent 实现),以及最上层的 code-agent(代码场景应用)。接下来我们就从最底层的 pi-ai 开始学习。
PI AI的核心抽象#
PI AI 解决了与不同大模型厂商交互时需要使用传入不同的url,甚至参数的封装问题。通过Provider,Models,Model等几个实例将这些复杂性进行封装,然后再对外提供统一的接口。
PI AI的使用#
PI Agent项目有三个模块,其中 @earendil-works/pi-ai 模块处于最底层。该模块封装了各大厂商的LLM 交互逻辑,并对外提供了统一的API接口,其最基本的使用方法如下:
// 从.env中加载配置,如API_KEY,注意不同模型厂商的API_KEY名称不一致
import "dotenv/config"
import { createModels } from "@earendil-works/pi-ai";
import { deepseekProvider } from "@earendil-works/pi-ai/providers/deepseek";
import { Context } from "@earendil-works/pi-ai/";
// 创建Provider容器
const models = createModels()
// 设置使用的Provider
models.setProvider(deepseekProvider())
// 获取目标模型
const model = models.getModel("deepseek","deepseek-v4-flash")
if (!model) throw new Error("model not found")
// 创建上下文
const ctx:Context = {
messages: [],
systemPrompt: "你是一个专业的诗人,你的任务是根据用户输入的诗题,创作一首诗。",
tools: [],
}
// 创建用户消息
const msg1: UserMessage= {
role: "user",
timestamp: new Date().getTime(),
content: "以秋天为主题写一首诗",
}
// 消息进入上下文
ctx.messages.push(msg1)
// 调用模型的completions接口与LLM进行交互,
const response = await models.complete(model,ctx)
// 将返回的消息直接压入上下文中
ctx.messages.push(response)
console.log("==========诗歌 Begin==============")
console.log(response.content)
console.log("==========诗歌 End==============")
// 再次组装用户消息
const usermsg: UserMessage= {
role: "user",
timestamp: new Date().getTime(),
content: "总结一下前面写的诗歌怎么样",
}
// 压入上下文中并使用
ctx.messages.push(usermsg)
const res2= await models.complete(model,ctx)
console.log("==========诗歌总结 Begin==============")
console.log(res2.content)
console.log("==========诗歌总结 End==============")
通过上面的代码,可以看到在其中有几个比较重要的对象Models,Provider,Model和Context
Models: 表示运行时Provider的集合,包含 providers(模型供应商)、认证应用(auth application)以及流式便捷方法(stream convenience)。Providers 拥有各自的流式行为;Models负责解析认证信息,并将每个请求委托给拥有目标模型的 provider。Provider: 用于表示不同的模型厂商,如Deepseek,anthropic,chatgpt等,Model: 则表示的是模型厂商不同的模型。Context: 模型上下文,系统提示词的设置,历史消息的保存都在这个模块中。 而且观察代码也可以发现,需要先为Models设置基础上下文,如Provider,之后再借助Models获取Model,与Model进行交互。所有的操作都要经过Model这个代理。Models就是对外暴露的门面,agent-core、coding-agent 等上层业务代码只需要认识Models这一个入口,而不必对接十几家厂商各自的 SDK、鉴权方式和流式协议差异。(典型的 Facade:把子系统(各厂商 provider)的复杂性收敛到一个简单接口后面。) 因此在使用时需要遵循如下流程:
- 初始化模型交互门面Models
- 设置供应商
- 获取模型实例
之后就可以借助
Models与模型供应商进行交互
Provider 的创建#
Model 提供基础信息,Provider 负责逻辑交互。Provider封装调用各个SDK,然后对外暴露streams,streamSimple等接口,交给统一门面Models去调用。然后Models对外暴露streams,streamSimple等接口。
以DeepSeekProvider为例看一下Provider实例的创建过程
使用createProvider函数创建Provider实例:
id: Provider唯一标识符name: Provider 名称baseUrl: Provider的API接口地址auth: 认证方式models: 所有的模型api: 封装与llm接口交互的函数
import { createProvider, type Provider } from "../models.ts";
export function deepseekProvider(): Provider<"openai-completions"> {
return createProvider({
id: "deepseek",
name: "DeepSeek",
baseUrl: "https://api.deepseek.com",
auth: { apiKey: envApiKeyAuth("DeepSeek API key", ["DEEPSEEK_API_KEY"]) },
models: Object.values(DEEPSEEK_MODELS),
api: openAICompletionsApi(),
});
}
openAICompletionsApi是一个返回类型为 ProviderStreams的懒加载函数,减少启动时间和初始内存占用——只有当真正需要调用 OpenAI completions API 时,才会加载对应的实现代码
export const openAICompletionsApi = (): ProviderStreams => lazyApi(() => import("./openai-completions.ts"));
创建的Provider实例有一个dispatch函数,通过dispatch获取到该模型的API接口函数,然后再调用该接口函数使用流的传递。
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
// Provider支持的模型,外部加载的模型和本地输入模型合并
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
};
const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
// 获取对应Model的API函数
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
const dispatch = (
model: Model<Api>,
run: (streams: ProviderStreams) => AssistantMessageEventStream,
): AssistantMessageEventStream => {
// 获取对应的API函数
const streams = apiFor(model);
if (!streams) {
return lazyStream(model, async () => {
throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
});
}
// 运行该函数
return run(streams);
};
// 创建Provider实例,其中的`stream`和`streamSimple`方法是与模型交互的方法
const provider: Provider<TApi> = {
id: input.id,
name: input.name ?? input.id,
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: currentModels,
refreshModels: fetchModels
? async (context) => {
if (context.stored) {
const restored = context.stored.models
.filter((model) => model.provider === input.id)
.map((model) => model as Model<TApi>);
if (
!(await context.publish({
update: () => {
dynamicModels = restored;
},
}))
) {
return;
}
}
if (!context.allowNetwork || context.signal.aborted) return;
const refreshed = await fetchModels(context);
if (context.signal.aborted) return;
await context.publish({
persist: { models: refreshed, checkedAt: Date.now() },
update: () => {
dynamicModels = refreshed;
},
});
}
: undefined,
filterModels: input.filterModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
};
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
if (streams.some((entry) => entry.fetchDeferred !== undefined)) {
provider.fetchDeferred = (model, handle, options) =>
lazyStream(model, async () => {
const implementation = apiFor(model);
if (!implementation?.fetchDeferred) {
throw new ModelsError(
"provider",
`Provider ${input.id} does not support deferred responses for "${model.api}"`,
);
}
return implementation.fetchDeferred(model, handle, options);
});
}
if (streams.some((entry) => entry.cancelDeferred !== undefined)) {
provider.cancelDeferred = async (model, handle, options) => {
const implementation = apiFor(model);
if (!implementation?.cancelDeferred) {
throw new ModelsError(
"provider",
`Provider ${input.id} cannot cancel deferred responses for "${model.api}"`,
);
}
await implementation.cancelDeferred(model, handle, options);
};
}
return provider;
}
models.streamSimple
provider.streamSimple()
provider.dispatch(model,run)
provider.apiFor()
run.stream()
streams.streamSimple()
api.streamSimple()
/Users/admin/program/javascriptProject/pi/packages/ai/src/api/openai-completions.ts 路径中封装了OpenAI的底层SDK,实现了将openai返回的消息转换成PI AI的消息。
function createClient(
model: Model<"openai-completions">,
context: Context,
apiKey: string,
optionsHeaders?: ProviderHeaders,
fetch?: typeof globalThis.fetch,
sessionId?: string,
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
) {
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({
messages: context.messages,
hasImages,
});
Object.assign(headers, copilotHeaders);
}
if (sessionId && compat.sendSessionAffinityHeaders) {
if (compat.sessionAffinityFormat === "openrouter") {
headers["x-session-id"] = sessionId;
} else {
if (compat.sessionAffinityFormat === "openai") {
headers.session_id = sessionId;
}
headers["x-client-request-id"] = sessionId;
headers["x-session-affinity"] = sessionId;
}
}
// Merge options headers last so they can override defaults
if (optionsHeaders) {
Object.assign(headers, optionsHeaders);
}
return new OpenAI({
apiKey,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: headers,
});
}
对外详细的封装#
以openai-completions接口为例,整个消息封装在下面这个大的函数中。(async () => { ... })() 本质上是「开一个后台异步任务,自己管自己」:
调用方执行 stream()
│
├─ 1. 创建空的 stream 对象
├─ 2. 启动 async IIFE(放到事件循环里,不阻塞)
├─ 3. return stream → 调用方立刻拿到 stream,去订阅事件
│
└─────────────── 稍后(事件循环下一轮)───────────────
IIFE 开始执行:
- 发请求拿到 openaiStream
- for await 逐帧读取 chunk
- stream.push(...) 推送事件
- stream.end() / stream.error(...)
这样就同时满足了两个要求:
- 同步返回:调用方立刻拿到 stream 对象订阅
- 异步执行:实际的网络 IO 和数据推送在后台异步完成,不阻塞返回。
该函数主要是对OpenAI SDK返回的响应进行解析封装,其中除了解析封装之外,还在消息的开始增加了
{ type: "start", partial: output })和stream.push({ type: "done", reason: output.stopReason, message: output });类型
361: 请求发出 → 拿到 openaiStream (HTTP 200 OK,连接已建立)
369: 调用 onResponse 回调 ——— 通知上层「HTTP 响应到了」
370: push start 事件 ——— 通知流消费者「正式开始了」
...
544: for await chunk ——— 开始逐帧推送 text_delta / toolcall_delta / ...
...
699: push done 事件 ——— 通知流结束
export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptions> = (
model: Model<"openai-completions">,
context: Context,
options?: OpenAICompletionsOptions,
): AssistantMessageEventStream => {
// 构建实例
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
// 与llm交互
const { data: openaiStream, response } = await retryProviderRequest(
() => client.chat.completions.create(params, requestOptions).withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
// **`options?.onResponse?.(...)`**:可选的回调钩子。如果调用方在 `options` 里传了 `onResponse` 函数,就执行它;否则什么都不做(`?.` 是可选链,避免 undefined 报错)**作用**:在真正开始推送数据之前,先把 HTTP 层的响应信息通知给上层(比如用于日志、调试、或者根据响应头做特殊处理)。
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
// 返回start类型的消息
stream.push({ type: "start", partial: output });
type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock;
// 遍历响应
for await (const chunk of openaiStream) {
if (!chunk || typeof chunk !== "object") continue;
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
// and each chunk in a streamed completion carries the same id.
output.responseId ||= chunk.id;
if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) {
output.responseModel ||= chunk.model;
}
if (chunk.usage) {
output.usage = parseChunkUsage(chunk.usage, model);
}
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice) continue;
// Fallback: some providers (e.g., Moonshot) return usage
// in choice.usage instead of the standard chunk.usage
if (!chunk.usage && (choice as any).usage) {
output.usage = parseChunkUsage((choice as any).usage, model);
}
if (choice.finish_reason) {
output.rawStopReason = choice.finish_reason;
const finishReasonResult = mapStopReason(choice.finish_reason);
output.stopReason = finishReasonResult.stopReason;
if (finishReasonResult.errorMessage) {
output.errorMessage = finishReasonResult.errorMessage;
}
hasFinishReason = true;
}
if (choice.delta) {
if (
choice.delta.content !== null &&
choice.delta.content !== undefined &&
choice.delta.content.length > 0
) {
const block = ensureTextBlock();
block.text += choice.delta.content;
// 追加响应内容
stream.push({
type: "text_delta",
contentIndex: getContentIndex(block),
delta: choice.delta.content,
partial: output,
});
}
// Some endpoints return reasoning in reasoning_content (llama.cpp),
// or reasoning (other openai compatible endpoints)
// Use the first non-empty reasoning field to avoid duplication
// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
const deltaFields = choice.delta as Record<string, unknown>;
let foundReasoningField: string | null = null;
for (const field of reasoningFields) {
const value = deltaFields[field];
if (typeof value === "string" && value.length > 0) {
foundReasoningField = field;
break;
}
}
// 解析推理字段
if (foundReasoningField) {
const delta = deltaFields[foundReasoningField];
if (typeof delta === "string" && delta.length > 0) {
const thinkingSignature =
model.provider === "opencode-go" && foundReasoningField === "reasoning"
? "reasoning_content"
: foundReasoningField;
const block = ensureThinkingBlock(thinkingSignature);
block.thinking += delta;
stream.push({
type: "thinking_delta",
contentIndex: getContentIndex(block),
delta,
partial: output,
});
}
}
// 解析工具调用
if (choice?.delta?.tool_calls) {
for (const toolCall of choice.delta.tool_calls as StreamingToolCallDelta[]) {
const block = ensureToolCallBlock(toolCall);
if (!block.id && toolCall.id) {
block.id = toolCall.id;
toolCallBlocksById.set(toolCall.id, block);
}
const name = toolCall.function?.name ?? toolCall.custom?.name;
if (!block.name && name) {
block.name = name;
}
let delta = "";
if (toolCall.function?.arguments) {
delta = toolCall.function.arguments;
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
block.arguments = parseStreamingJson(block.partialArgs);
} else if (toolCall.custom?.input) {
const nextInput = getCustomToolCallInput(block) + toolCall.custom.input;
delta = appendCustomToolCallInput(block, nextInput, false) ?? "";
}
stream.push({
type: "toolcall_delta",
contentIndex: getContentIndex(block),
delta,
partial: output,
});
}
}
const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details;
if (Array.isArray(reasoningDetails)) {
for (const detail of reasoningDetails) {
if (!isOpenAIReasoningDetail(detail)) continue;
ensureThinkingBlock("");
streamedReasoningDetails ??= [];
// Keep provider replay data in the existing signature slot. OpenRouter streams
// reasoning_details as deltas: consecutive text/summary deltas are merged into
// logical entries, while encrypted entries remain opaque and discrete.
appendOpenAIReasoningDetail(streamedReasoningDetails, detail);
}
}
}
}
// 错误处理
for (const block of blocks) {
finishBlock(block);
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted") {
throw new Error("Request was aborted");
}
if (!hasFinishReason && !compat.supportsFinishReason) {
output.stopReason = output.content.some((block) => block.type === "toolCall") ? "toolUse" : "stop";
}
if (output.stopReason === "error") {
throw new Error(output.errorMessage || "Provider returned an error stop reason");
}
if ((compat.supportsFinishReason && !hasFinishReason) || output.stopReason === "pending") {
throw new Error("Stream ended without finish_reason");
}
// 结尾
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
……
// 异常处理
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
使用方法#
精简流程#
- 初始化承载模型供应商的容器,即创建
Provider的集合 –Models - 初始化并添加模型供应商
- 获取模型并使用
// 创建Provider容器
import { createModels } from "@earendil-works/pi-ai"
const models = createModels()
// 配置Provider
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
models.setProvider(anthropicProvider())
// 获取具体模型
const model = models.getModel("anthropic","claude-sonnet-4-6")
// 创建context
const context: Context = {
systemPrompt: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'What time is it?', timestamp: Date.now() }]
};
// 使用模型
models.model(model,context)
流式及工具调用#
如果使用API的方式认证,其API Key需要从环境变量中配置,并且不同的模型环境变量的名称不一致。
import "dotenv/config"
import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { createModels } from "@earendil-works/pi-ai";
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
// 获取内置的Providers容器
const models = builtinModels();
const model = models.getModel('deepseek', 'deepseek-v4-flash')!;
const tools: Tool[] = [{
name: 'get_time',
description: 'Get the current time',
parameters: Type.Object({
timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
})
}];
const context: Context = {
systemPrompt: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'What time is it?', timestamp: Date.now() }],
tools
};
async function main() {
console.log('开始调用 LLM...');
try {
model.baseUrl= "http://192.168.50.209:9988/v1"
const stream = models.stream(model, context)
// 开启循环,从Stream中读取数据
for await (const event of stream) {
switch (event.type) {
case 'start':
console.log(`Starting with ${event.partial.model}`);
break;
case 'text_start':
console.log('\n[Text started]');
break;
case 'text_delta':
process.stdout.write(event.delta);
break;
case 'text_end':
console.log('\n[Text ended]');
break;
case 'thinking_start':
console.log('[Model is thinking...]');
break;
case 'thinking_delta':
process.stdout.write(event.delta);
break;
case 'thinking_end':
console.log('[Thinking complete]');
break;
case 'toolcall_start':
console.log(`\n[Tool call started: index ${event.contentIndex}]`);
break;
case 'toolcall_delta':
// Partial tool arguments are being streamed
const partialCall = event.partial.content[event.contentIndex];
if (partialCall.type === 'toolCall') {
console.log(`[Streaming args for ${partialCall.name}]`);
}
break;
case 'toolcall_end':
console.log(`\nTool called: ${event.toolCall.name}`);
console.log(`Arguments: ${JSON.stringify(event.toolCall.arguments)}`);
break;
case 'done':
console.log(`\nFinished: ${event.reason}`);
break;
case 'error':
console.error(`Error: ${event.error.errorMessage}`);
break;
}
}
// 获取到所有的数据
const finalMessage = await stream.result();
// 直接向context中写入所有的message
context.messages.push(finalMessage);
// Handle tool calls if any
const toolCalls = finalMessage.content.filter(b => b.type === 'toolCall');
for (const call of toolCalls) {
const result = call.name === 'get_time'
? new Date().toLocaleString('en-US', {
timeZone: call.arguments.timezone || 'UTC',
dateStyle: 'full',
timeStyle: 'long'
})
: 'Unknown tool';
// Add tool result to context (supports text and images)
context.messages.push({
role: 'toolResult',
toolCallId: call.id,
toolName: call.name,
content: [{ type: 'text', text: result }],
isError: false,
timestamp: Date.now()
});
}
// Continue if there were tool calls
if (toolCalls.length > 0) {
const continuation = await models.complete(model, context);
context.messages.push(continuation);
console.log('After tool execution:', continuation.content);
}
console.log(`Total tokens: ${finalMessage.usage.input} in, ${finalMessage.usage.output} out`);
console.log(`Cost: $${finalMessage.usage.cost.total.toFixed(4)}`);
//const result = await model.invoke({ context });
// console.log('LLM 响应:', result);
} catch (error) {
console.error('调用出错:', error);
}
}
main();
自定义模型或自定义URL#
// 也可以自己创建一个Provider
import { createModels, createProvider, envApiKeyAuth, type Model } from '@earendil-works/pi-ai';
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
const ollamaModel: Model<'openai-completions'> = {
id: 'llama-3.1-8b',
name: 'Llama 3.1 8B (Ollama)',
api: 'openai-completions',
provider: 'ollama',
baseUrl: 'http://localhost:11434/v1',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000
};
const ollama = createProvider({
id: 'ollama',
name: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
// Every provider declares auth; keyless local servers resolve as configured with no key.
auth: { apiKey: { name: 'Ollama', resolve: async () => ({ auth: {} }) } },
models: [ollamaModel],
api: openAICompletionsApi(),
});
const models = createModels();
models.setProvider(ollama);
await models.complete(models.getModel('ollama', 'llama-3.1-8b')!, context);
如果接口一致,可以直接修改模型的baseUrl
// 创建Provider容器
import { createModels } from "@earendil-works/pi-ai"
const models = createModels()
// 配置Provider
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
models.setProvider(anthropicProvider())
// 获取具体模型
const model = models.getModel("anthropic","claude-sonnet-4-6")
model.baseUrl= "http://192.168.50.209:9988/v1"
模型的认证#
每个模型厂商都有自己的认证方式,如API KEY的名称,API KEY的解析方式,OAuth的登录刷新等。
当调用Models.complete()或Models.stream()方法时,会解析模型的Provider,然后将从环境变量解析的API KEY凭证或者传入的请求头等信息拼装到请求选项中,然后发送给LLM厂商。具体流程如下:
flowchart LR
A["complete()"] --> B["stream()"]
B --> C["lazyStream 回调"]
C --> D["requireProvider()"]
D --> E["applyAuth()"]
E --> F["provider.stream()"]
A -.-> G[".result() 等待结果"]
G -.-> C
subgraph Auth["applyAuth 内部"]
direction LR
E1["requireProvider()"] --> E2["getAuth()"]
E2 --> E3["合并 headers"]
E3 --> E4["返回 requestModel + options"]
end
E -.-> E1
E4 -.-> F
class ModelsImpl {
……
// 校验Provider ID
requireProvider(model) {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
}
return provider;
}
async applyAuth(model, options) {
this.requireProvider(model);
// 获取Provider的认证
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
signal: options?.signal,
});
if (!resolution) {
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
}
// 将认证信息组装到请求选项中
const auth = resolution.auth;
// Explicit request options win per-field; the Models-only transform runs last.
const apiKey = options?.apiKey ?? auth.apiKey;
let headers = mergeHeaders(auth.headers, options?.headers);
if (options?.transformHeaders)
headers = await options.transformHeaders(headers ?? {});
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = { ...providerOptions, apiKey, headers, env };
return { requestModel, requestOptions };
}
stream(model, context, options) {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.stream(requestModel, context, requestOptions);
});
}
async complete(model, context, options) {
return this.stream(model, context, options).result();
}
……
}
Oauth认证#
Oauth 认证需要Provider能够支持并且配置了oauth的认证方式,PI AI认证后的凭据默认存储在内存中,不过也可以自定义。
await models.login("openai-codex","oauth",{
prompt: async (p) => {
// p.type: 'text' | 'secret' | 'select' | 'manual_code'
// manual_code prompts race a local callback server; p.signal aborts them when the server wins
console.log(p.message);
// 需要根据p.message中的类型进行选择与返回
return "browser";
},
notify: (event) => {
// event.type: 'info' | 'auth_url' | 'device_code' | 'progress'
// 前面的prompt完成后就进入notify流程实现最终的认证,如打印出认证地址进行认证
if (event.type === 'info') {
console.log(event.message);
for (const link of event.links ?? []) console.log(`${link.label ?? 'More information'}: ${link.url}`);
}
if (event.type === 'auth_url') console.log(`Open: ${event.url}`);
if (event.type === 'device_code') console.log(`Code: ${event.userCode} at ${event.verificationUri}`);
if (event.type === 'progress') console.log(event.message);
},});
Provider配置了oauth认证.
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(),
});
}
兼容#
不同厂商的 OpenAI 兼容层有细节差异,不生效时优先调 compat。
意思是:model.compat 直接控制 HTTP 请求体里长什么样,请求报错或行为异常时,先调这些开关而不是改核心代码。
拿两个最常见的场景举例(代码位置都在 src/api/openai-completions.ts):
export interface Model<TApi extends Api> {
id: string;
name: string;
api: TApi;
provider: ProviderId;
baseUrl: string;
reasoning: boolean;
/**
* Maps pi thinking levels to provider/model-specific values.
* Missing keys use provider defaults. null marks a level as unsupported.
*/
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: ModelCost;
contextWindow: number;
maxTokens: number;
/** Default sampling parameters for this model. See {@link StreamOptions.samplingParams}; per-request keys override these. */
samplingParams?: Record<string, unknown>;
headers?: Record<string, string>;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat
: TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses"
? OpenAIResponsesCompat
: TApi extends "anthropic-messages"
? AnthropicMessagesCompat
: TApi extends "bedrock-converse-stream"
? BedrockCompat
: never;
}
例 1:maxTokensField 控制请求里的字段名#
官方 OpenAI 用 max_completion_tokens,但很多兼容厂商只认 max_tokens。
逻辑(openai-completions.ts:821-826):
if (options?.maxTokens) {
if (compat.maxTokensField === "max_tokens") {
(params as any).max_tokens = options.maxTokens; // 兼容厂商标识
} else {
params.max_completion_tokens = options.maxTokens; // OpenAI 官方
}
}
现象:请求返回 unknown field: max_completion_tokens。
调法:在 **model **上加:
compat: { maxTokensField: "max_tokens" }
下一次请求就换成 max_tokens 字段了。
例 2:supportsDeveloperRole 控制 system prompt 用什么角色发#
官方对 reasoning 模型推荐用 developer 角色传 system prompt,但部分服务器(Ollama、vLLM、小厂代理)不认识这个角色。
逻辑(openai-completions.ts:1205-1208):
if (context.systemPrompt) {
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
const role = useDeveloperRole ? "developer" : "system";
params.push({ role, content: sanitizeSurrogates(context.systemPrompt) });
}
现象:请求报 "developer" is not a valid role。
调法:
compat: { supportsDeveloperRole: false }
下一次就用 system 角色发了。
合并逻辑:自动检测 + 你显式写的 compat 覆盖#
实际生效值在 getCompat() 里合并(openai-completions.ts:1673-1706):
function getCompat(model) {
const detected = detectCompat(model); // ① 根据 baseUrl / provider 自动猜
if (!model.compat) return detected;
return {
// ② 每一项都是:你显式写的 compat 优先,没写就用自动检测的
maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
// ...20 来项都这样
};
}
自动检测 detectCompat() 只是针对一小撮知名 URL(Together、DeepSeek、NVIDIA、Cloudflare 等)写死的白名单(generate-models.ts:631-724)。硅基流动、你自搭的 OneAPI/vLLM 不在白名单里,自动检测会按「完全兼容官方 OpenAI」处理,于是就会踩到 max_completion_tokens、developer role、reasoning_effort 这些字段不被识别的坑——这就是为什么说"不生效时优先调 compat"。
常见故障 → 调哪个 compat#
| 你看到的报错 / 现象 | 调 compat |
|---|---|
max_completion_tokens is unknown |
{ maxTokensField: "max_tokens" } |
invalid role: developer |
{ supportsDeveloperRole: false } |
reasoning_effort is not supported |
{ supportsReasoningEffort: false } |
tool 传 strict: true 报错 |
{ supportsStrictMode: false } |
推理模型的 thinking 格式不对(没收到 / 格式乱) |
{ thinkingFormat: "deepseek" | "together" | "qwen" | "openai" | ... } |
store 字段报错 |
{ supportsStore: false } |
所以排查顺序是:请求失败 → 看返回哪一项不认识 → 在 compat 里把那项对应的开关关掉或换成另一种格式,再试。
模型信息的生成#
./data/deepseek.json 这个文件是自动生成的,不是人工手写的。整个流程由 packages/ai/scripts/generate-models.ts 驱动。
生成流程#
运行 npm run generate-models
↓
① 从多个 API 抓取 + 硬编码收集所有模型数据 (allModels[])
↓
② 按 provider 分组去重 → providers[providerId][modelId]
↓
③ 按 provider + API 双层分组 → generatedDataProviders[providerId][api][modelId]
↓
④ 写入 JSON: src/providers/data/deepseek.json (generate-models.ts:2957-2961)
↓
⑤ 生成 TS: src/providers/deepseek.models.ts (generate-models.ts:2994-3002)
总结命令#
要刷新这个文件运行下面的命令
cd /Users/admin/program/javascriptProject/pi/packages/ai
npm run generate-models
参考文档#
- xx