@mariozechner/pi-ai

统一的 LLM API,支持自动模型发现、提供商配置、Token 和成本追踪,以及简单的上下文持久化和会话中途切换到其他模型。

注意:本库仅包含支持工具调用(函数调用)的模型,因为这对智能体工作流至关重要。

Quick Start

Step1: 选择供应商和模型,定义Tool,构造模型对话上下文。

import { Type, getModel, stream, complete, Context, Tool, StringEnum } from '@mariozechner/pi-ai';

// 完全类型化,支持提供商和模型的自动补全
const model = getModel('openai', 'gpt-4o-mini');

// 使用 TypeBox schema 定义工具,实现类型安全和验证
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?' }],
  tools
};

Step2-1:流式输出

// 方式 1:流式传输,包含所有事件类型
const s = stream(model, context);

for await (const event of s) {
  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}`);
      break;
  }
}

// 获取流式传输后的最终消息,添加到上下文中
const finalMessage = await s.result();
context.messages.push(finalMessage);

Step2-2:非流式输出

// 方式 2:只获取最终响应
const response = await complete(model, context);

for (const block of response.content) {
  if (block.type === 'text') {
    console.log(block.text);
  } else if (block.type === 'toolCall') {
    console.log(`Tool: ${block.name}(${JSON.stringify(block.arguments)})`);
  }
}

Step3:处理工具调用

// Get the final message after streaming, add it to the context
const finalMessage = await s.result();
context.messages.push(finalMessage);

// 处理工具调用(如果有)
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';

  // 将工具结果添加到上下文(支持文本和图像)
  context.messages.push({
    role: 'toolResult',
    toolCallId: call.id,
    toolName: call.name,
    content: [{ type: 'text', text: result }],
    isError: false,
    timestamp: Date.now()
  });
}

// 如果有工具调用则继续
if (toolCalls.length > 0) {
  const continuation = await 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)}`);

工具

工具使 LLM 能够与外部系统交互。本库使用 TypeBox schema 进行类型安全的工具定义,并使用 AJV 进行自动验证。TypeBox schema 可以作为普通 JSON 进行序列化和反序列化,非常适合分布式系统。

工具定义

import { Type, Tool, StringEnum } from '@mariozechner/pi-ai';

// 使用 TypeBox 定义工具参数
const weatherTool: Tool = {
  name: 'get_weather',
  description: '获取指定地点的当前天气',
  parameters: Type.Object({
    location: Type.String({ description: '城市名称或坐标' }),
    units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
  })
};

// 注意:为了与 Google API 兼容,请使用 StringEnum 辅助函数而不是 Type.Enum
// Type.Enum 生成 Google 不支持的 anyOf/const 模式

const bookMeetingTool: Tool = {
  name: 'book_meeting',
  description: '安排会议',
  parameters: Type.Object({
    title: Type.String({ minLength: 1 }),
    startTime: Type.String({ format: 'date-time' }),
    endTime: Type.String({ format: 'date-time' }),
    attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
  })
};

处理工具调用

工具结果使用内容块,可以包含文本和图像:

import { readFileSync } from 'fs';

const context: Context = {
  messages: [{ role: 'user', content: '伦敦的天气怎么样?' }],
  tools: [weatherTool]
};

const response = await complete(model, context);

// 检查响应中的工具调用
for (const block of response.content) {
  if (block.type === 'toolCall') {
    // 使用参数执行你的工具
    // 参见"验证工具参数"章节进行验证
    const result = await executeWeatherApi(block.arguments);

    // 添加带有文本内容的工具结果
    context.messages.push({
      role: 'toolResult',
      toolCallId: block.id,
      toolName: block.name,
      content: [{ type: 'text', text: JSON.stringify(result) }],
      isError: false,
      timestamp: Date.now()
    });
  }
}

// 工具结果也可以包含图像(对于支持视觉的模型)
const imageBuffer = readFileSync('chart.png');
context.messages.push({
  role: 'toolResult',
  toolCallId: 'tool_xyz',
  toolName: 'generate_chart',
  content: [
    { type: 'text', text: '生成的显示温度趋势的图表' },
    { type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
  ],
  isError: false,
  timestamp: Date.now()
});

工具调用流式传输

在流式传输期间,工具调用参数会在到达时逐步解析。这使得在完整参数可用之前就能进行实时 UI 更新:

const s = stream(model, context);

for await (const event of s) {
  if (event.type === 'toolcall_delta') {
    const toolCall = event.partial.content[event.contentIndex];

    // toolCall.arguments 在流式传输期间包含部分解析的 JSON
    // 这允许进行渐进式 UI 更新
    if (toolCall.type === 'toolCall' && toolCall.arguments) {
      // 要防御性编程:arguments 可能不完整
      // 示例:即使内容未完成也显示正在写入的文件路径
      if (toolCall.name === 'write_file' && toolCall.arguments.path) {
        console.log(`正在写入:${toolCall.arguments.path}`);

        // 内容可能是部分的或缺失的
        if (toolCall.arguments.content) {
          console.log(`内容预览:${toolCall.arguments.content.substring(0, 100)}...`);
        }
      }
    }
  }

  if (event.type === 'toolcall_end') {
    // 此时 toolCall.arguments 是完整的(但尚未验证)
    const toolCall = event.toolCall;
    console.log(`工具完成:${toolCall.name}`, toolCall.arguments);
  }
}

验证工具调用参数

当使用 agentLoop 时,工具参数会在执行前根据你的 TypeBox schema 自动验证。如果验证失败,错误会作为工具结果返回给模型,允许其重试。

当你使用 stream() 或 complete() 实现自己的工具执行循环时,使用 validateToolCall 在将参数传递给工具之前进行验证:

import { stream, validateToolCall, Tool } from '@mariozechner/pi-ai';

const tools: Tool[] = [weatherTool, calculatorTool];
const s = stream(model, { messages, tools });

for await (const event of s) {
  if (event.type === 'toolcall_end') {
    const toolCall = event.toolCall;

    try {
      // 根据 tool 的 schema 验证参数(无效时抛出异常)
      const validatedArgs = validateToolCall(tools, toolCall);
      const result = await executeMyTool(toolCall.name, validatedArgs);
      // ... 将工具结果添加到上下文
    } catch (error) {
      // 验证失败 - 将错误作为工具结果返回,让模型可以重试
      context.messages.push({
        role: 'toolResult',
        toolCallId: toolCall.id,
        toolName: toolCall.name,
        content: [{ type: 'text', text: error.message }],
        isError: true,
        timestamp: Date.now()
      });
    }
  }
}

完整事件参考

所有流式事件:

事件类型 描述 关键属性
start 流开始 partial:初始助手消息结构
text_start 文本块开始 contentIndex:在内容数组中的位置
text_delta 接收到文本片段 delta:新文本,contentIndex:位置
text_end 文本块完成 content:完整文本,contentIndex:位置
thinking_start 思考块开始 contentIndex:在内容数组中的位置
thinking_delta 接收到思考片段 delta:新文本,contentIndex:位置
thinking_end 思考块完成 content:完整思考内容,contentIndex:位置
toolcall_start 工具调用开始 contentIndex:在内容数组中的位置
toolcall_delta 工具参数流式传输 delta:JSON 片段,partial.content[contentIndex].arguments:部分解析的参数
toolcall_end 工具调用完成 toolCall:完整的已验证工具调用,包含 id、name、arguments
done 流完成 reason:停止原因(“stop”、“length”、“toolUse”),message:最终助手消息
error 发生错误 reason:错误类型(“error” 或 “aborted”),error:包含部分内容的 AssistantMessage

图像输入

具有视觉能力的模型可以处理图像。你可以通过 input 属性检查模型是否支持图像。如果你向非视觉模型传递图像,它们会被静默忽略。

import { readFileSync } from 'fs';
import { getModel, complete } from '@mariozechner/pi-ai';

const model = getModel('openai', 'gpt-4o-mini');

// 检查模型是否支持图像
if (model.input.includes('image')) {
  console.log('模型支持视觉');
}

const imageBuffer = readFileSync('image.png');
const base64Image = imageBuffer.toString('base64');

const response = await complete(model, {
  messages: [{
    role: 'user',
    content: [
      { type: 'text', text: '这张图片里有什么?' },
      { type: 'image', data: base64Image, mimeType: 'image/png' }
    ]
  }]
});

// 访问响应
for (const block of response.content) {
  if (block.type === 'text') {
    console.log(block.text);
  }
}

思考/推理

许多模型支持思考/推理能力,可以展示其内部思维过程。你可以通过 reasoning 属性检查模型是否支持推理。如果你向非推理模型传递推理选项,它们会被静默忽略。

统一接口

import { getModel, streamSimple, completeSimple } from '@mariozechner/pi-ai';

// 跨提供商的许多模型都支持思考/推理
const model = getModel('anthropic', 'claude-sonnet-4-20250514');
// 或 getModel('openai', 'gpt-5-mini');
// 或 getModel('google', 'gemini-2.5-flash');
// 或 getModel('xai', 'grok-code-fast-1');
// 或 getModel('groq', 'openai/gpt-oss-20b');
// 或 getModel('cerebras', 'gpt-oss-120b');
// 或 getModel('openrouter', 'z-ai/glm-4.5v');

// 检查模型是否支持推理
if (model.reasoning) {
  console.log('模型支持推理/思考');
}

// 使用简化的推理选项
const response = await completeSimple(model, {
  messages: [{ role: 'user', content: '求解:2x + 5 = 13' }]
}, {
  reasoning: 'medium'  // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'(xhigh 在非 OpenAI 提供商上映射到 high)
});

// 访问思考和文本块
for (const block of response.content) {
  if (block.type === 'thinking') {
    console.log('思考:', block.thinking);
  } else if (block.type === 'text') {
    console.log('响应:', block.text);
  }
}

提供商特定选项

要进行精细控制,使用提供商特定选项:

import { getModel, complete } from '@mariozechner/pi-ai';

// OpenAI 推理(o1、o3、gpt-5)
const openaiModel = getModel('openai', 'gpt-5-mini');
await complete(openaiModel, context, {
  reasoningEffort: 'medium',
  reasoningSummary: 'detailed'  // 仅 OpenAI Responses API
});

// Anthropic 思考(Claude Sonnet 4)
const anthropicModel = getModel('anthropic', 'claude-sonnet-4-20250514');
await complete(anthropicModel, context, {
  thinkingEnabled: true,
  thinkingBudgetTokens: 8192  // 可选的 token 限制
});

// Google Gemini 思考
const googleModel = getModel('google', 'gemini-2.5-flash');
await complete(googleModel, context, {
  thinking: {
    enabled: true,
    budgetTokens: 8192  // -1 表示动态,0 表示禁用
  }
});

流式传输思考内容

流式传输时,思考内容通过特定事件传递:

const s = streamSimple(model, context, { reasoning: 'high' });

for await (const event of s) {
  switch (event.type) {
    case 'thinking_start':
      console.log('[模型开始思考]');
      break;
    case 'thinking_delta':
      process.stdout.write(event.delta);  // 流式传输思考内容
      break;
    case 'thinking_end':
      console.log('\n[思考完成]');
      break;
  }
}

停止原因

每个 AssistantMessage 都包含一个 stopReason 字段,指示生成如何结束:

  • "stop" - 正常完成,模型完成了其响应
  • "length" - 输出达到最大 token 限制
  • "toolUse" - 模型正在调用工具并期望工具结果
  • "error" - 生成过程中发生错误
  • "aborted" - 请求通过中止信号被取消

AssistantMessage 还可能包含 responseId,当底层 API 暴露时,这是提供商特定的上游响应或消息标识符。不要假设它在所有提供商中都存在。

错误处理

当请求以错误结束时(包括中止和工具调用验证错误),流式 API 会发出错误事件:

// 在流式传输中
for await (const event of stream) {
  if (event.type === 'error') {
    // event.reason 是 "error" 或 "aborted"
    // event.error 是包含部分内容的 AssistantMessage
    console.error(`错误(${event.reason}):`, event.error.errorMessage);
    console.log('部分内容:', event.error.content);
  }
}

// 最终消息将包含错误详情
const message = await stream.result();
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
  console.error('请求失败:', message.errorMessage);
  // message.content 包含错误前收到的任何部分内容
  // message.usage 包含部分 token 计数和成本
}

中止请求

import { getModel, stream } from '@mariozechner/pi-ai';

const model = getModel('openai', 'gpt-4o-mini');
const controller = new AbortController();

// 2 秒后中止
setTimeout(() => controller.abort(), 2000);

const s = stream(model, {
  messages: [{ role: 'user', content: '写一个长故事' }]
}, {
  signal: controller.signal
});

for await (const event of s) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  } else if (event.type === 'error') {
    // event.reason 告诉你是 "error" 还是 "aborted"
    console.log(`${event.reason === 'aborted' ? '已中止' : '错误'}:`, event.error.errorMessage);
  }
}

// 获取结果(如果中止可能是部分的)
const response = await s.result();
if (response.stopReason === 'aborted') {
  console.log('请求被中止:', response.errorMessage);
  console.log('收到的部分内容:', response.content);
  console.log('使用的 Token:', response.usage);
}

中止后继续

被中止的消息可以添加到对话上下文中,并在后续请求中继续:

const context = {
  messages: [
    { role: 'user', content: '详细解释量子计算' }
  ]
};

// 第一个请求在 2 秒后被中止
const controller1 = new AbortController();
setTimeout(() => controller1.abort(), 2000);

const partial = await complete(model, context, { signal: controller1.signal });

// 将部分响应添加到上下文
context.messages.push(partial);
context.messages.push({ role: 'user', content: '请继续' });

// 继续对话
const continuation = await complete(model, context);

调试供应商载荷

使用 onPayload 回调检查发送给供应商的请求载荷。这对于调试请求格式问题或供应商验证错误很有用。

const response = await complete(model, context, {
  onPayload: (payload) => {
    console.log('供应商载荷:', JSON.stringify(payload, null, 2));
  }
});

stream、complete、streamSimple 和 completeSimple 都支持此回调。

API、模型和供应商

本库使用 API 实现的注册表。内置 API 包括:

  • anthropic-messages:Anthropic Messages API(streamAnthropic、AnthropicOptions)
  • google-generative-ai:Google Generative AI API(streamGoogle、GoogleOptions)
  • google-gemini-cli:Google Cloud Code Assist API(streamGoogleGeminiCli、GoogleGeminiCliOptions)
  • google-vertex:Google Vertex AI API(streamGoogleVertex、GoogleVertexOptions)
  • mistral-conversations:Mistral Conversations API(streamMistral、MistralOptions)
  • openai-completions:OpenAI Chat Completions API(streamOpenAICompletions、OpenAICompletionsOptions)
  • openai-responses:OpenAI Responses API(streamOpenAIResponses、OpenAIResponsesOptions)
  • openai-codex-responses:OpenAI Codex Responses API(streamOpenAICodexResponses、OpenAICodexResponsesOptions)
  • azure-openai-responses:Azure OpenAI Responses API(streamAzureOpenAIResponses、AzureOpenAIResponsesOptions)
  • bedrock-converse-stream:Amazon Bedrock Converse API(streamBedrock、BedrockOptions)

供应商和模型

供应商通过特定 API 提供模型。例如:

  • Anthropic 模型使用 anthropic-messages API
  • Google 模型使用 google-generative-ai API
  • OpenAI 模型使用 openai-responses API
  • Mistral 模型使用 mistral-conversations API
  • xAI、Cerebras、Groq 等模型使用 openai-completions API(OpenAI 兼容)

查询供应商和模型

import { getProviders, getModels, getModel } from '@mariozechner/pi-ai';

// 获取所有可用的提供商
const providers = getProviders();
console.log(providers); // ['openai', 'anthropic', 'google', 'xai', 'groq', ...]

// 从提供商获取所有模型(完全类型化)
const anthropicModels = getModels('anthropic');
for (const model of anthropicModels) {
  console.log(`${model.id}: ${model.name}`);
  console.log(`  API: ${model.api}`); // 'anthropic-messages'
  console.log(`  上下文:${model.contextWindow} tokens`);
  console.log(`  视觉:${model.input.includes('image')}`);
  console.log(`  推理:${model.reasoning}`);
}

// 获取特定模型(提供商和模型 ID 在 IDE 中都有自动补全)
const model = getModel('openai', 'gpt-4o-mini');
console.log(`通过 ${model.api} API 使用 ${model.name}`);

自定义模型

你可以为本地推理服务器或自定义端点创建自定义模型:

import { Model, stream } from '@mariozechner/pi-ai';

// 示例:带有自定义标头的自定义端点(绕过 Cloudflare 机器人检测)
const proxyModel: Model<'anthropic-messages'> = {
  id: 'claude-sonnet-4',
  name: 'Claude Sonnet 4 (代理)',
  api: 'anthropic-messages',
  provider: 'custom-proxy',
  baseUrl: 'https://proxy.example.com/v1',
  reasoning: true,
  input: ['text', 'image'],
  cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
  contextWindow: 200000,
  maxTokens: 8192,
  headers: {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
    'X-Custom-Auth': 'bearer-token-here'
  }
};

// 使用自定义模型
const response = await stream(proxyModel, context, {
  apiKey: 'xxxx'
});

一些 OpenAI 兼容服务器不理解用于支持推理的模型的 developer 角色。对于这些提供商,将 compat.supportsDeveloperRole 设置为 false,这样系统提示会作为 system 消息发送。如果服务器也不支持 reasoning_effort,也将 compat.supportsReasoningEffort 设置为 false。

这通常适用于 Ollama、vLLM、SGLang 和类似的 OpenAI 兼容服务器。你可以在提供商级别或每个模型设置 compat。

const ollamaReasoningModel: Model<'openai-completions'> = {
  id: 'gpt-oss:20b',
  name: 'GPT-OSS 20B (Ollama)',
  api: 'openai-completions',
  provider: 'ollama',
  baseUrl: 'http://localhost:11434/v1',
  reasoning: true,
  input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 131072,
  maxTokens: 32000,
  compat: {
    supportsDeveloperRole: false,
    supportsReasoningEffort: false,
  }
};

OpenAI 兼容性设置

openai-completions API 由许多提供商实现,但有细微差别。默认情况下,本库根据 baseUrl 为少数已知的 OpenAI 兼容提供商(Cerebras、xAI、Chutes、DeepSeek、zAi、OpenCode 等)自动检测兼容性设置。对于自定义代理或未知端点,你可以通过 compat 字段覆盖这些设置。对于 openai-responses 模型,compat 字段仅支持 Responses 特定的标志。

interface OpenAICompletionsCompat {
  supportsStore?: boolean;           // 提供商是否支持 `store` 字段(默认:true)
  supportsDeveloperRole?: boolean;   // 提供商是否支持 `developer` 角色与 `system`(默认:true)
  supportsReasoningEffort?: boolean; // 提供商是否支持 `reasoning_effort`(默认:true)
  supportsUsageInStreaming?: boolean; // 提供商是否支持 `stream_options: { include_usage: true }`(默认:true)
  supportsStrictMode?: boolean;      // 提供商是否支持工具定义中的 `strict`(默认:true)
  maxTokensField?: 'max_completion_tokens' | 'max_tokens';  // 使用哪个字段名(默认:max_completion_tokens)
  requiresToolResultName?: boolean;  // 工具结果是否需要 `name` 字段(默认:false)
  requiresAssistantAfterToolResult?: boolean; // 工具结果后是否必须有助手消息(默认:false)
  requiresThinkingAsText?: boolean;  // 思考块是否必须转换为文本(默认:false)
  thinkingFormat?: 'openai' | 'zai' | 'qwen'; // 推理参数格式:'openai' 使用 reasoning_effort,'zai' 使用 thinking: { type: "enabled" },'qwen' 使用 enable_thinking: boolean(默认:openai)
  openRouterRouting?: OpenRouterRouting; // OpenRouter 路由偏好(默认:{})
  vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway 路由偏好(默认:{})
}

interface OpenAIResponsesCompat {
  // 保留供将来使用
}

如果未设置 compat,本库会回退到基于 URL 的检测。如果部分设置了 compat,未指定的字段使用检测到的默认值。这对于以下情况很有用:

  • LiteLLM 代理:可能不支持 store 字段
  • 自定义推理服务器:可能使用非标准字段名
  • 自托管端点:可能有不同的功能支持

类型安全

模型按其 API 类型化,这保持了模型元数据的准确性。当你直接调用提供商函数时,会强制执行提供商特定的选项类型。通用的 stream 和 complete 函数接受带有附加提供商字段的 StreamOptions。

import { streamAnthropic, type AnthropicOptions } from '@mariozechner/pi-ai';

// TypeScript 知道这是一个 Anthropic 模型
const claude = getModel('anthropic', 'claude-sonnet-4-20250514');

const options: AnthropicOptions = {
  thinkingEnabled: true,
  thinkingBudgetTokens: 2048
};

await streamAnthropic(claude, context, options);

跨供应商切换

本库支持在同一对话中在不同 LLM 提供商之间无缝切换。这允许你在保持上下文的同时在对话中途切换模型,包括思考块、工具调用和工具结果。

工作原理

当来自一个供应商的消息被发送到不同的供应商时,本库会自动转换它们以保持兼容性:

  • 用户和工具结果消息原样传递
  • 来自相同供应商/API 的消息按原样保留
  • 来自不同供应商的消息将其思考块转换为带有 <thinking> 标签的文本
  • 工具调用和普通文本保持不变

示例:多提供商对话

import { getModel, complete, Context } from '@mariozechner/pi-ai';

// 从 Claude 开始
const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
const context: Context = {
  messages: []
};

context.messages.push({ role: 'user', content: '25 * 18 是多少?' });
const claudeResponse = await complete(claude, context, {
  thinkingEnabled: true
});
context.messages.push(claudeResponse);

// 切换到 GPT-5 - 它会将 Claude 的思考视为带 <thinking> 标签的文本
const gpt5 = getModel('openai', 'gpt-5-mini');
context.messages.push({ role: 'user', content: '那个计算正确吗?' });
const gptResponse = await complete(gpt5, context);
context.messages.push(gptResponse);

// 切换到 Gemini
const gemini = getModel('google', 'gemini-2.5-flash');
context.messages.push({ role: 'user', content: '最初的问题是什么?' });
const geminiResponse = await complete(gemini, context);

供应商兼容性

所有提供商都可以处理来自其他提供商的消息,包括:

  • 文本内容
  • 工具调用和工具结果(包括工具结果中的图像)
  • 思考/推理块(转换为带标签的文本以实现跨提供商兼容性)
  • 带有部分内容的中止消息

这使你能够实现灵活的工作流程:

  • 从快速模型开始进行初始响应
  • 切换到更强大的模型进行复杂推理
  • 使用专门的模型处理特定任务
  • 在提供商故障时保持对话连续性

上下文序列化

Context 对象可以使用标准 JSON 方法轻松序列化和反序列化,使得持久化对话、实现聊天历史或在服务之间传递上下文变得简单:

import { Context, getModel, complete } from '@mariozechner/pi-ai';

// 创建并使用上下文
const context: Context = {
  systemPrompt: '你是一个有帮助的助手。',
  messages: [
    { role: 'user', content: '什么是 TypeScript?' }
  ]
};

const model = getModel('openai', 'gpt-4o-mini');
const response = await complete(model, context);
context.messages.push(response);

// 序列化整个上下文
const serialized = JSON.stringify(context);
console.log('序列化上下文大小:', serialized.length, '字节');

// 保存到数据库、localStorage、文件等
localStorage.setItem('conversation', serialized);

// 稍后:反序列化并继续对话
const restored: Context = JSON.parse(localStorage.getItem('conversation')!);
restored.messages.push({ role: 'user', content: '告诉我更多关于它的类型系统' });

// 用任何模型继续
const newModel = getModel('anthropic', 'claude-3-5-haiku-20241022');
const continuation = await complete(newModel, restored);

Q&A

Q1: 为什么ai层需要定义自己的Message?

核心原因:抽象供应商差异。

各供应商消息格式差异很大:

  • Anthropic: content: ContentBlock[] (text/image/tool_use 混合)
  • OpenAI: content: string | MessageContent[],工具调用在 tool_calls 字段
  • Google: 完全不同的 parts 结构

统一格式让上层(coding-agent)无需关心供应商细节。

Q2:使用 TypeBox 定义工具参数有什么好处?

Schema 即类型,一份定义两处使用。

// types.ts:217-221
export interface Tool<TParameters extends TSchema = TSchema> {
  name: string;
  description: string;
  parameters: TParameters;  // TypeBox schema
}

好处:

  1. 编译时 - Static 从 schema 推断 TypeScript 类型
  2. 运行时 - validation.ts 用 AJV 验证 LLM 返回的参数,自动类型转换(如 “123” → 123)

避免传统方案中同时维护 JSON Schema 和 TypeScript 类型的重复。

Q3:跨供应商切换是如何做到的?

三层机制:

1. Model 对象即配置,Registry 按 api 字段路由

Model<TApi> 是一个纯数据对象,包含了路由所需的全部信息:

interface Model<TApi extends Api> {
  id: string;       // 模型 ID,如 "claude-opus-4-5"
  api: TApi;        // API 协议标识,如 "anthropic-messages"
  provider: string; // 提供商,如 "anthropic"
  baseUrl: string;  // 请求地址
  // ...定价、上下文窗口、能力等
}

api-registry.ts 维护一张 Map<api, StreamFunction> 表,key 是协议类型字符串而不是具体模型。stream.ts 查表分发:

const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options);

同一协议下的所有模型(无论哪个供应商)共用一个 StreamFunction,该函数内部读取 model.baseUrl、model.id 等字段构造实际请求。切换模型只需换一个 Model 对象,routing 自动跟着走。

2. 懒加载注册

register-builtins.ts 在模块加载时自动注册所有内置 provider,但每个 provider 用 createLazyStream() 包装,首次调用时才动态 import() 实际实现模块,避免启动时加载所有 SDK(Bedrock SDK 很重)。

3. 消息转换 - transform-messages.ts 处理兼容性

每次请求前,各 provider 的 convertMessages 会先调用 transformMessages,对历史消息做跨 provider 兼容处理:

  • 同模型:保留 thinking signature、text signature 等 provider 私有字段
  • 跨模型:thinking block 降级为纯文本,删除 vendor-specific 字段(如 Google 的 thoughtSignature)
  • tool call ID 规范化(见 Q4)

4. 统一事件流

所有供应商输出标准化 AssistantMessageEvent,包含 text_delta、thinking_delta、toolcall_delta 等细粒度事件,上层消费方无需感知底层 provider。

切换发生在哪里(以 pi CLI 为例)

用户执行 /model 并选择新模型
    ↓
interactive-mode.ts: showModelSelector()
    ↓
ModelSelectorComponent.handleSelect(model)
    ├─ settingsManager.setDefaultModelAndProvider()   // 持久化默认模型
    └─ onSelectCallback(model)
        ↓
    AgentSession.setModel(model):
        1. 验证 API key(无 key 直接 throw)
        2. agent.setModel(model)                      // Agent 层实际切换
        3. sessionManager.appendModelChange()         // 写 session 日志
        4. settingsManager.setDefaultModelAndProvider() // 再次持久化
        5. setThinkingLevel()                         // 重新 clamp thinking 级别
        6. _emitModelSelect()                         // 通知扩展

Agent.setModel() 本身只是 this._state.model = m,下一次 prompt() 调用时自然使用新模型。历史消息(Context.messages)原封不动传递,transformMessages 负责在发请求前做格式兼容。

Q4:transformMessages 里 tool call ID 和 thinking block 的处理细节

为什么要 normalize tool call ID?

tool call ID 由 assistant 消息里的供应商生成,切换 provider 后历史消息里的 ID 不一定符合新 provider 的格式要求。最典型的冲突:

  • OpenAI Responses API 生成的 ID 格式是 {call_id}|{item_id},item_id 部分 450+ 字符,含 +、/、= 等 base64 字符
  • Anthropic 要求 ID 只能含 [a-zA-Z0-9_-],最长 64 字符
  • OpenAI Completions 最长 40 字符

每个 provider 在调用 transformMessages 时传入自己的 normalizeToolCallId 回调:

Provider 规则
Anthropic replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
OpenAI Completions 取 | 前的 call_id,非法字符换 _,截到 40
OpenAI Responses 同 provider 内保持原结构,跨 provider 来的 item_id 用 fc_${shortHash(itemId)} 重建

transformMessages 内部用 toolCallIdMap 同步改写配对的 toolResult,保证 call/result ID 一致。

redacted 和 thinkingSignature 是什么?

ThinkingContent 有两个可选字段:

interface ThinkingContent {
  type: "thinking";
  thinking: string;
  thinkingSignature?: string;  // provider 私有,含义各异
  redacted?: boolean;          // Anthropic 专有:加密内容
}

redacted(Anthropic 专有):当 Claude 的 extended thinking 被安全过滤时,Anthropic 不返回明文推理,而是返回一个 redacted_thinking block,里面是服务端加密的不透明 payload,存在 thinkingSignature 里,thinking 字段只有占位符 "[Reasoning redacted]"。Anthropic 需要这个 payload 维持多轮对话的 thinking 连续性,原封不动传回即可。其他 provider 拿到这串数据毫无意义,所以跨模型时直接丢弃。

thinkingSignature(各 provider 用途不同):

Provider 存的内容 作用
Anthropic Anthropic 返回的 signature 字符串 下轮请求时验证 thinking 完整性(防篡改)
OpenAI Responses 整个 reasoning item 的 JSON 序列化 下轮请求时把完整的 reasoning item 放回
Google thoughtSignature opaque string 复用 thought context,省 token

跨模型时的完整处理逻辑:

redacted = true?
  同模型 → 原样保留(Anthropic 多轮连续性需要)
  跨模型 → 丢弃(其他 provider 无法解密)

有 thinkingSignature 且同模型?
  → 整块保留,即使 thinking 文本为空
    (OpenAI 加密 reasoning 场景:文本空,但 JSON item 存在 signature 里,下轮需要)

thinking 为空?
  → 丢弃

同模型 → 原样保留

跨模型且有内容 → 降级为纯 text block,signature 丢弃
  { type: "text", text: block.thinking }

跨模型时 thinkingSignature 一律丢弃,因为它是 provider 私有的 opaque 数据,发给其他 provider 会报错或被忽略。