Skip to content

第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 IDProviderundefined
getModels(id)Provider ID当前目录未知或 getModels 抛错时空数组
getModel(id, modelId)两个 IDModelundefined
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 的结果
apiKeyauth → 调用 options一个明确值
headersauth headers → 调用 headers → transformHeaders合并后的对象
envauth resolution → 调用 env合并后的对象或 undefined
baseUrl原 model → auth base URL原模型或复制的 requestModel
transformHeadersModels 专用在解构后不传 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。至少有三条边界路径应保留:”

路径触发点在此层的可观察结果不应误推的结论
未知 ProviderrequireProvidersetup 抛 ModelsError("provider"),由 lazy stream 包成 error模型目录一定为空
未配置认证!resolutionModelsError("auth"),进入流 error密钥格式一定错误
header transform 抛错await transformHeaderssetup 失败,进入流 errorProvider 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 选择发生在消费时。这让你可以在不触发网络请求的情况下先拿到流句柄——也是后面理解取消和错误时序的钥匙。

源码走查 ​

  1. 在 packages/ai/src/models.ts 搜索 class ModelsImpl;分别运行时阅读 getModel 与 requireProvider,记录一个返回 undefined、一个抛 ModelsError 的差异。
  2. 从 ModelsImpl.stream 跟到 lazyStream,再回到 applyAuth;确认 requireProvider 在外层 setup 和 applyAuth 中各出现一次。
  3. 逐行检查 mergeHeaders 与 applyAuth 的 transformHeaders;用两个大小写不同的同名 header 推演哪个键保留,不要只看对象展开顺序。
  4. 阅读 createProvider 的 apiFor、dispatch 和返回对象;构造一个不存在的 model.api,确认错误被包装为 stream 而非构造函数立即抛出。
  5. 搜索 completeSimple;确认它调用 streamSimple(...).result(),而没有单独的 Provider complete 方法。