当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)的复杂性收敛到一个简单接口后面。) 因此在使用时需要遵循如下流程:
  1. 初始化模型交互门面Models
  2. 设置供应商
  3. 获取模型实例 之后就可以借助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(...)

这样就同时满足了两个要求:

  1. 同步返回:调用方立刻拿到 stream 对象订阅
  2. 异步执行:实际的网络 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;
};

使用方法#

精简流程#

  1. 初始化承载模型供应商的容器,即创建Provider的集合 – Models
  2. 初始化并添加模型供应商
  3. 获取模型并使用
// 创建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

参考文档#

  1. xx

外链#