多提供商 LLM 统一接口库:自动认证、成本追踪与模型切换

badlogic 发布于 2026-07-28 阅读 27

本文介绍了 @earendil-works/pi-ai 库,这是一个统一的 LLM API 接口,支持 OpenAI、Anthropic、Google、DeepSeek 等多种主流提供商。它提供自动认证解析、Token和成本跟踪、流式与非流式 API 调用、工具调用(包括流式部分 JSON 解析)、推理/思考支持、图像输入与生成、跨提供商无缝切换(保留上下文)、上下文序列化以及自定义提供商创建等功能。该库旨在简化与多个 LLM 提供商交互的复杂性,为构建 AI 应用提供一致的开发体验。

统一的 LLM API,支持提供商集合、自动认证解析、Token 和成本追踪,以及简单的上下文持久化和在会话中切换到其他模型。

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

目录

支持的提供商

  • OpenAI
  • Ant Ling
  • Azure OpenAI (Responses)
  • OpenAI Codex (ChatGPT Plus/Pro 订阅,需要 OAuth,见下文)
  • DeepSeek
  • NVIDIA NIM
  • Anthropic
  • Google
  • Vertex AI (通过 Vertex AI 的 Gemini)
  • Mistral
  • Groq
  • Cerebras
  • Cloudflare AI Gateway
  • Cloudflare Workers AI
  • xAI
  • OpenRouter
  • Vercel AI Gateway
  • ZAI Coding Plan (Global) (附带独立中国提供商)
  • MiniMax (附带独立中国提供商)
  • Together AI
  • Hugging Face
  • Moonshot AI (附带独立中国提供商)
  • GitHub Copilot (需要 OAuth,见下文)
  • Amazon Bedrock
  • OpenCode Zen
  • OpenCode Go
  • Fireworks (使用 OpenAI 和 Anthropic 兼容 API)
  • Kimi For Coding (Moonshot AI 订阅端点,使用 Anthropic 兼容 API)
  • Xiaomi MiMo (默认 API 计费端点,附带 cn/ams/sgp 区域的独立 Token 计划提供商)
  • 任何 OpenAI 兼容 API:Ollama、vLLM、LM Studio 等。

安装

npm install @earendil-works/pi-ai

TypeBox 导出在 @earendil-works/pi-ai 中重新导出:TypeStaticTSchema

快速开始

你构建一个包含多个提供商的 Models 集合,并对其进行流式处理。最快的启动方式是注册所有内置提供商;关心包大小的应用应单独注册提供商(参见提供商工厂打包与 tree shaking)。

import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';

// 一个注册了所有内置提供商的 Models 集合
const models = builtinModels();

// 针对集合进行同步查询
const model = models.getModel('openai', 'gpt-4o-mini')!;

// 使用 TypeBox 模式定义工具,保证类型安全和验证
const tools: Tool[] = [{
  name: 'get_time',
  description: '获取当前时间',
  parameters: Type.Object({
    timezone: Type.Optional(Type.String({ description: '可选时区(例如 America/New_York)' }))
  })
}];

// 构建对话上下文(易于序列化并在模型间传输)
const context: Context = {
  systemPrompt: '你是一个有用的助手。',
  messages: [{ role: 'user', content: '现在几点了?', timestamp: Date.now() }],
  tools
};

// 选项 1:包含所有事件类型的流式处理。
// 认证通过提供商解析(此处从环境获取 OPENAI_API_KEY)。
const s = models.stream(model, context);

for await (const event of s) {
  switch (event.type) {
    case 'start':
      console.log(`使用 ${event.partial.model} 开始`);
      break;
    case 'text_start':
      console.log('\n[文本开始]');
      break;
    case 'text_delta':
      process.stdout.write(event.delta);
      break;
    case 'text_end':
      console.log('\n[文本结束]');
      break;
    case 'thinking_start':
      console.log('[模型正在思考...]');
      break;
    case 'thinking_delta':
      process.stdout.write(event.delta);
      break;
    case 'thinking_end':
      console.log('[思考完成]');
      break;
    case 'toolcall_start':
      console.log(`\n[工具调用开始: 索引 ${event.contentIndex}]`);
      break;
    case 'toolcall_delta':
      // 正在流式传输部分工具参数
      const partialCall = event.partial.content[event.contentIndex];
      if (partialCall.type === 'toolCall') {
        console.log(`[正在流式传输 ${partialCall.name} 的参数]`);
      }
      break;
    case 'toolcall_end':
      console.log(`\n工具已调用: ${event.toolCall.name}`);
      console.log(`参数: ${JSON.stringify(event.toolCall.arguments)}`);
      break;
    case 'done':
      console.log(`\n完成: ${event.reason}`);
      break;
    case 'error':
      console.error(`错误: ${event.error.errorMessage}`);
      break;
  }
}

// 流式处理后获取最终消息,添加到上下文中
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'
      })
    : '未知工具';

  // 将工具结果添加到上下文(支持文本和图像)
  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 models.complete(model, context);
  context.messages.push(continuation);
  console.log('工具执行后:', continuation.content);
}

console.log(`总Token数: 输入 ${finalMessage.usage.input}, 输出 ${finalMessage.usage.output}`);
console.log(`成本: $${finalMessage.usage.cost.total.toFixed(4)}`);

// 选项 2:不流式处理直接获取完整响应
const response = await models.complete(model, context);

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

本文档其余部分的代码片段假设有一个像这样的 models 集合(并注册了相关提供商)。

提供商与模型

提供商是运行时单元:它拥有自己的模型目录、身份认证(API 密钥解析、OAuth 流程)和流式行为。一个 Models 集合持有提供商,并将每个请求路由到拥有该模型的提供商。

提供商内部共享 API 实现(网络协议):Anthropic 模型使用 anthropic-messages,OpenAI 使用 openai-responses,而 xAI、Groq、Cerebras、OpenRouter 以及大多数其他提供商共享 openai-completions。混合 API 提供商(如 GitHub Copilot、OpenCode Zen)按模型分发请求。

提供商工厂

对于只需要特定提供商的应用程序,每个内置提供商都有一个工厂函数,每个都是子路径导入,只拉取该提供商的目录:

import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
import { amazonBedrockProvider } from '@earendil-works/pi-ai/providers/amazon-bedrock';
// ...支持提供商列表中的每个提供商一个模块

const models = createModels();
models.setProvider(anthropicProvider());
models.setProvider(openrouterProvider());

提供商工厂导入其模型目录和一个懒加载的 API 包装器。它们不导入其他提供商。通过打包器的代码分割,SDK 实现(@anthropic-ai/sdkopenai@google/genai 等)保留在懒加载块中,在首次请求该 API 的模型时加载。

所有内置提供商

对于需要所有功能的应用程序(如快速开始中所示):

import { builtinModels } from '@earendil-works/pi-ai/providers/all';

const models = builtinModels(); // 一个注册了所有内置提供商的 Models 集合

这会导入所有目录和每个内置提供商的工厂。这是一个重量级的显式入口点。builtinModels() 接受与 createModels() 相同的选项(credentialsauthContext);builtinProviders() 返回提供商数组,如果你想要在自建集合上注册它们。

查询模型

读取是同步的,并返回最后已知的列表:

const providers = models.getProviders();           // 已注册的 Provider 对象
const provider = models.getProvider('anthropic');  // 单个提供商

const all = models.getModels();                    // 跨所有提供商的每个模型
const anthropicModels = models.getModels('anthropic');
const model = models.getModel('anthropic', 'claude-sonnet-4-5');

for (const m of anthropicModels) {
  console.log(`${m.id}: ${m.name}`);
  console.log(`  API: ${m.api}`);
  console.log(`  上下文窗口: ${m.contextWindow} Token`);
  console.log(`  视觉: ${m.input.includes('image')}`);
  console.log(`  推理: ${m.reasoning}`);
}

动态列出的模型类型为 Model<Api>。当需要 API 特定的选项类型时,使用 hasApi() 守卫进行窄化:

import { hasApi } from '@earendil-works/pi-ai';

const m = models.getModel('anthropic', 'claude-sonnet-4-5');
if (m && hasApi(m, 'anthropic-messages')) {
  // m: Model<'anthropic-messages'> — 流选项完整类型化
  models.stream(m, context, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
}

静态目录读取

对于需要完整字面量类型(提供商和模型 ID 自动补全)的生成内置目录的工具,独立于任何集合:

import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all';

const model = getBuiltinModel('openai', 'gpt-4o-mini'); // 类型化 Model<'openai-responses'>
const providers = getBuiltinProviders();
const anthropic = getBuiltinModels('anthropic');

动态提供商

提供商可能具有动态的模型列表(例如 llama.cpp 服务器、实时 OpenRouter 列表)。读取保持同步;而获取则是一个显式的异步操作:

// getModels() 返回最后已知的列表(首次刷新前为空)
await models.refresh('llamacpp');        // 获取一个提供商的列表;失败时拒绝
await models.refresh();                  // 并发刷新所有提供商,尽力而为
const fresh = models.getModel('llamacpp', 'qwen3-30b');

静态内置提供商对 refresh() 无操作。参见 createProvider() 了解如何构建动态提供商。

身份认证

每个提供商拥有自己的认证:API 密钥如何解析(存储的凭据、环境变量、环境来源如 AWS 配置文件或 gcloud ADC)以及支持的 OAuth 登录/刷新流程。

认证解析方式

当你调用 models.stream() 时,集合通过拥有该模型的提供商解析认证并将其合并到请求中。显式的每请求值始终胜出:

// 通过提供商解析(环境变量、存储的凭据、OAuth Token):
await models.complete(model, context);

// 显式密钥覆盖提供商解析出的任何内容:
await models.complete(model, context, { apiKey: 'sk-explicit' });

你无需发出请求即可检查解析结果。传递提供商 ID 以获取提供商范围的认证,或传递模型以包含其静态的 model.headers

const providerAuth = await models.getAuth(model.provider);
const modelAuth = await models.getAuth(model);

if (modelAuth) {
  console.log(`通过 ${modelAuth.source} 配置`); // 例如 "ANTHROPIC_API_KEY"、 "OAuth"、 "stored credential"
  console.log(modelAuth.auth.headers);              // 提供商认证头 + model.headers
} else {
  console.log('未配置');
}

两个重载都会解析凭据,必要时刷新过期的 OAuth,并可能返回派生自认证的 apiKeyheadersbaseUrlgetAuth() 对未配置的提供商解析 undefined,并在真正出问题时("oauth":Token刷新失败,凭据保留用于重新登录;"auth":密钥解析或凭据存储失败)拒绝并返回 ModelsError。请求路径将相同的失败作为流错误呈现。

转换请求头

Models.stream()complete()streamSimple()completeSimple() 接受一个仅适用于 Models 的 transformHeaders 选项。它在提供商认证、model.headers 和显式 options.headers 合并后运行一次,但在提供商分发之前:

const response = await models.completeSimple(model, context, {
  headers: { "X-Client": "my-app" },
  transformHeaders: async (headers) => ({
    ...headers,
    "X-Request-ID": crypto.randomUUID(),
  }),
});

顺序如下:

提供商认证头 -> model.headers -> 显式 options.headers -> transformHeaders -> Provider.stream*()

头名称不区分大小写合并。显式头覆盖认证/模型头,而 transform 有最终控制权;为头返回 null 会抑制支持删除的较低级别默认值。

transformHeaders 属于 Models,而不是 ProviderModels 实现必须消费它并在调用 Provider.stream*() 之前移除它。Provider 实现继续接收普通的 ApiStreamOptionsSimpleStreamOptions,并且从不自行处理转换。使用此选项代替在 stream*() 之前调用 getAuth(model),后者会解析请求认证两次。

凭据存储

存储的凭据(交互式输入的 API 密钥、OAuth Token)保存在 CredentialStore 中 — 每个提供商一个类型标记的凭据。pi-ai 附带一个内存默认值;应用程序注入持久化存储:

import { createModels, type CredentialStore } from '@earendil-works/pi-ai';

const models = createModels({ credentials: myFileBackedStore });
// builtinModels() 接受相同的选项:
// const models = builtinModels({ credentials: myFileBackedStore });

契约很小:read(providerId)list() 用于非秘密的 { providerId, type } 元数据、modify(providerId, fn)(唯一的写入路径 — 序列化的读-修改-写)和 delete(providerId)。枚举不得解析秘密或执行配置的密钥命令。OAuth Token刷新在 modify 内部运行,因此并发请求和进程不能双重重放旋转的Token。已存储的凭据 拥有 其提供商:仅当未存储任何内容时才会查询环境变量,并且失败的刷新永远不会静默回退到环境密钥。

API 密钥凭据使用与 pi 的 auth.json 相同的鉴别器,并且可以携带提供商范围的环境/配置值:

const credential = {
  type: 'api_key',
  key: '...',
  env: {
    CLOUDFLARE_ACCOUNT_ID: 'account-id',
    CLOUDFLARE_GATEWAY_ID: 'gateway-id'
  }
} as const;

环境变量

内置提供商解析这些环境变量(Node.js;在浏览器中显式传递 apiKey):

提供商 环境变量
OpenAI OPENAI_API_KEY
Ant Ling ANT_LING_API_KEY
Azure OpenAI AZURE_OPENAI_API_KEY + AZURE_OPENAI_BASE_URL(例如 https://{resource}.ai.azure.com)或 AZURE_OPENAI_RESOURCE_NAME。支持 *.openai.azure.com*.cognitiveservices.azure.com*.ai.azure.com;根端点自动归一化为 /openai/v1。可选:AZURE_OPENAI_API_VERSION(默认 v1),AZURE_OPENAI_DEPLOYMENT_NAME_MAP
Anthropic ANTHROPIC_API_KEYANTHROPIC_OAUTH_TOKEN
DeepSeek DEEPSEEK_API_KEY
NVIDIA NIM NVIDIA_API_KEY
Google GEMINI_API_KEY
Vertex AI GOOGLE_CLOUD_API_KEYGOOGLE_CLOUD_PROJECT(或 GCLOUD_PROJECT)+ GOOGLE_CLOUD_LOCATION + ADC
Mistral MISTRAL_API_KEY
Groq GROQ_API_KEY
Cerebras CEREBRAS_API_KEY
Cloudflare AI Gateway CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_GATEWAY_ID
Cloudflare Workers AI CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID
xAI XAI_API_KEY
Fireworks FIREWORKS_API_KEY
Together AI TOGETHER_API_KEY
OpenRouter OPENROUTER_API_KEY
Vercel AI Gateway AI_GATEWAY_API_KEY
ZAI Coding Plan (Global) ZAI_API_KEY
ZAI Coding Plan (China) ZAI_CODING_CN_API_KEY
MiniMax (Global) MINIMAX_API_KEY
MiniMax (China) MINIMAX_CN_API_KEY
Moonshot AI / Moonshot AI (China) MOONSHOT_API_KEY
Hugging Face HF_TOKEN
OpenCode Zen / OpenCode Go OPENCODE_API_KEY
Kimi For Coding KIMI_API_KEY
Qwen Token Plan QWEN_TOKEN_PLAN_API_KEY
Qwen Token Plan (China) QWEN_TOKEN_PLAN_CN_API_KEY
Xiaomi MiMo (API billing) XIAOMI_API_KEY
Xiaomi MiMo Token Plan (China) XIAOMI_TOKEN_PLAN_CN_API_KEY
Xiaomi MiMo Token Plan (Amsterdam) XIAOMI_TOKEN_PLAN_AMS_API_KEY
Xiaomi MiMo Token Plan (Singapore) XIAOMI_TOKEN_PLAN_SGP_API_KEY
GitHub Copilot COPILOT_GITHUB_TOKEN

Amazon Bedrock 解析环境 AWS 凭据(AWS_PROFILE、访问密钥对、AWS_BEARER_TOKEN_BEDROCK、ECS 任务角色、Web 身份Token);其提供商拥有的登录流程支持 bearer Token、AWS 配置文件和现有的凭据链。Vertex AI 解析显式密钥或 gcloud Application Default Credentials 加项目和位置,其提供商拥有的登录流程支持 API 密钥、ADC 和服务帐户文件。

工具

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

定义工具

import { Type, type Tool, StringEnum } from '@earendil-works/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 })
  })
};

工具的约束采样

工具可以选择提供商端的约束采样。对于 JSON schema 工具,strict: 'prefer' 在支持时使用提供商端严格 schema 强制执行,否则回退到普通工具调用。strict: 'require' 在当前提供商/模型无法满足时使请求失败。设置 constrainedSampling: false 明确退出;其行为与省略该字段相同。

const strictTool: Tool = {
  name: 'edit_file',
  description: '编辑文件',
  parameters: Type.Object({
    path: Type.String(),
    content: Type.String()
  }, { additionalProperties: false }),
  constrainedSampling: { type: 'json_schema', strict: 'prefer' }
};

受支持的严格 JSON schema 约束采样适用于 OpenAI、Anthropic、Amazon Bedrock Converse 支持模型、Mistral 以及通过 Google Generative AI 和 Vertex 适配器的 Gemini 3 工具调用。Google 使用 VALIDATED 函数调用模式(或在明确请求时使用 ANY);较早的 Gemini 版本对 strict: 'prefer' 回退,对 strict: 'require' 拒绝,因为它们不强制执行 required 参数。Bedrock 严格工具能力从模型的结构化输出元数据生成;自定义 Bedrock 模型可以覆盖 compat.supportsStrictMode。OpenAI Responses 和 Chat Completions 还可以使用 OpenAI Lark 或正则表达式语法变体生成受语法约束的自定义工具。如果提供了多个 OpenAI 变体,则 Lark 优先于正则表达式。当活动模型支持语法工具时强制执行语法约束;否则工具回退到普通函数/ JSON schema 处理。语法工具能力是模型元数据:生成的目录为支持 OpenAI 自定义工具的端点上的 GPT-5+ 模型设置 compat.supportsOpenAIGrammarTools(OpenAI、OpenAI Codex、Azure OpenAI Responses、GitHub Copilot、opencode 和 Cloudflare AI Gateway)。对于 GPT-5 之前的模型,OpenAI 拒绝 type: "custom" 工具,而规范化工具体系的网关(例如 OpenRouter)会破坏它们,因此该标志在其他地方保持关闭。自定义模型定义可以通过 compat 选择加入。语法能力模型拒绝没有非空支持变体的语法配置。原生语法工具必须有一个对象参数模式,且恰好有一个必需的字符串属性:

const patchTool: Tool = {
  name: 'apply_patch',
  description: '应用补丁',
  parameters: Type.Object({
    input: Type.String()
  }, { additionalProperties: false }),
  constrainedSampling: {
    type: 'grammar',
    variants: {
      openai_lark: 'start: /.+/s'
    }
  }
};

处理工具调用

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

import { readFileSync } from 'fs';

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

const response = await models.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()
});

流式工具调用与部分 JSON

在流式处理期间,工具调用参数会在到达时逐步解析。这使得在完整参数可用之前实现实时 UI 更新成为可能:

const s = models.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) {
      // 务必防御性编码:参数可能不完整
      // 示例:即使在内容完成之前也显示正在写入的文件路径
      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);
  }
}

关于部分工具参数的重要说明:

  • toolcall_delta 事件期间,arguments 包含部分 JSON 的最佳解析结果
  • 字段可能缺失或不完整 — 在使用前始终检查是否存在
  • 字符串值可能在中途被截断
  • 数组可能不完整
  • 嵌套对象可能部分填充
  • 至少,arguments 将是一个空对象 {},永远不会是 undefined
  • Google 提供商不支持函数调用流式处理。相反,你将收到一个包含完整参数的 toolcall_delta 事件。

验证工具参数

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

import { validateToolCall, type Tool } from '@earendil-works/pi-ai';

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

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

    try {
      // 根据工具的 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: 包含 idnamearguments 的完整已验证工具调用
done 流完成 reason: 停止原因 ("stop"、"length"、 "toolUse"), message: 最终助手消息
error 发生错误 reason: 错误类型 ("error" 或 "aborted"), error: 包含部分内容的 AssistantMessage

不同内容块的流事件不能保证连续。提供商可能在同一上游块中发出文本、思考和工具调用的增量,并且 pi 可能交错发出相应的事件,例如 text_starttext_deltatoolcall_starttext_deltatoolcall_delta。消费方必须使用 contentIndex 将每个 delta/end 事件与其块关联,并且不得假设某个块的 *_start/*_delta/*_end 序列不会被其他块的事件中断。

图像输入

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

import { readFileSync } from 'fs';

const model = models.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 models.complete(model, {
  messages: [{
    role: 'user',
    content: [
      { type: 'text', text: '这张图片里有什么?' },
      { type: 'image', data: base64Image, mimeType: 'image/png' }
    ],
    timestamp: Date.now()
  }]
});

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

图像生成

图像生成使用与文本/聊天生成分离的 API 表面,镜像聊天端的设计:一个 ImagesModels 集合持有 ImagesProvider,读取是同步的,认证通过拥有该模型的提供商解析。图像生成是一次性 API:generateImages() 等待提供商响应并返回最终的 AssistantImages 结果 — 不要使用聊天/流 API 进行图像生成。

基本图像生成

import { builtinImagesModels } from '@earendil-works/pi-ai/providers/all';

// 每个内置图像生成提供商;接受与 createModels() 相同的选项
const imagesModels = builtinImagesModels();

const model = imagesModels.getModel('openrouter', 'google/gemini-2.5-flash-image')!;

// 认证通过提供商解析(此处为 OPENROUTER_API_KEY);显式 apiKey 胜出
const result = await imagesModels.generateImages(model, {
  input: [{ type: 'text', text: '生成一个纯白色背景上的红色圆形。' }]
});

for (const block of result.output) {
  if (block.type === 'text') {
    console.log(block.text);
  } else if (block.type === 'image') {
    console.log(block.mimeType);
    console.log(block.data.substring(0, 32));
  }
}

与聊天端类似,你可以组合构建集合:createImagesModels({ credentials?, authContext? })、来自 @earendil-works/pi-ai/providers/openrouter-imagesopenrouterImagesProvider() 工厂,以及用于自定义图像提供商的 createImagesProvider({ id, auth, models, refreshModels?, api })(使用 imagesModels.refresh(provider?) 进行动态列表)。失败永远不会拒绝 — 它们返回一个 stopReason: "error"AssistantImages。集合的提供商范围 getAuth(providerId) 的工作方式与聊天端完全相同。

旧的全局 API(getImageModel() / getImageModels() / getImageProviders() / generateImages())仍然可通过兼容入口点使用:

import { getImageModel, generateImages } from '@earendil-works/pi-ai/compat';

const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
const result = await generateImages(model, {
  input: [{ type: 'text', text: '生成一个纯白色背景上的红色圆形。' }]
}, {
  apiKey: process.env.OPENROUTER_API_KEY
});

一些模型也支持图像输入:

import { readFileSync } from 'fs';

const imageBuffer = readFileSync('input.png');
const result = await imagesModels.generateImages(model, {
  input: [
    { type: 'text', text: '创建这个图像的变体,背景改为蓝色。' },
    { type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
  ]
});

检查模型元数据中的能力:

console.log(model.input);   // ['text', 'image']
console.log(model.output);  // ['image'] 或 ['image', 'text']

注意事项和限制

  • 图像模型位于 ImagesModels 集合中,聊天模型位于 Models 集合中;两者是分离的表面。
  • 使用 generateImages(),而不是聊天/流 API。
  • 图像生成模型不参与工具调用。
  • 输出在 AssistantImages.output 中返回,可以包括 base64 编码的 ImageContent 块和 TextContent 块。
  • 一些模型只返回图像,其他模型返回图像加文本。检查 model.output
  • 一些模型接受图像输入,其他模型仅是文本到图像。检查 model.input
  • 与流 API 类似,图像生成支持 apiKeysignalheadersonPayloadonResponse 等选项,结果可能包含 stopReasonresponseIdusage
  • 如果你希望模型在对话中分析图像或调用工具,请使用支持图像输入的常规聊天 API。
  • 目前,图像生成仅通过一个提供商 OpenRouter 可用。

思考/推理

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

统一接口 (streamSimple/completeSimple)

// 跨提供商的许多模型支持思考/推理
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
// 或 models.getModel('openai', 'gpt-5-mini');
// 或 models.getModel('google', 'gemini-2.5-flash');
// 或 models.getModel('xai', 'grok-4.5');

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

// 使用简化的推理选项
const response = await models.completeSimple(model, {
  messages: [{ role: 'user', content: '解方程:2x + 5 = 13', timestamp: Date.now() }]
}, {
  reasoning: 'medium'  // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
});

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

xhighmax 是模型特定的、选择加入的级别。使用 getSupportedThinkingLevels(model) 确定具体模型是否公开了任一级别;像 GPT-5.6 这样的模型可以公开两者。

提供商专用选项 (stream/complete)

models.stream()/complete() 接受所属 API 的完整选项集。使用 hasApi() 将动态查找的模型窄化到其 API 以获得完整的选项类型:

import { hasApi } from '@earendil-works/pi-ai';

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

// Anthropic 思考
const anthropicModel = models.getModel('anthropic', 'claude-sonnet-4-5')!;
if (hasApi(anthropicModel, 'anthropic-messages')) {
  await models.complete(anthropicModel, context, {
    thinkingEnabled: true,
    thinkingBudgetTokens: 8192  // 可选Token限制
  });
}

// Google Gemini 思考
const googleModel = models.getModel('google', 'gemini-2.5-flash')!;
if (hasApi(googleModel, 'google-generative-ai')) {
  await models.complete(googleModel, context, {
    thinking: {
      enabled: true,
      budgetTokens: 8192  // -1 为动态,0 为禁用
    }
  });
}

流式思考内容

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

const s = models.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 字段,指示生成结束的原因:

  • "pending" - 仅在部分消息中出现,此时我们不知道停止原因是什么
  • "stop" - 这是模型本轮将产生的最终消息
  • "length" - 输出达到最大Token限制
  • "toolUse" - 模型正在调用工具并期望工具结果
  • "error" - 生成过程中发生错误
  • "aborted" - 通过中止信号取消请求

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

错误处理

请求失败永远不会从流函数中抛出:当请求以错误结束时(包括中止和工具调用验证错误),流 API 会发出一个错误事件,并且最终消息会携带详细信息:

// 在流式处理中
for await (const event of s) {
  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 s.result();
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
  console.error('请求失败:', message.errorMessage);
  // message.content 包含错误之前收到的任何部分内容
  // message.usage 包含部分Token计数和成本
}

身份认证失败(未配置密钥、OAuth 刷新失败、未知提供商)以相同方式呈现:作为 stopReason: "error" 的流错误。

中止请求

中止信号允许你取消正在进行中的请求。中止的请求具有 stopReason === 'aborted'

const controller = new AbortController();

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

const s = models.stream(model, {
  messages: [{ role: 'user', content: '写一个长篇故事', timestamp: Date.now() }]
}, {
  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: '详细解释量子计算', timestamp: Date.now() }
  ]
};

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

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

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

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

调试提供商负载

使用 onPayload 回调来检查发送给提供商的请求负载。这对于调试请求格式问题或提供商验证错误非常有用。

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

该回调由 streamcompletestreamSimplecompleteSimple 支持。

自定义提供商

createProvider()

createProvider() 从各个部分构建一个提供商:身份、身份认证、模型列表和 API 实现。用于本地推理服务器、代理或任何 OpenAI/Anthropic 兼容端点:

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',
  // 每个提供商声明认证;无密钥的本地服务器解析为空认证。
  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);

对于具有真实密钥的提供商,envApiKeyAuth(displayName, envVars) 提供标准行为(存储的凭据优先,其次是第一个设置的环境变量):

const proxy = createProvider({
  id: 'my-proxy',
  auth: { apiKey: envApiKeyAuth('我的代理 API 密钥', ['MY_PROXY_API_KEY']) },
  models: [/* ... */],
  api: openAICompletionsApi(),
});

混合 API 提供商传递一个由 model.api 键控的映射;每个模型分发到其 API 的实现:

import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';

const gateway = createProvider({
  id: 'my-gateway',
  auth: { apiKey: envApiKeyAuth('网关密钥', ['GATEWAY_API_KEY']) },
  models: [/* 具有 api: 'anthropic-messages' 或 'openai-responses' 的模型 */],
  api: {
    'anthropic-messages': anthropicMessagesApi(),
    'openai-responses': openAIResponsesApi(),
  },
});

提供商范围的端点或请求转换属于提供商的 API 实现:包装你作为 api 传递的 ProviderStreams,以便每个请求在分发前都经过转换。Cloudflare 提供商这样做是为了从解析的提供商环境变量中实现帐户/网关端点占位符:

function tenantStreams(streams: ProviderStreams): ProviderStreams {
  const withTenant = (model: Model<Api>) => ({ ...model, baseUrl: model.baseUrl.replace('{tenant}', tenantId) });
  return {
    stream: (model, context, options) => streams.stream(withTenant(model), context, options),
    streamSimple: (model, context, options) => streams.streamSimple(withTenant(model), context, options),
  };
}

const tenantGateway = createProvider({
  id: 'tenant-gateway',
  auth: { apiKey: envApiKeyAuth('网关密钥', ['GATEWAY_API_KEY']) },
  models: [/* ... */],
  api: tenantStreams(openAICompletionsApi()),
});

动态模型列表使用 fetchModelsModels.refresh() 刷新每个配置的动态提供商,传递其有效的 API 密钥或刷新的 OAuth 凭据。一个 ModelsStore 持久化动态目录;两个存储都默认使用内存实现。

const models = createModels({ credentials, modelsStore });
const llamacpp = createProvider({
  id: 'llamacpp',
  auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } },
  models: [],
  fetchModels: async ({ signal }) => fetchModelsFromServer('http://localhost:8080', signal),
  api: openAICompletionsApi(),
});

models.setProvider(llamacpp);
const result = await models.refresh({ signal });
if (result.aborted) console.log('刷新已取消');
for (const [provider, error] of result.errors) console.error(provider, error);

使用 models.refresh({ allowNetwork: false }) 恢复持久化的目录而不访问网络,或使用 models.refresh({ force: true }) 绕过提供商的新鲜度检查。模型读取保持同步并返回最后恢复或刷新的列表。

自定义模型可以携带 headers(例如用于机器人检测的代理)和 compat 标志。Models.getAuth(model) 包含这些模型头,并且流方法在显式请求头和 transformHeaders 之前合并它们。参见 OpenAI 兼容性设置

一些与 OpenAI 兼容的服务器不理解用于推理能力模型的 developer 角色。对于这些提供商,将 compat.supportsDeveloperRole 设置为 false,以便系统提示作为 system 消息发送。如果服务器也不支持 reasoning_effort,也将 compat.supportsReasoningEffort 设置为 false。这通常适用于 Ollama、vLLM、SGLang 和类似的与 OpenAI 兼容的服务器。

使用模型级别的 thinkingLevelMap 来描述模型特定的思考控制。键是 pi 思考级别(offminimallowmediumhighxhighmax)。直到 high 的标准级别缺失使用提供商默认值;xhighmax 是选择加入的,需要非空映射条目。字符串值发送给提供商,null 标记级别不受支持,映射可以跳过级别。

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,
  thinkingLevelMap: {
    minimal: null,
    low: null,
    medium: null,
    high: 'high',
    xhigh: null,
  },
  compat: {
    supportsDeveloperRole: false,
    supportsReasoningEffort: false,
  }
};

直接调用 API 实现

API 实现本身也是可导入的。每个模块精确导出 streamstreamSimple,并具有该 API 的完整选项类型。直接调用绕过提供商认证 — 显式传递 apiKey

import { stream } from '@earendil-works/pi-ai/api/anthropic-messages';

const s = stream(claudeModel, context, {
  apiKey: process.env.ANTHROPIC_API_KEY,
  thinkingEnabled: true,
  thinkingBudgetTokens: 2048,
});

内置 API 实现位于 ./api/<api-id>

API id 选项类型
anthropic-messages AnthropicOptions
openai-completions OpenAICompletionsOptions
openai-responses OpenAIResponsesOptions
openai-codex-responses OpenAICodexResponsesOptions
azure-openai-responses AzureOpenAIResponsesOptions
google-generative-ai GoogleOptions
google-vertex GoogleVertexOptions
mistral-conversations MistralOptions
bedrock-converse-stream BedrockOptions

导入一个实现模块会加载其 SDK。./api/<id>.lazy 包装器(由提供商工厂使用)将该加载推迟到首次请求,当运行时或打包器支持动态导入分块时。旧版本中的旧原始 API 子路径(./anthropic./google./mistral./openai-completions、...)已被移除;请使用 @earendil-works/pi-ai/api/<api-id>

OpenAI 兼容性设置

openai-completions API 由许多具有细微差异的提供商实现。默认情况下,库基于 baseUrl 对一小部分已知的与 OpenAI 兼容的提供商(Cerebras、xAI、Chutes、DeepSeek、NVIDIA NIM、Together AI、zAi、OpenCode、Cloudflare Workers AI 等)自动检测兼容性设置。对于自定义代理或未知端点,你可以通过 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)
  supportsOpenAIGrammarTools?: boolean; // 是否发送 OpenAI 自定义 Lark/regex 语法工具;false 回退到普通函数工具(默认:false;生成的目录为能力模型启用它)
  sendSessionAffinityHeaders?: boolean; // 从 `sessionId` 发送会话亲和性数据(默认:false)
  sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // 会话亲和性格式:'openai' 使用 `prompt_cache_key`、`session_id`、`x-client-request-id` 和 `x-session-affinity`;'openai-nosession' 使用 `prompt_cache_key`、`x-client-request-id` 和 `x-session-affinity`;'openrouter' 使用 `x-session-id`(默认:自动检测)
  maxTokensField?: 'max_completion_tokens' | 'max_tokens';  // 使用的字段名称(默认:max_completion_tokens)
  requiresToolResultName?: boolean;  // 工具结果是否需要 `name` 字段(默认:false)
  requiresAssistantAfterToolResult?: boolean; // 工具结果后是否必须跟随助手消息(默认:false)
  requiresThinkingAsText?: boolean;  // 思考块是否必须转换为文本(默认:false)
  requiresReasoningContentOnAssistantMessages?: boolean; // 所有重播的助手消息在启用推理时是否都必须包含空的 reasoning_content(默认:自动检测,针对 DeepSeek)
  thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // 推理参数格式:'openai' 使用 reasoning_effort,'openrouter' 使用 reasoning: { effort },'deepseek' 当支持时使用 thinking: { type } 加 reasoning_effort,'together' 当支持时使用 reasoning: { enabled } 加 reasoning_effort,'zai' 使用 thinking: { type },'qwen' 使用 enable_thinking,'chat-template' 使用可配置的 chat_template_kwargs,'qwen-chat-template' 使用 chat_template_kwargs.enable_thinking 和 preserve_thinking,'string-thinking' 使用顶级 thinking,'ant-ling' 仅对映射的 effort 使用 reasoning: { effort }(默认:openai)
  chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs 值;对 pi 控制的思考值使用 $var
  cacheControlFormat?: 'anthropic';  // 在系统提示、最后一个工具和最后一个用户/助手文本内容上使用 Anthropic 风格的 cache_control
  openRouterRouting?: OpenRouterRouting; // OpenRouter 路由偏好(默认:{})
  vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway 路由偏好(默认:{})
}

interface OpenAIResponsesCompat {
  supportsDeveloperRole?: boolean;   // 提供商是否支持 `developer` 角色而非 `system`(默认:true)
  sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // 会话亲和性头格式:'openai' 发送 `session_id` 和 `x-client-request-id`;'openai-nosession' 发送 `x-client-request-id`;'openrouter' 发送 `x-session-id`。不影响 `prompt_cache_key` 主体参数(默认:自动检测)
  supportsLongCacheRetention?: boolean; // 提供商是否支持 `prompt_cache_retention: "24h"`(默认:true)
  supportsStrictMode?: boolean;      // 提供商是否支持严格的 JSON schema 函数工具(默认:false;在内置 OpenAI 模型的元数据中启用)
  supportsOpenAIGrammarTools?: boolean; // 是否发送 OpenAI 自定义 Lark/regex 语法工具;false 回退到普通函数工具(默认:false;生成的目录为能力模型启用它)
}

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

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

测试用假提供商

fauxProvider() 构建一个内存中的提供商,具有脚本化的测试和演示响应:

import {
  createModels,
  fauxAssistantMessage,
  fauxProvider,
  fauxText,
  fauxThinking,
  fauxToolCall,
} from '@earendil-works/pi-ai';

const faux = fauxProvider({
  tokensPerSecond: 50 // 可选
});

const models = createModels();
models.setProvider(faux.provider);

const model = faux.getModel();
const context = {
  messages: [{ role: 'user', content: '总结 package.json 然后调用 echo', timestamp: Date.now() }]
};

faux.setResponses([
  fauxAssistantMessage([
    fauxThinking('需要先检查包元数据。'),
    fauxToolCall('echo', { text: 'package.json' })
  ], { stopReason: 'toolUse' })
]);

const first = await models.complete(model, context, {
  sessionId: 'session-1',
  cacheRetention: 'short'
});
context.messages.push(first);

context.messages.push({
  role: 'toolResult',
  toolCallId: first.content.find((block) => block.type === 'toolCall')!.id,
  toolName: 'echo',
  content: [{ type: 'text', text: 'package.json 内容在此' }],
  isError: false,
  timestamp: Date.now()
});

faux.setResponses([
  fauxAssistantMessage([
    fauxThinking('现在我可以总结工具输出。'),
    fauxText('以下是总结。')
  ])
]);

const s = models.stream(model, context);
for await (const event of s) {
  console.log(event.type);
}

// 可选:多个假模型用于模型切换测试
const multiModel = fauxProvider({
  provider: 'faux-multi',
  models: [
    { id: 'faux-fast', reasoning: false },
    { id: 'faux-thinker', reasoning: true }
  ]
});
models.setProvider(multiModel.provider);
const thinker = multiModel.getModel('faux-thinker');

console.log(thinker?.reasoning);
console.log(faux.getPendingResponseCount());
console.log(faux.state.callCount);

注意事项:

  • 响应从队列中按请求开始顺序消耗。
  • 如果队列为空,假提供商返回一个错误消息,其中 errorMessage: "No more faux responses queued"
  • 使用 faux.setResponses([...]) 替换剩余的队列,使用 faux.appendResponses([...]) 添加更多响应。
  • faux.models 公开所有假模型。faux.getModel() 返回第一个,faux.getModel(id) 返回特定一个。
  • 使用 fauxAssistantMessage(...) 进行脚本化的助手回复。使用 fauxText(...)fauxThinking(...)fauxToolCall(...) 构建内容块,无需手动填写低级字段。
  • 用法估计大约每 4 个字符 1 个Token。当存在 sessionIdcacheRetention 不是 "none" 时,提示缓存读取和写入会自动模拟。
  • 工具调用参数通过 toolcall_delta 块逐步流式传输。
  • 默认情况下,每个流式块在自己的微任务上发出。设置 tokensPerSecond 以实时调节块发送速度。
  • 预期用途是每个Handle一个确定性的脚本化流程。如果需要独立的并发流程,请创建具有不同 provider ID 的单独假提供商。

跨提供商切换

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

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

  • 用户和工具结果消息 原样传递
  • 来自相同提供商/API 的助手消息 原样保留
  • 来自不同提供商的助手消息 将其思考块转换为带有 <thinking> 标签的文本
  • 工具调用和常规文本 原样保留
import { createModels, type Context } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { googleProvider } from '@earendil-works/pi-ai/providers/google';

const models = createModels();
models.setProvider(anthropicProvider());
models.setProvider(openaiProvider());
models.setProvider(googleProvider());

const context: Context = { messages: [] };

// 从 Claude 开始
const claude = models.getModel('anthropic', 'claude-sonnet-4-5')!;
context.messages.push({ role: 'user', content: '25 * 18 等于多少?', timestamp: Date.now() });
context.messages.push(await models.completeSimple(claude, context, { reasoning: 'medium' }));

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

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

所有提供商都可以处理来自其他提供商的消息 — 文本、工具调用和结果(包括图像)、思考块(转换为带标签的文本)以及带有部分内容的中止消息。这实现了灵活的工作流程:从快速模型开始,切换到更强大的模型进行复杂推理,或在提供商中断期间保持连续性。

上下文序列化

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

const context: Context = {
  systemPrompt: '你是一个有用的助手。',
  messages: [
    { role: 'user', content: '什么是 TypeScript?', timestamp: Date.now() }
  ]
};

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

// 序列化整个上下文
const serialized = JSON.stringify(context);

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

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

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

模型也是普通可序列化的数据 — 没有附加函数或实现 — 因此持久化"此对话正在使用哪个模型"只需一次 JSON.stringify

注意:如果上下文包含图像(如图像输入部分所示以 base64 编码),这些也会被序列化。

浏览器使用

本库支持浏览器环境。核心入口点和提供商工厂无副作用且打包干净。环境变量在浏览器中不可用,因此请显式传递 API 密钥 — 或注入一个 CredentialStore(例如 localStorage 支持的)并让提供商认证从存储的凭据中解析:

import { createModels } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';

const models = createModels();
models.setProvider(anthropicProvider());

const model = models.getModel('anthropic', 'claude-3-5-haiku-20241022')!;
const response = await models.complete(model, {
  messages: [{ role: 'user', content: '你好!', timestamp: Date.now() }]
}, {
  apiKey: 'your-api-key'
});

安全警告:在前端代码中暴露 API 密钥是危险的。任何人都可以提取并滥用你的密钥。仅将此方法用于内部工具或演示。对于生产应用,请使用保持 API 密钥安全的后端代理。

浏览器兼容性说明:

  • Amazon Bedrock (bedrock-converse-stream) 在浏览器环境中不受支持。它仍然可以出现在模型列表中;调用在运行时失败。
  • OAuth 登录流仅限 Node。它们通过打包器不透明导入进行懒加载,因此注册一个支持 OAuth 的提供商不会将仅 Node 的代码拉入浏览器包中 — 只有实际登录才会。
  • 如果你需要从 Web 应用使用 Bedrock 或基于 OAuth 的认证,请使用服务器端代理或后端服务。

打包与 tree shaking

对于小型包,只导入你需要的提供商:

import { createModels } from '@earendil-works/pi-ai';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';

const models = createModels();
models.setProvider(openaiProvider());

规则:

  • @earendil-works/pi-ai 是核心入口点,不导入内置目录、提供商工厂或 SDK 实现。
  • @earendil-works/pi-ai/providers/<provider> 只导入该提供商的目录和懒加载 API 包装器。
  • @earendil-works/pi-ai/providers/all 导入每个内置提供商工厂和所有目录。仅在你想要完整的内置集合时使用。
  • 通过代码分割,提供商 SDK 保留在懒加载块中,并在首次请求时加载。
  • 不进行代码分割时,打包器会将可达的懒加载 API 实现折叠到单个包中。然后,单提供商包包含该提供商的 SDK;providers/all 包含所有静态可见的 SDK。Bedrock 是个例外:其 AWS SDK 实现通过打包器不透明的仅 Node 输入加载。
  • 直接导入 @earendil-works/pi-ai/api/<api-id> 会立即加载该 API 实现及其 SDK。

避免在新打包的应用中使用 @earendil-works/pi-ai/compat;它保留了旧的全局 API 并导入了完整的内置目录表面。

对于单文件 Node ESM 包,一些 SDK 依赖项可能内部仍然使用动态 CommonJS require()。如果你看到类似 Dynamic require of "child_process" is not supported 的错误,请向包中添加 Node require shim。使用 esbuild:

esbuild app.js --bundle --platform=node --format=esm \
  --banner:js='import { createRequire } from "module";const require = createRequire(import.meta.url);' \
  --outfile=app.bundle.js

这仅适用于 Node 包;不是浏览器或 Cloudflare Workers 的解决方法。

Bedrock 仅适用于 Node。像任何其他提供商一样添加它:

import { createModels } from '@earendil-works/pi-ai';
import { amazonBedrockProvider } from '@earendil-works/pi-ai/providers/amazon-bedrock';

const models = createModels();
models.setProvider(amazonBedrockProvider());

在正常的 Node 包使用和代码分割包中,Bedrock 懒加载其 AWS SDK 实现。对于必须包含 Bedrock 支持的独立单文件包,请显式注册实现模块:

import { setBedrockProviderModule } from '@earendil-works/pi-ai/api/bedrock-converse-stream.lazy';
import { bedrockProviderModule } from '@earendil-works/pi-ai/bedrock-provider';

setBedrockProviderModule(bedrockProviderModule);

该显式覆盖会打包 AWS SDK。没有它,Bedrock 的不透明运行时导入期望包的 Bedrock 实现文件在运行时可用。

提供商范围的环境覆盖

在流选项中传递 env 以将提供商配置范围限定到单个请求。env 中的值优先于进程环境变量,用于提供商认证和配置,例如 Cloudflare 帐户 ID、Azure OpenAI 设置、Vertex 项目/位置、Bedrock 设置、PI_CACHE_RETENTIONHTTP_PROXY/HTTPS_PROXY

const models = builtinModels();
const model = models.getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6')!;

const response = await models.complete(model, context, {
  env: {
    CLOUDFLARE_API_KEY: '...',
    CLOUDFLARE_ACCOUNT_ID: 'account-id',
    CLOUDFLARE_GATEWAY_ID: 'gateway-id'
  }
});

当单个进程需要不同的提供商设置来满足不同请求,或者环境变量不应泄漏到提供商调用中时,请使用此方法。

OAuth 提供商

有几个提供商支持 OAuth 认证而不是静态 API 密钥:

  • Anthropic (Claude Pro/Max 订阅)
  • OpenAI Codex (ChatGPT Plus/Pro 订阅,访问 GPT-5.x Codex 模型)
  • GitHub Copilot (Copilot 订阅)
  • OpenRouter (OAuth PKCE,生成用户控制的 API 密钥)

这些提供商中的每一个在其 provider.auth.oauth 上携带一个 OAuthAuth,具有三个操作:login(interaction) 使用提供商中立的 AuthInteraction.prompt()/notify() 协议并返回凭据,refresh(credential) 在适当时刷新过期凭据,toAuth(credential) 派生出请求认证(GitHub Copilot 的每个帐户基本 URL 来自这里)。刷新是自动的:models.getAuth(providerId) 和请求路径在凭据存储锁下刷新过期的Token,因此并发请求和进程不能双重重放。OpenRouter 的 OAuth 流反而返回一个永久的 API 密钥,因此其刷新操作是一个空操作。

import { createModels } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';

const models = createModels({ credentials: myStore }); // 持久化 CredentialStore
models.setProvider(anthropicProvider());

// 登录:Models 驱动流程并持久化凭据
await models.login('anthropic', 'oauth', {
  prompt: async (p) => {
    // p.type: 'text' | 'secret' | 'select' | 'manual_code'
    // manual_code 提示竞速本地回调服务器;当服务器胜出时 p.signal 中止它们
    return await askUser(p.message);
  },
  notify: (event) => {
    // event.type: 'info' | 'auth_url' | 'device_code' | 'progress'
    if (event.type === 'info') {
      console.log(event.message);
      for (const link of event.links ?? []) console.log(`${link.label ?? '更多信息'}: ${link.url}`);
    }
    if (event.type === 'auth_url') console.log(`打开: ${event.url}`);
    if (event.type === 'device_code') console.log(`代码: ${event.userCode} 在 ${event.verificationUri}`);
    if (event.type === 'progress') console.log(event.message);
  },
});

// 从此以后,请求会自动解析并刷新Token
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
await models.complete(model, context);

// 登出
await models.logout('anthropic');

Vertex AI

Vertex AI 模型支持 Google Cloud API 密钥或 Application Default Credentials (ADC)。其提供商拥有的 API 密钥登录流程可以配置任一方法:

  • API 密钥:设置 GOOGLE_CLOUD_API_KEY 或在调用选项中传递 apiKey
  • 本地开发 (ADC):运行 gcloud auth application-default login
  • CI/生产 (ADC):将 GOOGLE_APPLICATION_CREDENTIALS 设置为指向服务帐户 JSON 密钥文件

使用 ADC 时,还要设置 GOOGLE_CLOUD_PROJECT(或 GCLOUD_PROJECT)和 GOOGLE_CLOUD_LOCATION。你也可以在调用选项中传递 project/location。使用 GOOGLE_CLOUD_API_KEY 时,不需要 projectlocation

## 本地(使用你的用户凭据)
gcloud auth application-default login
export GOOGLE_CLOUD_PROJECT="my-project"
export GOOGLE_CLOUD_LOCATION="us-central1"

## CI/生产(服务帐户密钥文件)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"

官方文档:Application Default Credentials

CLI 登录

最快捷的认证方式:

npx @earendil-works/pi-ai login              # 交互式提供商选择
npx @earendil-works/pi-ai login anthropic    # 登录到特定提供商
npx @earendil-works/pi-ai list               # 列出可用提供商

凭据保存到当前目录的 auth.json 中。

编程式 OAuth

内置的登录和刷新流是私有的提供商实现。使用提供商拥有的 OAuthAuth,它与 CredentialStore 组合,并通过 Models 获得锁定的自动刷新。@earendil-works/pi-ai/oauth 入口点仅保留编码Agent扩展 OAuth 兼容性所需的类型声明。

提供商说明:

OpenAI Codex:需要 ChatGPT Plus 或 Pro 订阅。提供对具有扩展上下文窗口和推理能力的 GPT-5.x Codex 模型的访问。当在流选项中提供 sessionId 时,库会自动处理基于会话的提示缓存,除非 cacheRetention"none"。你可以在流选项中设置 transport"sse""websocket""auto",用于 Codex Responses 传输选择。使用 WebSocket 并启用 sessionId 和缓存保留时,连接按会话重用,并在不活动 5 分钟后过期。

Azure OpenAI (Responses):仅使用 Responses API。设置 AZURE_OPENAI_API_KEY 以及 AZURE_OPENAI_BASE_URLAZURE_OPENAI_RESOURCE_NAMEAZURE_OPENAI_BASE_URL 同时支持 https://<resource>.openai.azure.comhttps://<resource>.cognitiveservices.azure.com;根端点自动规范化为 /openai/v1。使用 AZURE_OPENAI_API_VERSION(默认为 v1)在需要时覆盖 API 版本。默认情况下部署名称被视为模型 ID,使用 azureDeploymentName 或逗号分隔的 model-id=deployment 对(例如 gpt-4o-mini=my-deployment, gpt-4o=prod)的 AZURE_OPENAI_DEPLOYMENT_NAME_MAP 进行覆盖。旧版基于部署的 URL 有意不支持。

GitHub Copilot:如果你收到"所请求的模型不受支持"错误,请在 VS Code 中手动启用模型:打开 Copilot Chat,单击模型选择器,选择模型(警告图标),然后单击"启用"。

从旧全局 API 迁移

旧版本暴露了一个全局 API:stream()/complete() 通过全局注册表根据 model.api 分发,同步 getModel()/getModels()/getProviders() 目录读取,registerApiProvider()getEnvApiKey() 以及每个 API 的懒加载流函数。该表面在兼容入口点上保持不变:

// 之前
import { getModel, complete } from '@earendil-works/pi-ai';

// 之后(行为相同,仅导入路径更改)
import { getModel, complete } from '@earendil-works/pi-ai/compat';

Compat 是根入口点的严格超集,因此文件可以整体切换其导入路径。它将在未来版本中移除;请迁移到 createModels() + 提供商工厂:

getModel('openai', 'gpt-4o-mini') models.getModel('openai', 'gpt-4o-mini') 或来自 providers/allgetBuiltinModel()
getModels('anthropic') / getProviders() models.getModels('anthropic') / models.getProviders()getBuiltin*
stream(model, ctx, opts) (环境密钥注入) models.stream(model, ctx, opts) (提供商认证解析)
registerApiProvider({ api, stream, streamSimple }) createProvider({ id, auth, models, api }) + models.setProvider()
getEnvApiKey('openai') await models.getAuth(model.provider)
streamAnthropic(model, ctx, opts) 来自 @earendil-works/pi-ai/api/anthropic-messagesstream,或集合中的提供商
registerFauxProvider() fauxProvider() + models.setProvider()

开发

添加新提供商

添加新的 LLM 提供商需要对多个文件进行更改。分层布局:API 实现位于 src/api/,提供商工厂位于 src/providers/,稳定的生成目录包装器位于 src/providers/<id>.models.ts,并且 src/models.generated.ts 注册它们。此清单涵盖所有必要步骤:

1. 核心类型 (src/types.ts)
  • 将 API 标识符添加到 KnownApi(例如 "bedrock-converse-stream"),如果它是一个新的 API
  • 将提供商名称添加到 KnownProvider(例如 "amazon-bedrock"
  • 将选项类型添加到 ApiOptionsMap
2. API 实现 (src/api/<api-id>.ts,仅针对新 API)

创建一个新的 API 实现文件(例如 bedrock-converse-stream.ts),导出精确的 streamstreamSimple,加上:

  • 一个扩展 StreamOptions 的选项接口(例如 BedrockOptions
  • Context 转换为提供商格式的消息转换函数
  • 如果提供商支持工具,则进行工具转换
  • 响应解析以发出标准化事件(texttool_callthinkingusagestop

添加一个懒加载包装器 src/api/<api-id>.lazy.ts(通过 lazyApi()<name>Api()),以便提供商可以引用实现而不导入其 SDK。在 src/index.ts 中添加应从 @earendil-works/pi-ai 可用的任何根级别 export type 重新导出。

3. 模型生成 (scripts/generate-models.tsscripts/generate-image-models.ts)
  • 添加从提供商来源(例如 models.dev API)获取和解析模型的逻辑
  • 通过 scripts/generate-models.ts 将聊天/工具能力的提供商模型数据映射到标准化 Model 接口;hydration 按 API 分组被忽略的 src/providers/data/<id>.json 值,而稳定的 src/providers/<id>.models.ts 包装器直接从这些 JSON 键派生确切的模型/API 类型
  • 通过 scripts/generate-image-models.ts 将图像生成提供商模型数据映射到标准化 ImagesModel 接口
  • 处理提供商特定的特殊之处(定价格式、能力标志、模型 ID 转换)
4. 提供商工厂 (src/providers/<id>.ts)
  • createProvider() 连接目录 + 认证 + 懒加载 API 包装器
  • 认证:标准密钥提供商的 envApiKeyAuth,环境认证(AWS 配置文件、ADC)的自定义 ApiKeyAuth,存在 OAuth 流时的 lazyOAuth
  • src/providers/all.ts 中注册工厂
  • 如果是新 API:在 src/compat.ts 的内置列表中注册,并在 package.json 中添加包子路径导出
5. 测试 (test/)

创建或更新测试文件以覆盖新提供商:

  • stream.test.ts - 基本流式处理和工具使用
  • tokens.test.ts - Token使用报告
  • abort.test.ts - 请求取消
  • empty.test.ts - 空消息处理
  • context-overflow.test.ts - 上下文限制错误
  • image-limits.test.ts - 图像支持(如果适用)
  • unicode-surrogate.test.ts - Unicode 处理
  • tool-call-without-result.test.ts - 孤立的工具调用
  • image-tool-result.test.ts - 工具结果中的图像
  • total-tokens.test.ts - Token计数准确性
  • cross-provider-handoff.test.ts - 跨提供商上下文重放
  • providers.test.ts - 提供商列表和认证解析

对于 cross-provider-handoff.test.ts,至少添加一个提供商/模型对。如果提供商公开了多个模型系列(例如 GPT 和 Claude),则每个系列至少添加一个对。

对于具有非标准认证(AWS、Google Vertex)的提供商,创建一个实用程序,如带有凭据检测辅助函数的 bedrock-utils.ts

6. 编码 Agent 集成 (../coding-agent/)

更新 src/core/model-resolver.ts

  • DEFAULT_MODELS 中添加提供商的默认模型 ID

更新 src/cli/args.ts

  • 在帮助文本中添加环境变量文档

更新 README.md

  • 将提供商添加到提供商部分,附上设置说明
7. 文档

更新 packages/ai/README.md

  • 添加到支持的提供商表格
  • 记录任何提供商特定的选项或认证要求
  • 在环境变量部分添加环境变量
8. 变更日志

packages/ai/CHANGELOG.md## [Unreleased] 下添加条目:

#### Added
- 添加了对 [提供商名称] 提供商的支持 ([#PR](链接) 由 [@author](链接))

许可协议

MIT

  • 原文链接: github.com/badlogic/pi-m...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论