Skip to content

Part II · 源码篇 / Ch 02

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

小A在目录柜台递出模型令牌,老Z核验认证后将其引向一个 Provider 通道,通道中流出串起光点的消息流。

本章导读: 源码路线第 ① 站(核心层)。你在 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 IDProviderundefined
getModels(id)Provider ID当前目录未知或 getModels 抛错时空数组
getModel(id, modelId)两个 IDModelundefined
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 的接口节选。删去的说明与可选目录刷新方法,不改变这里的调用关系。

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。”

text
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 合并;所示语句按原顺序保留。”

ts
// 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 的结果
apiKeyauth → 调用 options一个明确值
headersauth headers → 调用 headers → transformHeaders合并后的对象
envauth resolution → 调用 env合并后的对象或 undefined
baseUrl原 model → auth base URL原模型或复制的 requestModel
transformHeadersModels 专用在解构后不传 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 真正委派的位置 ​

ts
// 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。至少有三条边界路径应保留:

路径触发点在此层的可观察结果不应误推的结论
未知 ProviderrequireProvidersetup 抛 ModelsError("provider"),由 lazy stream 包成 error模型目录一定为空
未配置认证!resolutionModelsError("auth"),进入流 error密钥格式一定错误
header transform 抛错await transformHeaderssetup 失败,进入流 errorProvider SDK 已经发出请求
Provider 内部请求失败Provider.stream* 已返回或运行由 Provider 的事件语义报告失败必然被自动重试

createProvider 让工厂与自定义配置走同一分派 ​

“Provider 对象本身是谁造的?”

“createProvider。它的输入包括静态模型、ProviderAuth 与 api,v1.0.0 还可以给 images 和 classifiers;三者一个都不给,构造时直接抛错。这次固定快照的注释明确说,内置 Provider 工厂与 models.json 自定义 Provider 都经过这里;这能支持‘同一构造函数’这一事实,不能替代对每种自定义配置的验证。”老Z打开代码:

ts
// 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 身份和认证。”

ts
// 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 调用在后台继续,不以消费者开始迭代为前提。这也是后面理解取消和错误时序的钥匙。

源码走查 ​

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