模型层的秘密:一个接口如何驯服 40 个厂商
模型层的秘密:一个接口如何驯服 40 个厂商
🏭 40+ Providers
🔌 9 种协议
🔑 双通道认证
⚡ lazyStream
📚 Pi 源码学习系列
系列第一篇里,我们沿着一条消息从上到下走了一遍。走到模型层时,我留了个悬念:provider.streamSimple 内部到底是什么?provider 从哪来?模型目录是谁生成的?认证是怎么塞进请求的?
这篇把模型层整个掀开。它是 packages/ai 包(2.3 万行,174 个文件),也是整个项目里”抽象最密”的地方。拆开之后,骨架只有三句话:数据行为分离,协议厂商分离,认证契约化。
🗺️ 地图:ai 包的四层结构
┌──────────────────────────────────────────────────────┐│ Models 集合层 (models.ts, 945 行) ││ providers Map + 凭证存储 + auth 解析 + 请求分发 ││ ← coding-agent 的 ModelRuntime 就包着它 │├──────────────────────────────────────────────────────┤│ Provider 层 (providers/, 86 个文件) ││ 40+ 个厂商工厂: deepseek.ts / anthropic.ts / ... ││ 每个 = id + baseUrl + auth + models + api │├──────────────────────────────────────────────────────┤│ Api 协议层 (api/, 31 个文件) ││ 9 种协议: openai-completions / openai-responses / ││ anthropic-messages / google-generative-ai / ... ││ 每个 = 统一 Context → 各家 HTTP 请求 → 统一事件流 │├──────────────────────────────────────────────────────┤│ 数据与工具: types.ts / auth/ / utils/ / 生成器脚本 │└──────────────────────────────────────────────────────┘第一站先说清楚一件事:provider 自己不发请求。请求是”协议”发的,provider 只是把协议、模型、认证方式组装起来的胶水。这个认知会贯穿全文。
🧩 站 1:三大抽象,各管一件事
这是 ai 包最重要的心智模型:
| 抽象 | 是什么 | 类比 | 数量 |
|---|---|---|---|
Api | 一种协议(请求格式 + SSE 解析) | 语言 | 9 种 |
Provider | 一个厂商(baseUrl + 认证 + 模型清单) | 说这种语言的公司 | 40+ 个 |
Model | 一个模型的元数据(纯数据,无行为) | 公司的产品 | 上千个 |
组合关系是多对多。看一个最小的完整 provider,整个文件就这些:
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(), // ← 复用 OpenAI 的协议! });}DeepSeek、Moonshot、MiniMax、Groq、Cerebras……一大批厂商都是同一行 api: openAICompletionsApi(),因为它们都兼容 OpenAI 的 chat completions 格式。加一个新厂商 = 5 行代码;只有协议不兼容时才需要写新适配器(anthropic-messages、google-generative-ai 就是自己写的)。
反过来,一个厂商可以混用多种协议。createProvider 的 api 参数支持两种形态:
// models.ts:719api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;传单个实现,所有模型共用;传一个 Map,按 model.api 分派。OpenAI 自己的 provider 就是混合的:responses 协议 + completions 协议 + codex 协议并存,一个厂商吃三种协议。
为什么 Model 必须是纯数据? 因为模型目录是生成出来的(下一站),而且模型要能跨进程序列化(存盘、恢复)。行为永远在 Provider/API 层,数据永远在 Model 层。这个”数据行为分离”是 ai 包的第一原则,后面所有设计都从它长出来。
🏭 站 2:模型数据从哪来:生成器 + 类型体操
models.generated.ts(125 行)别读,它是流水线产物,读完就忘。真正的源头是生成脚本:
scripts/generate-models.ts (手工维护, 含各家定价/规格) ↓ 生成src/providers/<name>.models.ts ← 每个厂商的汇总文件 ↓ importsrc/providers/data/<name>.json ← 数据本体(.gitignore, 不在仓库里)<name>.models.ts 每个只有 6 行:
// deepseek.models.ts —— 自动生成import values from "./data/deepseek.json" with { type: "json" };import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
export const DEEPSEEK_MODELS: ModelCatalog<typeof values, "deepseek"> = flattenModelCatalog("deepseek", values);flattenModelCatalog 看着平淡,里面藏着整个包最深的类型体操(28 行,值得全读):
type ModelId<TGroups extends ModelGroups> = { [TApi in keyof TGroups]: keyof TGroups[TApi]; // 取每个分组的所有模型 id}[keyof TGroups] & string;
type ModelApi<TGroups extends ModelGroups, TModelId extends ModelId<TGroups>> = { [TApi in keyof TGroups]: TModelId extends keyof TGroups[TApi] ? TApi : never; // 反查 id 属于哪个协议}[keyof TGroups] & Api;
export type ModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = { [TModelId in ModelId<TGroups>]: Model<ModelApi<TGroups, TModelId>> & { id: TModelId; provider: TProvider };};它从 JSON 数据推导类型:每个模型 id 自动关联到它所属的协议分组。于是 DEEPSEEK_MODELS["deepseek-chat"] 的类型是 Model<"openai-completions">,stream() 的参数自动全类型。改 JSON 数据,类型跟着变,不可能出现”目录里有、类型里没有”的漂移。
🔑 站 3:认证双通道:apiKey 和 OAuth
auth/types.ts 把认证收敛成两个通道:
export interface ProviderAuth { apiKey?: ApiKeyAuth; // API key 通道 oauth?: OAuthAuth; // OAuth 通道}通道一:envApiKeyAuth(九成厂商用这个)
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth { return { login: async (interaction) => { const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` }); return { type: "api_key", key }; }, resolve: async ({ ctx, credential, signal }) => { if (credential?.key) { // ① 已存储的凭证优先 return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; } for (const envVar of envVars) { // ② 然后扫环境变量 const value = await ctx.env(envVar); if (value) return { auth: { apiKey: value }, source: envVar }; } return undefined; // ③ 都没有 = 未配置 }, };}解析顺序就三条:存储的凭证 → 环境变量 → 未配置。注意 resolve 返回 undefined 表示”这个厂商没配”,调用方据此决定哪些模型可用。source 字段(“ANTHROPIC_API_KEY” / “OAuth” / ”~/.aws/credentials”)会一路带到状态 UI,你看到的”Auth: ANTHROPIC_API_KEY”就是它。
非标准厂商自己写 ApiKeyAuth:Cloudflare 要 account id 和 gateway id,AWS 要走 profile 解析,接口形状一样,实现各不相同。
通道二:OAuth 双检锁刷新
OAuth 的难点是 token 过期。auth/resolve.ts 里这段是我读过的并发刷新处理里最严谨的写法之一:
const expiresSoon = (credential) => Date.now() + 5 * 60_000 >= credential.expires; // 5 分钟阈值
if (expiresSoon(credential)) { post = await credentials.modify(providerId, async (current) => { if (current?.type !== "oauth") return undefined; // 已登出 if (!expiresSoon(current)) return undefined; // 别人已刷新(双检!) return await oauth.refresh(current, refreshSignal); // 锁内才真正刷新 });}先乐观检查(过期了?),进 modify 锁后再查一次(还过期吗?),只有锁内确认过期才刷新。因为 modify 是串行化的写路径,并发请求不会重复刷新同一个 token。刷新失败的 token 保留在存储里供重试,报 ModelsError code “oauth”。
CredentialStore:认证的存储契约
export interface CredentialStore { read(providerId): Promise<Credential | undefined>; list(): Promise<readonly CredentialInfo[]>; // 只给元信息, 不泄露密钥 modify(providerId, fn): Promise<Credential | undefined>; // 唯一写路径, 串行化 delete(providerId): Promise<void>;}这是”接口在 ai 包、实现在宿主”的典型:coding-agent 的 AuthStorage(落盘 auth.json)实现了它。ai 包只定契约,不管文件格式。想换存储?换一个实现就行。
🚚 站 4:请求的完整旅程(补全系列第一篇)
系列第一篇停在 prepared.provider.streamSimple。现在把它的内部补完:
// models.ts (ModelsImpl.streamSimple)streamSimple(model, context, options) { return lazyStream(model, async () => { const provider = this.requireProvider(model); // ① 找到厂商 const { requestModel, requestOptions } = await this.applyAuth(model, options); // ② 认证 return provider.streamSimple(requestModel, context, requestOptions); });}applyAuth 是”钱和身份的边界”完整版:
const resolution = await this.getAuth(model, {...}); // ③ 解析凭证const apiKey = options?.apiKey ?? auth.apiKey; // ④ 显式传入优先于存储let headers = mergeHeaders(auth.headers, options?.headers); // header 合并, 大小写不敏感const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; // ⑤ baseUrl 覆盖return { requestModel, requestOptions };然后 provider 的 dispatch 按 model.api 选出协议实现,进入 Api 层:
// api/openai-completions.ts —— deepseek 的实际请求路径streamSimple(model, context, options) { const base = buildBaseOptions(model, context, options, options?.apiKey); const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; return stream(model, context, { ...base, reasoningEffort: clampedReasoning === "off" ? undefined : clampedReasoning });}buildBaseOptions 做的是格式翻译:把统一 Context(messages / tools / systemPrompt)转成 OpenAI 的 chat completions 请求体,SSE 解析后产出统一事件流。每个 Api 适配器都是同构的:streamSimple(薄封装)→ stream(HTTP + 事件流)→ 解析函数。读一个等于读全部。
⚡ 站 5:lazyStream,同步返回异步就绪
你注意到没有:streamSimple 不是 async 函数,却要等认证、可能要动态加载模块。秘密在 lazyStream(api/lazy.ts,70 行):
export function lazyStream(model, setup) { const outer = new AssistantMessageEventStream(); setup() // 异步就绪(认证 / 加载模块) .then((inner) => forwardStream(outer, inner)) .catch((error) => { // 失败编码进流, 不 throw const message = createSetupErrorMessage(model, error); outer.push({ type: "error", reason: "error", error: message }); outer.end(message); }); return outer; // 立即返回空流}| 时序 | 调用方视角 | 内部发生的事 |
|---|---|---|
| 同步 | 拿到一个空的事件流 | 认证解析、模块加载在后台跑 |
| 稍后 | 流开始推送事件 | setup 完成,内部流转发过来 |
| 失败时 | 流里出现 error 事件 | 不抛异常,错误编码为 stopReason “error” 的 AssistantMessage |
这解释了系列第一篇的疑问:“为什么返回值永远是事件流?“因为认证和模块加载是异步的,调用方拿到的必须是一个”先空后满”的流对象。配套的 lazyApi 让整个协议模块可以动态 import(openai-completions.lazy.ts 只有几行),首次调用才真正加载,启动开销因此小很多。
这是贯穿全包的契约:任何失败(认证、网络、模块加载)都编码为流里的 error 事件,绝不 throw。
🌉 站 6:compat,新旧架构的桥
compat.ts 开头自己承认了身份:
/** * Temporary compatibility entrypoint preserving the old global pi-ai API * surface... This module is deleted with the coding-agent ModelManager migration. */旧架构是”全局 api 注册表”(registerApiProvider / getApiProvider,按协议分发);新架构是”Models 集合”(createModels,按厂商分发)。compat 保留旧 API,但 streamSimple 会先看新集合:
// compat.ts:streamSimpleconst builtinProvider = getBuiltinProviderForModel(model); // 新集合优先if (builtinProvider) { return builtinProvider.streamSimple(model, context, withEnvApiKey(model, options));}const provider = resolveApiProvider(model.api); // 旧注册表兜底return provider.streamSimple(model, context, withEnvApiKey(model, options));这就是为什么 coding-agent 的 sdk.ts 里 setDefaultStreamFn(streamSimple) 能同时兼容两套体系。读 coding-agent 时见到 resetApiProviders()、provider-composer,都是新旧并存的过渡产物。看到 compat 的 import,知道它是迁移桥即可,新代码不学它。
💰 站 7:两个”值钱”的函数
calculateCost,分档计费
// models.ts:863export function calculateCost(model, usage) { for (const tier of model.cost.tiers ?? []) { if (inputTokens > tier.inputTokensAbove) rates = tier; // 用量跨档换费率 } // Anthropic 1h 缓存写入按 2x 输入价计费 const longWrite = usage.cacheWrite1h ?? 0; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;}成本计算放在 ai 包而不是应用层,说明”模型定价”是模型元数据的一部分。cost.tiers 支持阶梯定价(用量折扣),cacheWrite1h 是 Anthropic 特有的长缓存计费。各家差异收敛在数据(JSON)和这一个函数里。
clampThinkingLevel,思考档位归一化
pi 有统一的思考档位 off / minimal / low / medium / high / xhigh / max,但每家叫法不同:OpenAI 的 reasoningEffort 只有三档,Claude 是 budget tokens,Gemini 是 thinkingLevel。thinkingLevelMap 把统一档位映射到各家取值:
getSupportedThinkingLevels(model) { return EXTENDED_THINKING_LEVELS.filter((level) => { const mapped = model.thinkingLevelMap?.[level]; if (mapped === null) return false; // null = 明确不支持 if (level === "xhigh" || level === "max") return mapped !== undefined; // 高端位必须有映射 return true; });}clampThinkingLevel 再处理”用户要 high 但模型只支持到 medium”:先向上找,再向下找,找不到就 off。用户请求的档位和模型能力之间,永远由这一层兜底,各家 API 拿到的永远是合法值。
🧠 记忆锚点
| 概念 | 一句话 |
|---|---|
| Api / Provider / Model | 协议 / 厂商 / 产品数据,多对多组合 |
createProvider | 5 字段拼一个厂商;加厂商 5 行代码 |
envApiKeyAuth | 存储凭证优先,再扫环境变量,undefined = 未配置 |
| OAuth 双检锁 | 锁内二次检查才刷新,并发不重复刷新 |
CredentialStore.modify | 唯一写路径、串行化;接口在 ai 包,实现在宿主 |
lazyStream | 同步返回空流,认证/加载异步就绪,失败编码进流 |
flattenModelCatalog | 从 JSON 推导精确类型,改数据类型跟着变 |
| compat | 旧全局注册表到新 Models 集合的迁移桥,会删除 |
clampThinkingLevel | 统一思考档位与各家参数之间的兜底层 |
🧪 动手验证
- 对比标准与”非标”:读
providers/deepseek.ts(标准)和providers/cloudflare-workers-ai.ts(要 account id),看非标认证长什么样 - 数复用:
grep -rn "envApiKeyAuth("看多少厂商复用标准认证,多少自写 resolve - 看思考档位数据:跑
npm run generate-models生成数据文件后,打开providers/data/anthropic.json,看 claude 系列的thinkingLevelMap - 断点体验 lazyStream:在
lazyStream的 catch 打日志,拔网线发请求,观察错误如何变成流事件而不是异常
结尾
ai 包的骨架是三句话:数据行为分离,协议厂商分离,认证契约化。三句话撑起 40 个厂商和 9 种协议,新厂商 5 行代码接入,新协议只写一个适配器。
下一篇沿着 AgentTool 往下钻,看 read / bash / edit / write 这些工具具体怎么执行、怎么处理图片和大文件。工具是 agent 的”手”,是应用层最贴近用户的部分,也是扩展系统的主战场。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!



