源码篇第2章 目录与户口——pi-ai

本章导读: 源码路线第 ① 站(核心层)。你在
pi-ai包,主要读models.ts、auth/resolve.ts和providers/index.ts。这一站要弄清楚的是:手里那个Model对象怎么一路走过认证合并、Provider 选择,最终变成一条流。
小A照着 源码篇第1章的地图打开了 pi-ai,信心满满地写下三行代码:拿一个模型、调 stream、收事件。结果卡在第一行:手里明明有一个 Model 对象,他却不知道它从哪来、要往哪去。
“老Z,”他说,“我有一个 Model,可它就是不动。”
“‘有一个 Model’离‘发出请求’还差四步。”老Z说,“找到运行时 Provider、解析认证、合并请求配置、按 API 类型挑中流实现。这一章就跟着走一遍,把每步的先后看清楚,尤其是不配置密钥时,到底在哪一层失败。”
以下源码事实均来自上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)、@earendil-works/pi-ai 版本 1.0.0。本章只跟 ModelsImpl.stream/streamSimple 的请求前半段;不枚举厂商 URL、OAuth 页面或每个模型目录的生成过程,也不展开 v1.0.0 新增的图像模型、分类器模型和 deferred 响应。
先分清三种“模型集合”
“Model 到底从哪来?我猜 getModel 一下就有了。”
“查目录确实靠 getModel,但别急着把它当成流入口的门票,不然你会被 undefined 和异常搞晕。”老Z打开 models.ts,“ModelsImpl 私有地持有 Map<string, Provider>。getModel(provider, id) 只是从同步的最后已知目录中找一个 Model,找不到返回 undefined,这是查询结果;真正的流入口不接受‘可选 Provider’,缺了会经 requireProvider 抛出 ModelsError,那是不可继续的请求错误。两个行为不能混同。”
比喻:目录与户口
getModel像翻电话簿:查得到,拿到的就是undefined之外的结果;查不到,就是没有。requireProvider像办业务必须出示户口:没有这个人,直接报错,不会“查无此人”地继续办下去。
| 操作 | 输入 | 成功结果 | 失败/边界语义 |
|---|---|---|---|
getProvider(id) | Provider ID | Provider | undefined |
getModels(id) | Provider ID | 当前目录 | 未知或 getModels 抛错时空数组 |
getModel(id, modelId) | 两个 ID | Model | undefined |
getAvailable() | 可选 Provider ID | 已认证且过滤后的目录 | 未认证的不进入结果;认证读取错误会拒绝 |
stream(model, …) | 带 provider 的 Model | 立即返回事件流 | 设置期错误变成流的 error event |
getAuth(model, …) | Provider/模型与覆盖项 | AuthResult | 未知/未配置时 undefined |
“可 getModel 不是已经把模型给我了吗,requireProvider 再查一遍 Provider,图什么?”
“Model 只是数据,可能来自缓存、配置,也可能是调用者手工构造的;ModelsImpl 的 Provider Map 才是本次运行真正可执行的那一份。”老Z说,“两边可能不同步,所以流入口要重新验证。”
Provider 把运行时责任收在一处
先看 packages/ai/src/models.ts 的接口节选。删去的说明与可选目录刷新方法,不改变这里的调用关系。
// packages/ai/src/models.ts,Provider(v1.0.0,节选)
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly auth: ProviderAuth;
getModels(): readonly Model<TApi>[];
stream<T extends TApi>(
model: Model<T>, context: TranscriptContext, options?: ApiStreamOptions<T>,
): AssistantMessageEventStream;
streamSimple(
model: Model<TApi>, context: TranscriptContext, options?: SimpleStreamOptions,
): AssistantMessageEventStream;
}“先说已确认的:这个接口同时管着身份、认证定义、目录读取和两种流入口,stream 保留 API 类型参数,streamSimple 使用简化选项,两者的输出都是 AssistantMessageEventStream。”老Z说,“注意 context 的类型是 TranscriptContext,不是调用者传进来的 Context:ModelsImpl 先用 normalizeContext 把 system prompt 和工具声明并进 system message,Provider 只看规范化后的 transcript。从它能推断的是,上层循环不必知道某个厂商的 SDK 类型;不能推断的是,所有 Provider 都支持同一组选项。”
删去的可选成员里,v1.0.0 还加了 getAllModels、fetchDeferred/cancelDeferred、generateImages 和 classify。后两个服务图像模型和分类器模型,注释写明它们从不 reject,失败以结果对象返回。本章只跟聊天模型的流。
小A指着接口里的两个可选成员:“那 refreshModels? 和 filterModels? 呢?”
“前者恢复/刷新动态目录,后者在认证通过后按 credential 过滤目录。这两个方法把‘目录里存在’和‘此凭据可用’分成了两件事;至于刷新本身能不能联网成功,接口不保证。”
请求的精确调用序列
小A想抢个先:“我猜是先进认证,再找 Provider?”
“反了。”老Z画了一次 streamSimple 的状态序列;stream 只有 ApiStreamOptions 的类型差异。“ModelsImpl.stream 和 streamSimple 先同步规范化 context,再同步返回 lazyStream,认证和 Provider 调用发生在异步 setup 里,setup 一进来(第 4 步)就先取 Provider。”
1. 调用者给 ModelsImpl.streamSimple(model, context, options)
2. normalizeContext(context) 得到 TranscriptContext
3. lazyStream 立刻创建 outer AssistantMessageEventStream 并启动 setup
4. setup: requireChatProvider(model) 先断言是聊天模型,再从 Map 取 Provider;没有则抛 ModelsError("provider")
5. setup: applyAuth(model, options)
5a. 再次 requireProvider,随后 getAuth(model, { apiKey, env, signal })
5b. resolveProviderAuth 解析 store/环境/显式覆盖;没有结果则抛 ModelsError("auth")
5c. 合并认证、模型、调用选项的 headers、env、baseUrl
6. provider.streamSimple(requestModel, transcript, requestOptions)
7. lazyStream 转发 inner events;setup 拒绝时改写成统一 error event 与 error stopReason小A盯着第 4 步和第 5a 步:“requireProvider 出现了两次,是不是写重复了?”
“看着重复,其实第二次是 applyAuth 的自包含前置条件:这个私有函数将来即便被别的代码复用,也先验证 Provider。”老Z说,“所以源码里就是查两次,不能把它读成‘只检查一次’。”
“ModelsError 的 code 我也猜一下,是给人看的错误分类?”
“恰恰相反,code 是给上层程序做分支的字段,不是错误消息本身。它可取 provider、auth、stream、oauth、model_source、model_validation,v1.0.0 把定义移到了 utils/models-error.ts,auth/resolve.ts 和 models.ts 都转出它;这一层里 requireProvider 抛 provider,applyAuth 抛 auth,dispatch 抛 stream。消息文本是另一回事:applyAuth 的 message 里嵌的是 model.provider,requireProvider 的 message 是“Unknown provider”。想按 message 字符串匹配做判断,这段源码并不能保证那些文本稳定。”
逐段读 applyAuth
“关键函数看原文。”老Z把代码拉到屏幕上,“这里是短节选,为保持焦点,省略后续的 env、baseUrl 与 provider options 合并;所示语句按原顺序保留。”
// packages/ai/src/models.ts,ModelsImpl.applyAuth(v1.0.0,节选)
this.requireProvider(model);
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
signal: options?.signal,
});
if (!resolution) {
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
}
const auth = resolution.auth;
const apiKey = options?.apiKey ?? auth.apiKey;
let headers = mergeHeaders(auth.headers, options?.headers);
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});“第一段调用 getAuth,把显式 apiKey、env 传作覆盖项,v1.0.0 还把调用者的 abort signal 一并传下去,认证解析和 OAuth 刷新都能被取消;OAuth 刷新另有 15 秒的默认超时。源码没有把 credential 内容写入 Model;getAuth 再通过 resolveProviderAuth(provider, credentials, authContext, overrides) 取得结果。”老Z说,“所以‘模型对象天然不含密钥’是较准确的说法;‘凭据一定安全’推不出来,CredentialStore 的具体实现和宿主存储另有边界。”
“那认证解析不到呢?是返回一个匿名请求,还是把空 key 丢给 SDK?”
“都不是。resolution 为空就抛 ModelsError("auth", …);又因为上层用的是 lazyStream,调用者最终接到的是 error event,而不是这个 Promise 的同步异常。”
“第三段开始排优先级:调用选项的 apiKey 胜过认证结果;mergeHeaders 以不区分大小写的键合并,后传入的 options header 覆盖已有值;transformHeaders 最后运行。紧接着的源码还合并 resolution.env 与 options.env;认证若给出 baseUrl,就复制模型成为 requestModel;transformHeaders 则从 provider options 里剥掉。也就是说,transform 是 Models 层就消费掉的能力,不会作为未知选项泄漏给 Provider。”
| 配置字段 | 来源次序(后者优先) | 交给 Provider 的结果 |
|---|---|---|
apiKey | auth → 调用 options | 一个明确值 |
headers | auth headers → 调用 headers → transformHeaders | 合并后的对象 |
env | auth resolution → 调用 env | 合并后的对象或 undefined |
baseUrl | 原 model → auth base URL | 原模型或复制的 requestModel |
transformHeaders | Models 专用 | 在解构后不传 Provider |
“这张表只是 applyAuth 这一层。追到 resolveProviderAuth 里,优先级又是怎么排的?”
“applyAuth 调 getAuth(model),后者最终委托 resolveProviderAuth。第一优先是显式 apiKey 覆盖:只要传了 options.apiKey 且 provider 声明了 apiKey 认证,就直接用它,完全跳过存储的 credential,哪怕你已经登录过。其次才读存储的 credential,且类型必须匹配(oauth 对 oauth、api_key 对 api_key),不匹配就返回 undefined;都落空,才回到环境变量这类 ambient 来源。源码注释写得很明确,stored credential 拥有 provider,不会悄悄回退环境变量;刷新失败或类型不匹配之后,也不存在静默的 env fallback。”
“还有一处没算进去,model 自带的静态 headers 呢?”
“参与,且排在 auth 结果之后。getAuth(model) 拿到 resolution 后,若 model 带 headers,就把它们并入 auth headers;applyAuth 再用 options.headers 覆盖。所以一个字段的完整次序是 auth 解析 < model 静态 headers < 调用 options。由此可以推出:同一个 model 对象,既带着 provider 身份进目录,又带着自己的 header 出请求;但它始终不持有密钥,apiKey 只来自 auth 解析或调用选项。”
这样设计的收益,是把 credential 解析和每次请求的可覆盖配置集中到一处;代价是 header 变换函数能观察最终 header,宿主必须把它当作有安全影响的扩展点。若小程序只支持固定 endpoint,直接调用 SDK 的配置路径更短,但会丢失多 Provider 的统一接口。
stream 和 streamSimple 真正委派的位置
// packages/ai/src/models.ts,ModelsImpl.stream / streamSimple(v1.0.0,节选)
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);
});
// streamSimple 的末行对应为:
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);“那 complete 呢?它是不是另有一条网络实现?”
“没有。complete 与 completeSimple 调用的就是上面这条流,再等待 .result() 而已。这只说明‘完整结果’是消费流后的便利 API,推不出模型服务端不流式返回。”老Z说。
正常路径是 Provider 存在、认证解析到结果、流实现可用,随后 Provider 获得已处理的 model、context 和 options。至少有三条边界路径应保留:
| 路径 | 触发点 | 在此层的可观察结果 | 不应误推的结论 |
|---|---|---|---|
| 未知 Provider | requireProvider | setup 抛 ModelsError("provider"),由 lazy stream 包成 error | 模型目录一定为空 |
| 未配置认证 | !resolution | ModelsError("auth"),进入流 error | 密钥格式一定错误 |
| header transform 抛错 | await transformHeaders | setup 失败,进入流 error | Provider SDK 已经发出请求 |
| Provider 内部请求失败 | Provider.stream* 已返回或运行 | 由 Provider 的事件语义报告 | 失败必然被自动重试 |
createProvider 让工厂与自定义配置走同一分派
“Provider 对象本身是谁造的?”
“createProvider。它的输入包括静态模型、ProviderAuth 与 api,v1.0.0 还可以给 images 和 classifiers;三者一个都不给,构造时直接抛错。这次固定快照的注释明确说,内置 Provider 工厂与 models.json 自定义 Provider 都经过这里;这能支持‘同一构造函数’这一事实,不能替代对每种自定义配置的验证。”老Z打开代码:
// packages/ai/src/models.ts,createProvider(v1.0.0,节选)
const single =
input.api && typeof (input.api as ProviderStreams).stream === "function"
? (input.api as ProviderStreams)
: undefined;
const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
const dispatch = (model, run) => {
const streams = apiFor(model);
if (!streams) {
return lazyStream(model, async () => {
throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
});
}
return run(streams);
};“这里先判断 input.api 是单一的 ProviderStreams,还是以 model.api 为键的映射;dispatch 在没有匹配项时,返回的是一个会报错的 lazy stream,而不是在工厂构造时就拒绝整个 Provider。这样,一个混合 API 的目录就能共享 provider 身份和认证。”
// packages/ai/src/models.ts,createProvider 返回对象(v1.0.0,节选)
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),“这两行就是上节 Provider.stream/streamSimple 的落点。它们没有做认证,认证已经在 Models 集合侧完成。谁绕过 ModelsImpl 直接调用一个 Provider,谁就得自行满足其 options 契约,这是接口分层带来的明确责任边界。”
“还有个细节,dispatch 里那个报错分支也是 lazyStream,这个错误要等到什么时候才冒出来?”
“和认证错误一样,setup 在调用 lazyStream 时立即启动,失败在后台转成 error event;消费者开始迭代时才读到该事件。dispatch 在没有匹配 streams 时构造的 lazy stream,其 setup 会抛 ModelsError("stream", …),由 lazyStream 的 catch 转成一个 error event 结束。所以只要给了至少一种实现,createProvider 返回时这个 Provider 对象是完好的;漏配的 model.api 要到第一次被请求才暴露。目录刷新、getModel 查找这类只读操作,不会被一个坏 api 字段打断,代价就是坏配置一直藏到请求时才显形。”
目录刷新与请求认证不是同一次操作
“模型目录会自动刷新吗?刷新和发请求,是一回事吗?”
“不是一回事。ModelsImpl.refresh 为带 refreshModels 的 Provider 并发执行刷新:跳过没有刷新方法的静态 Provider,providers 选项还能只刷新指定的几个。signal 已 aborted 时直接返回。刷新结果是 { aborted, errors },不是‘第一个失败就 reject 全部’的单一异常接口。”
“每个 Provider 分两段走。”老Z说,“第一段先读存储的 credential,再以 allowNetwork: false 跑一次 refreshModels,用缓存目录恢复状态,这一步在认证和联网之前。读 credential 失败的错误要等缓存恢复后才抛出。第二段解析可用于刷新的 credential,解析不到就停在缓存目录;解析到才联网刷新。任一步失败且 signal 尚未中止,错误按 Provider ID 放进 errors map。”
“还有一个并发细节:每次刷新开始都给该 Provider 换一个新的代号,并 abort 上一次未完成的刷新;setProvider/deleteProvider 也会这样做。Provider 通过 context.publish() 发布新目录,发布时代号不对或已中止就丢弃。所以慢的旧刷新不会覆盖新结果。”
| 情形 | refresh 行为 | stream 行为 |
|---|---|---|
| 静态 Provider | 不在 refreshable 列表 | 可直接走 Provider stream |
| 动态目录未刷新 | 可能仍是空或上次目录 | Model 仍须由调用者提供 |
| refresh 网络失败 | errors map,保留已恢复的缓存目录 | 不是自动转为请求重试 |
| request auth 未配置 | refresh 是否可恢复取决于实现 | applyAuth 产生 auth error |
| signal aborted | 返回 aborted 状态、少记错误 | Provider 流具体决定最终事件 |
这样设计,产品可以把“展示可选模型”与“发送当前请求”分开处理;代价是 UI 必须同时面对目录可能过期和请求认证可能失败。任何“列表里看得到就一定能用”的结论,都超出了这段源码。
另一个细节是 getAvailable 在认证确认后才应用 filterModels,不像 getModels 那样单纯给最后已知 catalog。v1.0.0 里不带后缀的 getModels、getModel、getAvailable 只返回聊天模型;*OfType 和 getAll* 才包括图像和分类器模型。所以 availability 是带 credential 语境的视图,不是 Model 结构上的永久布尔属性。
“那认证解析器的来源,到底有哪几种?”
“这一章没有读全 resolveProviderAuth 的所有 credential source,只确认它在 getAuth 调用链上。要审计密钥落盘、OAuth 刷新或环境变量优先级,得沿 auth 目录另行逐文件走查。”
小结
这一章回答的是:你手里那个 Model 对象,怎么一路变成一条真实的模型流。它不直接跳向 HTTP,而是经过三层分派:ModelsImpl 调用 lazy stream 时立即启动 setup,定位 Provider 并开始认证解析,同时同步交出外层流,applyAuth 把认证合并进配置,再委派给 Provider.stream*;createProvider 则在 Provider 内部按 model.api 字段选择适配实现。
边界也要分清:未知 Provider、未配置认证、缺少 API 映射,三者各有不同的错误来源和恢复路径,不能混成同一个“模型调用失败”。本章未覆盖认证解析器的所有来源、动态模型刷新和各厂商 payload,那些属于 auth 目录的独立走查。
要点
请求的“出发”是分派链,不是一次调用:
stream调用时立即启动 setup,选择 Provider 并开始异步认证,同时同步返回流句柄;后续 Provider 调用在后台继续,不以消费者开始迭代为前提。这也是后面理解取消和错误时序的钥匙。
源码走查
- 在
packages/ai/src/models.ts搜索class ModelsImpl;分别运行时阅读getModel与requireProvider,记录一个返回undefined、一个抛ModelsError的差异。 - 从
ModelsImpl.stream跟到normalizeContext与lazyStream,再回到applyAuth;确认requireProvider在外层 setup(经requireChatProvider)和applyAuth中各出现一次。 - 逐行检查
mergeHeaders与applyAuth的transformHeaders;用两个大小写不同的同名 header 推演哪个键保留,不要只看对象展开顺序。 - 阅读
createProvider的apiFor、dispatch和返回对象;构造一个不存在的model.api,确认错误被包装为 stream 而非构造函数立即抛出。 - 搜索
completeSimple;确认它调用streamSimple(...).result(),而没有单独的 Provider complete 方法。 - 阅读
ModelsImpl.refresh与runProviderRefreshPhase;写出一次刷新里两次调用refreshModels的allowNetwork取值和先后。
