模型层的秘密:一个接口如何驯服 40 个厂商

3214 字
16 分钟
模型层的秘密:一个接口如何驯服 40 个厂商

模型层的秘密:一个接口如何驯服 40 个厂商#

🏭 40+ Providers

🔌 9 种协议

🔑 双通道认证

⚡ lazyStream

📚 Pi 源码学习系列

✅ 第 1 篇 · 已发布
一条消息的旅程:三层架构与双层循环
✅ 第 2 篇 · 本文
模型层:模型目录、认证与 provider 适配器
📝 第 3 篇 · 规划中
工具深潜:read / bash / edit / write 怎么执行
📝 第 4 篇 · 规划中
会话层:持久化、恢复与配置系统

系列第一篇里,我们沿着一条消息从上到下走了一遍。走到模型层时,我留了个悬念: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,整个文件就这些:

deepseek.ts
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 就是自己写的)。

反过来,一个厂商可以混用多种协议。createProviderapi 参数支持两种形态:

// models.ts:719
api: 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 ← 每个厂商的汇总文件
↓ import
src/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 行,值得全读):

model-catalog.ts
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(九成厂商用这个)#

auth/helpers.ts
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 的 dispatchmodel.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:streamSimple
const 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.tssetDefaultStreamFn(streamSimple) 能同时兼容两套体系。读 coding-agent 时见到 resetApiProviders()provider-composer,都是新旧并存的过渡产物。看到 compat 的 import,知道它是迁移桥即可,新代码不学它。

💰 站 7:两个”值钱”的函数#

calculateCost,分档计费#

// models.ts:863
export 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 是 thinkingLevelthinkingLevelMap 把统一档位映射到各家取值:

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协议 / 厂商 / 产品数据,多对多组合
createProvider5 字段拼一个厂商;加厂商 5 行代码
envApiKeyAuth存储凭证优先,再扫环境变量,undefined = 未配置
OAuth 双检锁锁内二次检查才刷新,并发不重复刷新
CredentialStore.modify唯一写路径、串行化;接口在 ai 包,实现在宿主
lazyStream同步返回空流,认证/加载异步就绪,失败编码进流
flattenModelCatalog从 JSON 推导精确类型,改数据类型跟着变
compat旧全局注册表到新 Models 集合的迁移桥,会删除
clampThinkingLevel统一思考档位与各家参数之间的兜底层

🧪 动手验证#

  1. 对比标准与”非标”:读 providers/deepseek.ts(标准)和 providers/cloudflare-workers-ai.ts(要 account id),看非标认证长什么样
  2. 数复用grep -rn "envApiKeyAuth(" 看多少厂商复用标准认证,多少自写 resolve
  3. 看思考档位数据:跑 npm run generate-models 生成数据文件后,打开 providers/data/anthropic.json,看 claude 系列的 thinkingLevelMap
  4. 断点体验 lazyStream:在 lazyStream 的 catch 打日志,拔网线发请求,观察错误如何变成流事件而不是异常

结尾#

ai 包的骨架是三句话:数据行为分离,协议厂商分离,认证契约化。三句话撑起 40 个厂商和 9 种协议,新厂商 5 行代码接入,新协议只写一个适配器。

下一篇沿着 AgentTool 往下钻,看 read / bash / edit / write 这些工具具体怎么执行、怎么处理图片和大文件。工具是 agent 的”手”,是应用层最贴近用户的部分,也是扩展系统的主战场。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

模型层的秘密:一个接口如何驯服 40 个厂商
https://rushzb-blog.pages.dev/posts/pi-model-layer/
作者
rushzb
发布于
2026-08-13
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
rushzb
初级 Vibe Coder
公告
欢迎浏览我的学习报告~
分类
标签
最新动态
站点统计
文章
6
动态
1
分类
1
标签
25
总字数
56,556
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.14.2
文章许可
CC BY-NC-SA 4.0