pi-ai:Models 运行时与 Provider 架构

pi-ai 是 Pi 与各家大模型对话的"驱动层"。从 v0.79 开始,它经历了一次重大重构:从旧的全局 API registry 转向 Models 运行时。本文基于 Pi v0.79.10 深入讲解新架构。

为什么重构?

旧架构(v0.78 及以前)的核心是 api-registry.ts

registerApiProvider('openai-completions', { stream, streamSimple });
registerApiProvider('anthropic-messages', { stream, streamSimple });

// 调用时
stream(model, context, opts); // 内部查表、注入 API key

问题:

  1. 全局副作用registerApiProvider 修改模块级状态,tree-shaking 困难
  2. 认证混乱:API key 从环境变量、auth 文件、运行时覆盖等多种来源临时注入
  3. 模型元数据臃肿:所有模型塞进一个 models.generated.ts,动辄上万行
  4. OAuth 难管理:没有统一凭证存储和刷新机制

新架构的回答是:每个调用者持有自己的 Models 实例,Provider 自带认证配置,模型目录按 Provider 拆分。

核心抽象总览

┌─────────────────────────────────────────────────────────────┐
│                        Models 运行时                         │
│  createModels({ credentials, authContext })                  │
│       │                                                     │
│       ▼                                                     │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐       │
│  │  Provider   │   │  Provider   │   │  Provider   │       │
│  │  anthropic  │   │   openai    │   │  openrouter │ ...   │
│  └──────┬──────┘   └──────┬──────┘   └──────┬──────┘       │
│         │                 │                 │              │
│         ▼                 ▼                 ▼              │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐       │
│  │    auth     │   │    auth     │   │    auth     │       │
│  │ apiKey/oauth│   │ apiKey/oauth│   │ apiKey/oauth│       │
│  └──────┬──────┘   └──────┬──────┘   └──────┬──────┘       │
│         │                 │                 │              │
│         ▼                 ▼                 ▼              │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐       │
│  │   models    │   │   models    │   │   models    │       │
│  │   catalog   │   │   catalog   │   │   catalog   │       │
│  └──────┬──────┘   └──────┬──────┘   └──────┬──────┘       │
│         │                 │                 │              │
│         ▼                 ▼                 ▼              │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐       │
│  │     api     │   │     api     │   │     api     │       │
│  │anthropic-   │   │openai-      │   │openai-      │       │
│  │messages     │   │completions  │   │completions  │       │
│  └─────────────┘   └─────────────┘   └─────────────┘       │
└─────────────────────────────────────────────────────────────┘

Provider:一个完整的 LLM 提供商

文件packages/ai/src/models.ts

export interface Provider<TApi extends Api = Api> {
  id: string; // "anthropic"
  name: string; // "Anthropic"
  baseUrl?: string; // API 基础地址
  auth: ProviderAuth; // 认证配置
  models: Model<TApi>[]; // 该 Provider 支持的模型列表
  api: ApiImplementation<TApi>; // API 实现
}

一个 Provider 由四部分组成:

字段作用
id / nameProvider 标识与显示名
baseUrlAPI 基础地址,可被自定义 Provider 覆盖
auth该 Provider 如何获取 API key / OAuth token
models模型元数据数组
api该 Provider 使用哪种 LLM 协议实现

Provider factory 示例

文件packages/ai/src/providers/anthropic.ts

import { anthropicMessagesApi } from '../api/anthropic-messages.lazy.ts';
import { envApiKeyAuth, lazyOAuth } from '../auth/helpers.ts';
import { createProvider, type Provider } from '../models.ts';
import { loadAnthropicOAuth } from '../utils/oauth/load.ts';
import { ANTHROPIC_MODELS } from './anthropic.models.ts';

export function anthropicProvider(): Provider<'anthropic-messages'> {
  return createProvider({
    id: 'anthropic',
    name: 'Anthropic',
    baseUrl: 'https://api.anthropic.com',
    auth: {
      apiKey: envApiKeyAuth('Anthropic API key', ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']),
      oauth: lazyOAuth({ name: 'Anthropic (Claude Pro/Max)', load: loadAnthropicOAuth }),
    },
    models: Object.values(ANTHROPIC_MODELS),
    api: anthropicMessagesApi(),
  });
}

createProvider() 会做一些标准化工作(如给 api 实现绑定 Provider 上下文),返回一个 Provider 对象。

API:只负责协议实现

目录packages/ai/src/api/

每种 LLM 协议一个文件,只导出两个函数:

export interface ApiImplementation<TApi extends Api> {
  stream(model: Model<TApi>, context: Context, options?: ApiStreamOptions<TApi>): Promise<AssistantMessageEventStream>;
  streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}

例如:

  • openai-completions.ts — OpenAI Chat Completions API
  • openai-responses.ts — OpenAI Responses API
  • anthropic-messages.ts — Anthropic Messages API
  • google-generative-ai.ts — Google Gemini API
  • bedrock-converse-stream.ts — Amazon Bedrock Converse

API 实现内部只做协议转换和 SSE 解析,不处理认证来源。认证由 Provider 的 auth 配置和 Models 运行时的 CredentialStore 提供。

lazy 包装

api/*.lazy.ts 是按需加载的 wrapper,用于减少启动时加载未使用 Provider 的实现代码:

export function anthropicMessagesApi() {
  return {
    stream: (...args) => import('./anthropic-messages.ts').then((m) => m.stream(...args)),
    streamSimple: (...args) => import('./anthropic-messages.ts').then((m) => m.streamSimple(...args)),
  };
}

Models 运行时:统一调用入口

文件packages/ai/src/models.ts

export interface Models {
  getModel(providerId: string, modelId: string): Model<any> | undefined;
  getModels(providerId?: string): Model<any>[];
  setProvider(provider: Provider): void;
  deleteProvider(id: string): void;
  refresh(providerId?: string): Promise<void>;

  stream(model: Model<Api>, context: Context, options?: ApiStreamOptions): Promise<AssistantMessage>;
  complete(model: Model<Api>, context: Context, options?: ApiStreamOptions): Promise<AssistantMessage>;
  streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
  completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>;
}

创建 Models 实例

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

const models = createModels({
  credentials: new InMemoryCredentialStore(),
  authContext: defaultAuthContext(),
});

注册 Provider

import { anthropicProvider, openAIProvider } from '@earendil-works/pi-ai/providers/all';

models.setProvider(anthropicProvider());
models.setProvider(openAIProvider());

调用 LLM

const model = models.getModel('anthropic', 'claude-sonnet-4-20250514');
if (!model) throw new Error('model not found');

const response = await models.stream(
  model,
  {
    systemPrompt: 'You are a helpful coding assistant.',
    messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
    tools: [],
  },
  {
    maxTokens: 4096,
  },
);

内置全部 Provider

如果你不想逐个注册,可以用 builtinModels()

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

const models = builtinModels();

这会把所有内置 Provider 都注册进去。Pi 自身在启动时就是这么做的。

认证基础设施

目录packages/ai/src/auth/

ProviderAuth

export interface ProviderAuth {
  apiKey?: ApiKeyAuth;
  oauth?: OAuthAuth;
}

辅助函数

文件packages/ai/src/auth/helpers.ts

// 从环境变量读取 API key
envApiKeyAuth('OpenAI API key', ['OPENAI_API_KEY']);

// 延迟加载 OAuth 凭证
lazyOAuth({ name: 'GitHub Copilot', load: loadGitHubCopilotOAuth });

CredentialStore

export interface CredentialStore {
  read(providerId: string): Promise<StoredCredential | undefined>;
  modify(providerId: string, fn: (current?: StoredCredential) => StoredCredential | undefined): Promise<void>;
  delete(providerId: string): Promise<void>;
}

InMemoryCredentialStore 是默认实现。coding-agent 中使用的是 AuthStorage,它把凭证持久化到 ~/.pi/agent/auth.json

AuthContext

AuthContext 提供认证所需的外部回调:

  • prompt() / notify() — OAuth 登录提示
  • 环境变量读取
  • UI 状态更新
export interface AuthContext {
  prompt: OAuthPrompt;
  notify: OAuthNotify;
  getEnv: (name: string) => string | undefined;
  // ...
}

OAuth 刷新在 CredentialStore 的锁内进行,避免并发刷新导致竞态。

模型目录:从单一文件到按 Provider 拆分

目录packages/ai/src/providers/*.models.ts

v0.79 之前所有模型元数据在一个 models.generated.ts 中。v0.79 之后,每个 Provider 有自己的模型目录:

providers/
├── anthropic.models.ts
├── openai.models.ts
├── openrouter.models.ts
├── google.models.ts
└── ...

这样有几个好处:

  1. Tree-shaking:只引用 anthropicProvider() 时不会打包 OpenAI 的模型目录
  2. 按需生成generate-models.ts 可以单独更新某个 Provider 的模型
  3. 类型更精确Model<'openai-completions'> 与 Provider 强绑定

Model 类型

export interface Model<TApi extends Api> {
  id: string;
  name: string;
  api: TApi;
  provider: string;
  contextWindow: number;
  reasoning: boolean;
  input: ('text' | 'image')[];
  cost: CostInfo;
  compat?: Compat;
  // ...
}

兼容层:compat.ts

文件packages/ai/src/compat.ts

旧全局 API 没有直接删除,而是移到 @earendil-works/pi-ai/compat

import { stream, complete, getBuiltinModel, registerApiProvider } from '@earendil-works/pi-ai/compat';

这是临时兼容入口,未来会随 coding-agent 的 ModelManager 迁移而删除。新代码应使用 createModels() 和 Provider factory。

coding-agent 中如何组装 pi-ai

文件packages/coding-agent/src/core/sdk.tspackages/coding-agent/src/core/model-registry.ts

coding-agent 启动时会做以下事情:

  1. 创建 AuthStorage(文件 backed 的 CredentialStore)
  2. 创建 ModelRegistry,把 AuthStorage 和 settings 中的 Provider 配置结合
  3. 构造 Models 实例(通常通过 builtinModels()
  4. AgentstreamFn 中调用 modelRegistry.getApiKeyAndHeaders(model) 解析认证
  5. 调用 models.stream(model, context, { ...auth })
// sdk.ts 中的简化版 streamFn
streamFn: async (model, context, options) => {
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
  if (!auth.ok) throw new Error(auth.error);

  const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined;

  return models.stream(model, context, {
    ...options,
    apiKey: auth.apiKey,
    headers: auth.headers,
    env,
  });
};

关键设计决策

1. 无状态 Provider factory

每个 Provider factory(如 anthropicProvider())都是无状态的纯函数。调用者可以创建多个 Models 实例,各自拥有独立的 Provider 配置和凭证。

2. 认证与协议分离

API 实现只关心协议;认证完全由 Provider 的 auth 字段和 CredentialStore 管理。新增 Provider 时不需要修改 API 实现。

3. 同步模型读取 + 显式刷新

Models.getModel() / getModels() 是同步的,返回上一次已知的模型列表。对于动态 Provider(如 GitHub Copilot 的模型选择器),需要显式调用 models.refresh(providerId)

4. 模型元数据编译期类型安全

const model = getBuiltinModel('anthropic', 'claude-sonnet-4-20250514');
//     ^ Model<"anthropic-messages">

models.stream(model, context, options);
// TypeScript 会根据 model.api 推断 options 类型

下一步