Part II Chapter 2: The Catalog and the Household Register — pi-ai

Chapter guide: Stop ① on the source route (core layer). You are in the
pi-aipackage, reading mainlymodels.ts,auth/resolve.ts, andproviders/index.ts. What this stop has to make clear: how theModelobject in your hand walks through auth merging and Provider selection, and finally becomes a stream.
Xiao A opened pi-ai following the map in Part II Chapter 1, confident, and wrote three lines of code: get a model, call stream, collect events. He got stuck on the first line. He clearly had a Model object in hand, but he had no idea where it came from or where it was going.
"Lao Z," he said, "I have a Model, and it just won't move."
"'Having a Model' is still four steps away from 'sending a request'," Lao Z said. "Find the runtime Provider, resolve auth, merge the request configuration, pick the stream implementation by API type. We will walk it once in this chapter and get the order of each step straight — especially where exactly the failure happens when no key is configured."
The source facts below all come from the upstream pi tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32), @earendil-works/pi-ai version 1.0.0. This chapter follows only the first half of a request through ModelsImpl.stream / streamSimple. It does not enumerate vendor URLs, OAuth pages, or how each model catalog is generated, and it does not cover the image models, classifier models, or deferred responses added in v1.0.0.
First, tell apart the three kinds of "model collection"
"Where does a Model actually come from? I'd guess getModel just hands it over."
"Looking things up in the catalog does go through getModel, but don't rush to treat it as the ticket into the stream entry point, or undefined and exceptions will confuse you." Lao Z opened models.ts. "ModelsImpl privately holds a Map<string, Provider>. getModel(provider, id) only searches a Model from the last known catalog, synchronously, and returns undefined when nothing matches — that is a lookup result. The real stream entry point does not accept an optional Provider: if one is missing, requireProvider throws a ModelsError, which is a request error you cannot continue past. The two behaviours must not be conflated."
Metaphor: the catalog and the household register
getModelis like consulting a phone book: if the entry is there, you get something other thanundefined; if it isn't, there is no such entry.requireProvideris like a counter that must see your household register before doing business: if the person doesn't exist, it errors out on the spot. It never goes ahead "no such person, proceeding anyway".
| Operation | Input | Success result | Failure / boundary semantics |
|---|---|---|---|
getProvider(id) | Provider ID | Provider | undefined |
getModels(id) | Provider ID | current catalog | empty array when unknown or when getModels throws |
getModel(id, modelId) | two IDs | Model | undefined |
getAvailable() | optional Provider ID | authenticated, filtered catalog | unauthenticated ones do not enter the result; an auth read error rejects |
stream(model, …) | a Model carrying a provider | returns an event stream immediately | setup-time errors become the stream's error event |
getAuth(model, …) | Provider / model plus overrides | AuthResult | undefined when unknown or unconfigured |
"But getModel already gave me the model. What does requireProvider gain by looking the Provider up again?"
"Model is just data. It may come from a cache, from configuration, or be built by hand by the caller. The Provider Map inside ModelsImpl is the one that is actually executable in this run," Lao Z said. "The two can fall out of sync, so the stream entry point re-validates."
Provider collects runtime responsibility in one place
Start with the interface excerpt in packages/ai/src/models.ts. The removed comments and the optional catalog-refresh methods do not change the call relationships here.
// packages/ai/src/models.ts, Provider (v1.0.0, excerpt)
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;
}"First, what is confirmed: this interface covers identity, the auth definition, catalog reading, and both stream entry points at the same time. stream keeps an API type parameter, streamSimple uses simplified options, and both output an AssistantMessageEventStream," Lao Z said. "Note that the type of context is TranscriptContext, not the Context the caller passed in: ModelsImpl first uses normalizeContext to fold the system prompt and the tool declarations into the system message, and the Provider only ever sees the normalized transcript. From this you can infer that the upper loop need not know any vendor's SDK type. You cannot infer that every Provider supports the same set of options."
Among the optional members that were removed, v1.0.0 also added getAllModels, fetchDeferred / cancelDeferred, generateImages, and classify. The last two serve image models and classifier models, and the comments state plainly that they never reject — failures come back as a result object. This chapter follows only the stream for chat models.
Xiao A pointed at two optional members in the interface. "What about refreshModels? and filterModels?"
"The first restores or refreshes the dynamic catalog; the second filters the catalog by credential after authentication passes. Those two methods split 'present in the catalog' and 'usable with this credential' into two separate questions. Whether the refresh itself succeeds over the network — the interface guarantees nothing."
The exact call sequence of a request
Xiao A wanted to get ahead of him. "I'd guess auth comes first, then finding the Provider?"
"Backwards." Lao Z drew the state sequence for one streamSimple call; stream differs only in the type of ApiStreamOptions. "ModelsImpl.stream and streamSimple first normalize context synchronously, then return a lazyStream synchronously. Auth and the Provider call happen inside the asynchronous setup, and the setup — as soon as it enters, step 4 — gets the Provider first."
1. caller invokes ModelsImpl.streamSimple(model, context, options)
2. normalizeContext(context) yields a TranscriptContext
3. lazyStream immediately creates the outer AssistantMessageEventStream and starts setup
4. setup: requireChatProvider(model) first asserts it is a chat model, then takes the Provider from the Map; missing -> throw ModelsError("provider")
5. setup: applyAuth(model, options)
5a. requireProvider again, then getAuth(model, { apiKey, env, signal })
5b. resolveProviderAuth resolves store / environment / explicit overrides; no result -> throw ModelsError("auth")
5c. merge headers, env and baseUrl from auth, the model, and the call options
6. provider.streamSimple(requestModel, transcript, requestOptions)
7. lazyStream forwards the inner events; if setup rejects, it rewrites them into a single error event and an error stopReasonXiao A stared at step 4 and step 5a. "requireProvider shows up twice. Isn't that duplicated code?"
"It looks duplicated. The second time is actually applyAuth's self-contained precondition: even if this private function gets reused by other code later, it validates the Provider first." Lao Z said. "So the source does check twice. You must not read it as 'checked once'."
"Let me guess about ModelsError's code too — is it an error classification meant for humans to read?"
"Quite the opposite. code is the field upper-layer programs branch on; it is not the error message itself. It can take provider, auth, stream, oauth, model_source, model_validation. v1.0.0 moved the definition to utils/models-error.ts, and both auth/resolve.ts and models.ts re-export it. In this layer, requireProvider throws provider, applyAuth throws auth, and dispatch throws stream. The message text is a separate matter: applyAuth's message embeds model.provider, while requireProvider's message is "Unknown provider". If you want to branch by matching on the message string, this source does not guarantee that those texts stay stable."
Reading applyAuth paragraph by paragraph
"For the key function, look at the original text." Lao Z pulled the code onto the screen. "This is a short excerpt. To keep the focus, the later merging of env, baseUrl, and provider options is omitted; the statements shown are kept in their original order."
// packages/ai/src/models.ts, ModelsImpl.applyAuth (v1.0.0, excerpt)
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 ?? {});"The first block calls getAuth, passing the explicit apiKey and env as overrides. v1.0.0 also passes the caller's abort signal down, so auth resolution and OAuth refresh can both be cancelled; OAuth refresh has its own default 15-second timeout. The source does not write credential contents into the Model. getAuth then obtains its result through resolveProviderAuth(provider, credentials, authContext, overrides)." Lao Z said. "So 'the model object naturally contains no secret' is the fairly accurate statement. 'The credential is definitely safe' does not follow — the concrete CredentialStore implementation and the host's storage have boundaries of their own."
"Then what if auth resolves to nothing? Does it send an anonymous request, or hand an empty key to the SDK?"
"Neither. If resolution is empty, it throws ModelsError("auth", …). And because the layer above uses lazyStream, what the caller finally receives is an error event, not a synchronous exception from this Promise."
"The third block starts ordering precedence: the call option's apiKey beats the auth result; mergeHeaders merges by case-insensitive keys, and headers from the options passed in later overwrite existing values; transformHeaders runs last. The source right after it also merges resolution.env with options.env; if auth yields a baseUrl, the model is copied into requestModel; transformHeaders is stripped out of the provider options. In other words, transform is a capability consumed at the Models layer — it does not leak to the Provider as an unknown option."
| Config field | Source order (the later wins) | Result handed to the Provider |
|---|---|---|
apiKey | auth → call options | one definite value |
headers | auth headers → call headers → transformHeaders | the merged object |
env | auth resolution → call env | the merged object or undefined |
baseUrl | the original model → auth base URL | the original model or a copied requestModel |
transformHeaders | Models only | not passed to the Provider after destructuring |
"This table is only the applyAuth layer. Follow it into resolveProviderAuth — how is precedence ordered there?"
"applyAuth calls getAuth(model), and that ultimately delegates to resolveProviderAuth. The first priority is an explicit apiKey override: as long as options.apiKey is passed and the provider declares apiKey auth, it is used directly, and the stored credential is skipped entirely — even if you are already logged in. Only next does it read the stored credential, and the type must match (oauth against oauth, api_key against api_key); a mismatch returns undefined. Only when both come up empty does it fall back to ambient sources such as environment variables. The source comment is explicit: the stored credential owns the provider, and it does not quietly fall back to environment variables. After a refresh failure or a type mismatch there is likewise no silent env fallback."
"There is one more thing the table hasn't counted. What about the static headers the model itself carries?"
"They take part, and they rank after the auth result. Once getAuth(model) has the resolution, if the model carries headers, they are merged into the auth headers; applyAuth then lets options.headers overwrite. So the full order for one field is: auth resolution < model static headers < call options. From this you can infer: the same model object carries its provider identity into the catalog and carries its own headers out into the request, but it never holds a key — apiKey comes only from auth resolution or from the call options."
What this design buys is credential resolution and per-request overridable configuration concentrated in one place. What it costs is that a header transform function can observe the final headers, so the host must treat it as an extension point with security consequences. If a small program only supports a fixed endpoint, calling the SDK's configuration path directly is shorter, but you lose the unified interface across multiple Providers.
Where stream and streamSimple actually delegate
// packages/ai/src/models.ts, ModelsImpl.stream / streamSimple (v1.0.0, excerpt)
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>);
});
// the last line of streamSimple corresponds to:
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);"Then what about complete? Does it have a separate network implementation?"
"No. complete and completeSimple call exactly the stream above and merely wait on .result(). That only says 'the full result' is a convenience API on top of consuming the stream. It does not support inferring that a model server doesn't return streaming." Lao Z said.
The normal path is: the Provider exists, auth resolves to a result, the stream implementation is available, and then the Provider receives the processed model, context, and options. At least three boundary paths should be kept in mind:
| Path | Trigger point | Observable result at this layer | Conclusion you must not jump to |
|---|---|---|---|
| Unknown Provider | requireProvider | setup throws ModelsError("provider"), wrapped into an error by the lazy stream | the model catalog must be empty |
| Auth not configured | !resolution | ModelsError("auth"), entering the stream's error | the key format must be wrong |
| Header transform throws | await transformHeaders | setup fails, entering the stream's error | the Provider SDK has already sent the request |
| Request fails inside the Provider | Provider.stream* has returned or is running | reported by the Provider's own event semantics | the failure will necessarily be retried automatically |
createProvider puts the factory and custom configuration on the same dispatch
"Who creates the Provider object itself?"
"createProvider. Its inputs include static models, ProviderAuth, and api; v1.0.0 also allows images and classifiers. Give it none of the three and construction throws outright. The comment in this pinned snapshot says plainly that both the built-in Provider factories and the models.json custom Providers go through here. That supports the fact of 'one constructor'; it is not a substitute for validating each kind of custom configuration." Lao Z opened the code:
// packages/ai/src/models.ts, createProvider (v1.0.0, excerpt)
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);
};"This first decides whether input.api is a single ProviderStreams or a map keyed by model.api. When nothing matches, dispatch returns a lazy stream that will report an error, rather than rejecting the whole Provider at factory construction time. That way, a catalog with mixed APIs can share the provider identity and the authentication."
// packages/ai/src/models.ts, createProvider's returned object (v1.0.0, excerpt)
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),"Those two lines are the landing point for Provider.stream / streamSimple from the previous section. They do no authentication — authentication was already completed on the Models collection side. Whoever bypasses ModelsImpl and calls a Provider directly must satisfy its options contract on their own. That is a clear responsibility boundary created by the layering of the interface."
"One more detail. That error branch inside dispatch is also a lazyStream. When does this error actually surface?"
"As with an auth error, setup starts immediately when lazyStream is called, and a failure becomes an error event in the background; the consumer reads that event when it starts iterating. The lazy stream that dispatch constructs when there are no matching streams throws ModelsError("stream", …) in its setup, and lazyStream's catch turns it into one error event that ends the stream. So as long as at least one implementation is supplied, the Provider object createProvider returns is intact; a missing model.api mapping only shows up the first time that model is requested. Read-only operations such as catalog refresh and getModel lookups are not interrupted by one bad api field. The price is that a bad configuration stays hidden until a request exposes it."
Catalog refresh and request auth are not the same operation
"Does the model catalog refresh automatically? Are refreshing and sending a request the same thing?"
"They are not the same thing. ModelsImpl.refresh runs the refresh concurrently for every Provider that has refreshModels: it skips static Providers with no refresh method, and the options argument can also refresh only the ones you name. If the signal is already aborted, it returns directly. The refresh result is { aborted, errors }, not a single-exception interface that rejects everything on the first failure."
"Each Provider goes through two phases." Lao Z said. "The first phase reads the stored credential, then runs refreshModels once with allowNetwork: false, restoring state from the cached catalog — this step comes before authentication and before going online. Errors from reading the credential are held until the cache restore finishes. The second phase resolves a credential usable for refresh; if it resolves none, it stops at the cached catalog. It goes online to refresh only after resolving one. If any step fails and the signal has not aborted, the error goes into the errors map keyed by Provider ID."
"There is one more concurrency detail: each refresh start gives that Provider a new token and aborts the previous unfinished refresh; setProvider / deleteProvider do the same. A Provider publishes its new catalog through context.publish(), and the publish is discarded if the token doesn't match or the operation was already aborted. So a slow old refresh never overwrites a newer result."
| Situation | refresh behaviour | stream behaviour |
|---|---|---|
| Static Provider | not in the refreshable list | can go straight to the Provider stream |
| Dynamic catalog not refreshed | may still be empty, or the last catalog | the Model must still be supplied by the caller |
| refresh network failure | errors map; the restored cached catalog is kept | not automatically turned into a request retry |
| request auth not configured | whether refresh can recover depends on the implementation | applyAuth produces an auth error |
| signal aborted | returns an aborted state, records fewer errors | the Provider stream decides the final events |
Designed this way, a product can handle "show the selectable models" and "send this request" separately. The cost is that the UI must deal at once with a possibly stale catalog and a possibly failing request auth. Any conclusion of the form "if it's in the list, it must work" goes beyond this source.
One further detail: getAvailable applies filterModels only after authentication is confirmed, rather than simply handing back the last known catalog the way getModels does. In v1.0.0, the unsuffixed getModels, getModel, and getAvailable return chat models only; *OfType and getAll* are what include image and classifier models. So availability is a view that carries the credential context — not a permanent boolean property on the Model structure.
"Then the auth resolver's sources — how many are there in the end?"
"This chapter did not read all of resolveProviderAuth's credential sources. It only confirmed that it sits on getAuth's call chain. To audit key persistence on disk, OAuth refresh, or environment-variable precedence, you have to walk the auth directory file by file separately."
Summary
This chapter answered one thing: how the Model object in your hand turns, step by step, into a real model stream. It does not leap straight to HTTP. It passes through three layers of dispatch: ModelsImpl starts setup immediately when calling the lazy stream, locates the Provider and begins auth resolution, while returning the outer stream synchronously; applyAuth merges authentication into the configuration; then it delegates to Provider.stream*. Inside the Provider, createProvider picks the adapter implementation by the model.api field.
The boundaries also need separating: an unknown Provider, unconfigured auth, and a missing API mapping each have their own error source and recovery path. They must not be merged into one generic "model call failed". This chapter did not cover every source the auth resolver consults, dynamic model refresh, or each vendor's payload. Those belong to a separate walkthrough of the auth directory.
Key point
A request's "departure" is a dispatch chain, not one call: calling
streamimmediately starts setup, selects the Provider and begins asynchronous auth resolution while returning a stream handle synchronously; the Provider call continues in the background without waiting for the consumer to start iterating. This is also the key to understanding cancellation and error timing later.
Source Walkthrough
- Search for
class ModelsImplinpackages/ai/src/models.ts. ReadgetModelandrequireProviderside by side and record the difference: one returnsundefined, the other throwsModelsError. - Follow from
ModelsImpl.streamtonormalizeContextandlazyStream, then back toapplyAuth. Confirm thatrequireProviderappears once in the outer setup (viarequireChatProvider) and once insideapplyAuth. - Check
mergeHeadersand thetransformHeadersinapplyAuthline by line. Reason through which key survives for two headers with the same name but different capitalisation — don't just look at object spread order. - Read
createProvider'sapiFor,dispatch, and returned object. Construct amodel.apithat doesn't exist and confirm the error is wrapped into a stream rather than thrown immediately by the constructor. - Search for
completeSimple. Confirm it callsstreamSimple(...).result()and that no separate Provider complete method exists. - Read
ModelsImpl.refreshandrunProviderRefreshPhase. Write out theallowNetworkvalues and the order of the tworefreshModelscalls within one refresh.
