模型与 Provider

@earendil-works/pi-ai 把不同模型供应商收敛到统一的 Model + Context + AssistantMessageEventStream。这让 agent loop 不需要关心 OpenAI、Anthropic、Google、Bedrock 等 API 的细节。

本章涉及的类型、registry、lazy provider、compat、OAuth 和 ModelRegistry 行号见源码索引

关键源码:

  • packages/ai/src/types.ts
  • packages/ai/src/stream.ts
  • packages/ai/src/api-registry.ts
  • packages/ai/src/providers/register-builtins.ts
  • packages/coding-agent/src/core/model-registry.ts
  • packages/coding-agent/src/core/sdk.ts

统一模型接口

Model 描述模型本身:

  • id
  • name
  • api
  • provider
  • baseUrl
  • reasoning
  • contextWindow
  • maxTokens
  • cost
  • compat

Context 描述一次请求:

interface Context {
  systemPrompt?: string;
  messages: Message[];
  tools?: Tool[];
}

Message 只包含三类:

  • UserMessage
  • AssistantMessage
  • ToolResultMessage

应用层的自定义消息必须在调用 provider 前转成这些标准消息。

Provider Registry

api-registry.ts 维护 api -> provider 映射。

Provider 要实现:

  • stream(model, context, options)
  • streamSimple(model, context, options)

注册时会包一层校验:如果传入的 model.api 和 provider 声明的 api 不一致,直接报错。这能早发现模型配置错误。

streamSimple()

stream.ts 是上层真正调用的入口:

  1. 根据 model.api 从 registry 找 provider。
  2. 如果 options 没显式 apiKey,尝试从环境变量读取。
  3. 调用 provider 的 streamSimple()

completeSimple() 只是对 streamSimple() 的封装:等待 stream result。

Agent loop 只依赖这个统一接口。

AssistantMessageEventStream

Provider 的输出不是直接返回完整文本,而是统一事件流:

  • start
  • text_start/text_delta/text_end
  • thinking_start/thinking_delta/thinking_end
  • toolcall_start/toolcall_delta/toolcall_end
  • done
  • error

每个事件都携带 partial 或 final AssistantMessage。这使得 UI、日志、agent state 可以用同一套事件协议处理文本、thinking 和 tool call。

Lazy Provider

providers/register-builtins.ts 用 lazy-load 注册内置 provider。第一次请求某个 API 时才动态 import 对应模块,再注册真实 provider。

好处:

  • 启动更快。
  • 减少不相关 provider 的运行时成本。
  • provider 加载失败也能转成统一 assistant error message,而不是让外层流程崩掉。

Provider 兼容层

types.ts 中有大量 compat 配置,例如:

  • OpenAI completions 是否支持 developer role。
  • tool result 是否需要 name。
  • thinking/reasoning 参数格式。
  • prompt cache control 格式。
  • session affinity header。
  • Anthropic eager tool input streaming。

这些字段体现了一个现实:即使都号称 OpenAI-compatible,不同平台也会有细碎差异。Pi 把差异显式建模在 compat 层,而不是散落在 agent loop。

Coding Agent 的模型管理

createAgentSession() 会从多个来源决定模型:

  1. 如果 session 已有消息,优先从 session 中恢复 model。
  2. 如果恢复失败,用 settings/default provider/default model。
  3. 如果仍没有,就从可用模型中找初始模型。
  4. thinking level 会根据模型能力 clamp。

AgentSession 后续支持:

  • setModel()
  • cycleModel()
  • scoped models
  • setThinkingLevel()
  • cycleThinkingLevel()

模型切换会写入 session entry,也会写入 settings 作为默认值。

认证与请求头

sdk.ts 里的 streamFn 会:

  • 通过 ModelRegistry.getApiKeyAndHeaders(model) 获取认证。
  • 合并 env、timeout、retry、websocket connect timeout。
  • 合并 provider attribution headers。
  • sessionId 传给 provider,用于支持缓存 affinity。

扩展还可以通过 provider request hook 修改 payload 或观察 response。

设计启发

  • 模型适配应集中在 provider 层,agent loop 不应该知道 provider 差异。
  • 流协议要把 text、thinking、tool call 都作为一等事件。
  • OpenAI-compatible 并不等于真正兼容,compat 配置应该显式化。
  • 模型选择、认证、provider 请求参数是产品层职责,不是 agent loop 职责。