Appearance
第19章 目录与户口——pi-ai
本章导读: 源码路线第 ① 站(核心层)。你在
pi-ai包,主要读models.ts、auth/resolve.ts和providers/index.ts。这一站要弄清楚的是:手里那个Model对象怎么一路走过认证合并、Provider 选择,最终变成一条流。
小a照着 16 章的地图,打开了 pi-ai。他信心满满地写了三行代码:拿一个模型、调 stream、收事件。结果第一行就卡住了——他拿着一个 Model 对象,却不知道它从哪来、要往哪去。
“老z,”他说,“我有一个 Model,可它就是不动。”
“‘有一个 Model’离‘发出请求’还差四步。”老z说,“找到运行时 Provider、解析认证、合并请求配置、按 API 类型挑中流实现。你要确认这些步骤的先后——尤其是不配置密钥时,到底在哪一层失败。”
以下源码事实均来自 pi-mono 提交 583f153d502aa8e958eefdb9af0fbd3344e68f95、@earendil-works/pi-ai 版本 0.83.0。本章只跟 ModelsImpl.stream/streamSimple 的请求前半段;不枚举厂商 URL、OAuth 页面或每个模型目录的生成过程。
先分清三种“模型集合”
小a:“那 Model 到底从哪来?我 getModel 一下不就得了?”
“先分清三种‘模型集合’,不然你会被 undefined 和异常搞晕。”老z打开 models.ts:
ModelsImpl 私有地持有 Map<string, Provider>。getModel(provider, id) 只是从同步的最后已知目录中找一个 Model;找不到返回 undefined。真正的流入口不接受“可选 Provider”,它会通过 requireProvider 抛出 ModelsError。
比喻:目录与户口
getModel像翻电话簿——查得到是undefined之外的结果,查不到就是没有。requireProvider像办业务必须出示户口——没有这个人,直接报错,而不是“查无此人地继续”。
“这两个行为不能混同:目录查找失败是查询结果,流请求缺少 Provider 是不可继续的请求错误。”
| 操作 | 输入 | 成功结果 | 失败/边界语义 |
|---|---|---|---|
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 |
小a:“既然 getModel 已经给了模型,为什么还要 requireProvider?”
“Model 是数据,可能来自缓存、配置或调用者手工构造;ModelsImpl 的 Provider Map 才是本次运行的可执行单元。”老z说,“两者可不同步,所以流入口需要重新验证。”
Provider 把运行时责任收在一处
下面是 packages/ai/src/models.ts 的接口节选。删去的说明与可选目录刷新方法不改变这里的调用关系。
ts
// packages/ai/src/models.ts,Provider(583f153d,节选)
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: Context, options?: ApiStreamOptions<T>,
): AssistantMessageEventStream;
streamSimple(
model: Model<TApi>, context: Context, options?: SimpleStreamOptions,
): AssistantMessageEventStream;
}“已确认事实是:Provider 同时拥有身份、认证定义、目录读取和两种流入口。stream 保留 API 类型参数,streamSimple 使用简化选项;两者的输出都是 AssistantMessageEventStream。”老z说,“从这个接口可以推断,上层循环不必知道某个厂商的 SDK 类型;不能推断所有 Provider 都支持同一组选项。”
“那 refreshModels? 和 filterModels? 呢?”小a指着接口里的可选成员问。
“前者恢复/刷新动态目录,后者在认证通过后按 credential 过滤目录。它们解释了‘目录里存在’和‘此凭据可用’是两件事;却不保证刷新能够联网成功。”
请求的精确调用序列
小a:“那一次 streamSimple 内部到底按什么顺序走?”
“ModelsImpl.stream 与 streamSimple 都同步返回 lazyStream,所以认证和 Provider 调用发生在异步 setup 中。”老z画了一次 streamSimple 的状态序列;stream 只有 ApiStreamOptions 的类型差异。
text
1. 调用者给 ModelsImpl.streamSimple(model, context, options)
2. lazyStream 立刻创建 outer AssistantMessageEventStream 并启动 setup
3. setup: requireProvider(model) 从 Map 取 Provider;没有则抛 ModelsError("provider")
4. setup: applyAuth(model, options)
4a. 再次 requireProvider,随后 getAuth(model, { apiKey, env })
4b. resolveProviderAuth 解析 store/环境/显式覆盖;没有结果则抛 ModelsError("auth")
4c. 合并认证、模型、调用选项的 headers、env、baseUrl
5. provider.streamSimple(requestModel, context, requestOptions)
6. lazyStream 转发 inner events;setup 拒绝时改写成统一 error event 与 error stopReason小a盯着第 3 步和第 4a 步:“requireProvider 出现了两次?是不是重复了?”
“看似重复,但第二次是 applyAuth 的自包含前置条件:这个私有函数即便被未来代码复用,也先验证 Provider。”老z说,“不能把它改写成‘只检查一次’的源码事实。”
“那 ModelsError 的 code,是不是就是给人看的分类?”小a又问。
“ModelsError 带 code 字段,可取值为 provider、auth、stream、oauth、model_source、model_validation。这一层里 requireProvider 抛 provider,applyAuth 抛 auth,dispatch 抛 stream。code 是给上层程序做分支的字段,不是错误消息本身——比如 applyAuth 的 message 里嵌的是 model.provider,requireProvider 的 message 是“Unknown provider”。因此按 message 字符串匹配做判断,这段源码并不能保证那些文本稳定。”
逐段读 applyAuth
“这是关键函数的原文短节选。为保持焦点,省略后续的 env、baseUrl 与 provider options 合并;所示语句按原顺序保留。”老z把代码拉到屏幕上:
ts
// packages/ai/src/models.ts,ModelsImpl.applyAuth(583f153d,节选)
this.requireProvider(model);
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
});
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 传作覆盖项。源码没有把 credential 内容写入 Model;getAuth 再通过 resolveProviderAuth(provider, credentials, authContext, overrides) 取得结果。”老z说,“因此,‘模型对象天然不含密钥’是较准确的说法;‘凭据一定安全’不是——因为 CredentialStore 的具体实现和宿主存储另有边界。”
“那认证失败呢?”小a问,“是返回一个匿名请求吗?”
“不是。第二段的失败不是返回匿名请求,也不是把空 key 交 SDK。resolution 为空就抛 ModelsError("auth", …);由于上层使用 lazyStream,调用者最终接到 error event,而不是这个 Promise 的同步异常。”
“第三段给出优先级的一部分:调用选项的 apiKey 胜过认证结果;mergeHeaders 以不区分大小写的键合并,后传入的 options header 覆盖已有值;transformHeaders 最后运行。紧接着的源码还合并 resolution.env 与 options.env,若认证给出 baseUrl 则复制模型成为 requestModel,并从 provider options 中剥掉 transformHeaders。所以 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 |
小a:“那这些字段的优先级,追到 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 呢?”小a又问。
“参与,且排在 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 真正委派的位置
ts
// packages/ai/src/models.ts,ModelsImpl.stream / streamSimple(583f153d,节选)
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
});
// streamSimple 的末行对应为:
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);小a:“那 complete 呢?是不是另一条网络实现?”
“complete 与 completeSimple 没有另一条网络实现:它们分别调用上述流,再等待 .result()。”老z说,“这说明‘完整结果’是消费流后的便利 API;不说明模型服务端不流式返回。”
“正常路径是 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 是谁造的?”小a问。
“createProvider 的输入包括静态模型、ProviderAuth 与 api。固定快照的注释明确说内置 Provider 工厂与 models.json 自定义 Provider 都经过这里;这能支持‘同一构造函数’这一事实,不能替代每种自定义配置的验证。”老z打开代码:
ts
// packages/ai/src/models.ts,createProvider(583f153d,节选)
const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? 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 身份和认证。代价是漏配只有首次请求该模型时才暴露。”
ts
// packages/ai/src/models.ts,createProvider 返回对象(583f153d,节选)
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——那这个错误什么时候才冒出来?”小a问。
“和认证错误一样,要等到消费者开始迭代。dispatch 在没有匹配 streams 时构造的 lazy stream,其 setup 会抛 ModelsError("stream", …),由 lazyStream 的 catch 转成一个 error event 结束。这个 Provider 对象在 createProvider 返回时是完好的,构造过程不抛错;漏配的 model.api 只有第一次被请求时才暴露。这就意味着目录刷新、getModel 查找这类只读操作不会被一个坏 api 字段打断——代价是坏配置藏到请求时才显形。”
目录刷新与请求认证不是同一次操作
小a:“那模型目录会不会自动刷新?刷新和发请求是一回事吗?”
“不是一回事。”老z说,“ModelsImpl.refresh 为带 refreshModels 的 Provider 并发执行刷新。它跳过没有刷新方法的静态 Provider;也会在 signal 已 aborted 时跳过尚未开始的 Provider。刷新结果是 { aborted, errors },而不是‘第一个失败就 reject 全部’的单一异常接口。若读取 credential、解析刷新 credential 或网络刷新失败,且 signal 尚未中止,错误按 Provider ID 放进 errors map。源码随后尝试 allowNetwork: false 的 cache restoration——这次恢复失败被明确视为 best-effort,保留原先认证/网络错误。”
| 情形 | 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。所以 availability 是带 credential 语境的视图,而非 Model 结构上的永久布尔属性。”
小a:“那认证解析器到底有哪些来源?”
“本章也未读取 resolveProviderAuth 的所有 credential source。它只确认该解析器在 getAuth 调用链上。若要审计密钥落盘、OAuth 刷新或环境变量优先级,应沿 auth 目录另行逐文件走查。”
小结
这一章回答的是:你手里那个 Model 对象,怎么一路变成一条真实的模型流。答案是它不直接跳向 HTTP,而是经过三层分派——ModelsImpl 先同步交出 lazy stream,消费时才定位 Provider,applyAuth 把认证合并进配置,再委派给 Provider.stream*;createProvider 则在 Provider 内部按 model.api 字段选择适配实现。
- 边界:未知 Provider、未配置认证、缺少 API 映射,三者各有不同的错误来源和恢复路径,不能混成同一个"模型调用失败"。本章未覆盖认证解析器的所有来源、动态模型刷新和各厂商 payload——那些属于 auth 目录的独立走查。
记住:请求的"出发"是分派链,不是一次调用。
stream先交出一个惰性流,真正的认证和 Provider 选择发生在消费时。这让你可以在不触发网络请求的情况下先拿到流句柄——也是后面理解取消和错误时序的钥匙。
源码走查
- 在
packages/ai/src/models.ts搜索class ModelsImpl;分别运行时阅读getModel与requireProvider,记录一个返回undefined、一个抛ModelsError的差异。 - 从
ModelsImpl.stream跟到lazyStream,再回到applyAuth;确认requireProvider在外层 setup 和applyAuth中各出现一次。 - 逐行检查
mergeHeaders与applyAuth的transformHeaders;用两个大小写不同的同名 header 推演哪个键保留,不要只看对象展开顺序。 - 阅读
createProvider的apiFor、dispatch和返回对象;构造一个不存在的model.api,确认错误被包装为 stream 而非构造函数立即抛出。 - 搜索
completeSimple;确认它调用streamSimple(...).result(),而没有单独的 Provider complete 方法。