packages/agent/docs/models.md
This document describes the target design for the next pi-ai model/provider refactor. It describes the desired shape, not the current implementation. It is intended to be complete enough to start implementing from a fresh session.
Goals:
Models is a dumb runtime collection of providers.src/api/ and are reusable/lazy.src/providers/.refresh.models.json and extensions layer by wrapping providers, not by mutating provider internals ad hoc./compat entrypoint.Non-goals for the immediate pi-ai pass:
ModelRegistry yet.Models.ImagesModels/ImagesProvider in images-models.ts); the old global image API (images.ts, images-api-registry.ts) lives on compat.Target source layout:
packages/ai/src/
index.ts # core exports only; no built-in provider imports
models.ts # Models runtime, Provider
images-models.ts # ImagesModels runtime, ImagesProvider (mirrors models.ts)
compat.ts # temporary old-API compatibility entrypoint
auth/ # auth method types, helpers, shared resolveProviderAuth(), login callbacks
api/ # API implementations and lazy wrappers
openai-completions.ts # real implementation, imports SDKs, exports stream/streamSimple
openai-completions.lazy.ts
openai-responses.ts
openai-responses.lazy.ts
openai-codex-responses.ts
openai-codex-responses.lazy.ts
azure-openai-responses.ts
azure-openai-responses.lazy.ts
anthropic-messages.ts
anthropic-messages.lazy.ts
google-generative-ai.ts
google-generative-ai.lazy.ts
google-vertex.ts
google-vertex.lazy.ts
mistral-conversations.ts
mistral-conversations.lazy.ts
bedrock-converse-stream.ts
bedrock-converse-stream.lazy.ts
openrouter-images.ts # image-generation API implementation
openrouter-images.lazy.ts
lazy.ts # lazyStream()/lazyApi() helpers
(shared helpers: openai-responses-shared, google-shared, transform-messages, ...)
providers/ # concrete provider factories and per-provider catalogs
openai.ts
openai.models.ts # generated OpenAI catalog
openai-codex.ts
openai-codex.models.ts
anthropic.ts
anthropic.models.ts
google.ts
google.models.ts
...one pair per built-in provider...
openrouter-images.ts # image-generation provider factory
faux.ts # test provider factory
all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*()
utils/oauth/ # OAuth flow implementations (node), lazy-loaded
src/index.ts must stay core-only. It must not import:
providers/allcompatProvider, API, and compat entrypoints are explicit subpath exports.
Minimal provider usage:
import { createModels } from "@earendil-works/pi-ai";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
const models = createModels();
models.setProvider(openaiProvider());
const model = models.getModel("openai", "gpt-4o-mini");
if (!model) throw new Error("model not found");
const response = await models.complete(model, context);
Multiple providers:
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(openrouterProvider());
All built-ins, explicitly heavy metadata entrypoint:
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
const models = builtinModels();
providers/all may import all provider metadata/catalogs. It still must not eagerly import SDK implementations; provider streams use lazy wrappers.
Models is a provider collection plus auth application and stream convenience. No stream registry, no auth resolver strategy object.
export function createModels(options?: {
/** App-owned credential storage. Default: in-memory store. */
credentials?: CredentialStore;
/** Environment access for auth resolution (env vars, file existence). Default: process.env/node:fs backed; injectable for tests and non-Node hosts. */
authContext?: AuthContext;
}): MutableModels;
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
/** Sync read of last-known models. Best-effort: a provider whose getModels() throws yields no models. */
getModels(provider?: string): readonly Model<Api>[];
/** Dynamic lists are honestly Model<Api>; narrow with the hasApi() guard. */
getModel(provider: string, id: string): Model<Api> | undefined;
/**
* Ask dynamic providers to re-fetch their model lists. With a provider id,
* rejects on that provider's failure; without, refreshes all concurrently
* best-effort. Static providers are no-ops.
*/
refresh(provider?: string): Promise<void>;
/**
* Resolve request auth for a model. Includes source label for status UI.
* Resolves undefined when the provider is unknown or unconfigured. Rejects
* with ModelsError ("oauth" on refresh failure, "auth" on api-key/store
* failure); status/availability UIs catch rejections and render
* "needs re-login" instead of treating them as unconfigured.
*/
getAuth(model: Model<Api>): Promise<AuthResult | undefined>;
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
): AssistantMessageEventStream;
complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>;
}
export interface MutableModels extends Models {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: Provider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}
Removed concepts:
no Models.setStreamFunctions() / getStreamFunctions()
no api-registry as a real dispatch mechanism
no Models.provider(id) builder, no setModel/upsertModel/patchModel lifecycle
no ModelAuthResolver / setAuthResolver — resolution policy is fixed, store is injected
If an app needs different auth policy, it wraps providers (wrap auth methods or getModels) or passes explicit request auth in stream options.
A provider is the concrete runtime unit. It owns id/name/base metadata, auth methods, model listing, and stream behavior.
Provider is generic over the APIs its models use. Concrete factories declare what they emit (openaiProvider(): Provider<"openai-responses" | "openai-completions">), giving typed model lists to direct factory users. A Models collection holds providers as Provider<Api>.
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: Record<string, string>;
/**
* Required: at least one of apiKey/oauth. Even ambient-credential providers
* (env vars, AWS profiles, ADC) and keyless local servers provide apiKey
* auth whose resolve() reports whether the provider is configured.
* getAuth() returning undefined = not configured.
*/
readonly auth: ProviderAuth;
/** Current known models, sync. Static providers: the catalog. Dynamic providers: as of the last refresh (empty before the first). */
getModels(): readonly Model<TApi>[];
/** Dynamic providers only: fetch and update the model list. Concurrent calls share one in-flight fetch. */
refreshModels?(): Promise<void>;
stream<T extends TApi>(model: Model<T>, context: Context, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
There is no Provider.api field. model.api carries API identity; the provider dispatches internally (see createProvider()).
Model.api remains: existing metadata and tests use it, it is useful for diagnostics, and provider construction uses it for API implementation selection. But Models never dispatches on it; the provider does.
Full stream options are API-specific. Model<TApi> pays off by deriving the option type from the API:
// types.ts — type-only imports from API impl modules are erased, so this is tree-shake safe
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
"openai-completions": OpenAICompletionsOptions;
"openai-responses": OpenAIResponsesOptions;
"openai-codex-responses": OpenAICodexResponsesOptions;
"azure-openai-responses": AzureOpenAIResponsesOptions;
"google-generative-ai": GoogleOptions;
"google-vertex": GoogleVertexOptions;
"mistral-conversations": MistralOptions;
"bedrock-converse-stream": BedrockOptions;
}
export type ApiStreamOptions<TApi extends Api> = TApi extends keyof ApiOptionsMap
? ApiOptionsMap[TApi]
: StreamOptions & Record<string, unknown>;
Custom api strings fall back to the generic shape.
Runtime model lists are dynamic, so models.getModel()/getModels() honestly return Model<Api>. Typing improves at three points:
hasApi() type guard — runtime-checked narrowing for dynamic lookups (no blind casts):
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi>;
const model = models.getModel("anthropic", "claude-opus-4-7");
if (model && hasApi(model, "anthropic-messages")) {
// model: Model<"anthropic-messages">, stream options fully typed
}
getBuiltinModel() — sync, generated-catalog lookup with typed overloads: (provider, id) -> Model<exact-api-literal>. The path for hardcoded known models.
Provider<TApi> factories — typed model lists when using a provider directly, without a Models collection.
Deliberately not done: tying models.getModel(provider, ...) to typed provider/model ids would require statically knowing which providers are installed in a mutable runtime collection. The harness path (streamSimple + SimpleStreamOptions) is API-agnostic and unaffected.
For comparison: Vercel AI SDK attaches the implementation to the model object, which dissolves dispatch typing but makes models non-serializable (no sessions/RPC/catalogs as plain data), and its providerOptions bag is Record<string, JSON> checked only by satisfies convention. Plain-data models + provider-owned behavior keeps stronger typing where it matters.
types.ts currently exports type Provider = KnownProvider | string (a provider id). Rename that alias to ProviderId and fix call sites. The Provider interface above takes the name.
Reads are sync; fetching is an explicit async verb. Provider.getModels() returns the current known list — the full catalog for static providers, the last-refreshed list for dynamic ones (llama.cpp, OpenRouter live listing). refreshModels() is where dynamic providers fetch.
This split exists because a sync-or-async union (Promise<T> | T) invites latent sync assumptions that detonate on the first async provider, while async-only reads force every consumer (UI lists, extension find/getAll surfaces) through Promises for data that is almost always static. Sync reads + explicit refresh keeps the staleness visible and the contract single: getModels() = last known, refresh() = make it current. A fetched list is stale the moment it returns anyway; naming the refresh point is honest about it.
Apps own the refresh lifecycle: startup, registry reload, opening a model selector. Freshness-critical lookups are two-step: await models.refresh("llamacpp"); models.getModel("llamacpp", id).
Dynamic refresh must be side-effect-free discovery:
OK: fetch /v1/models, enumerate local catalog, refresh cached remote model list
Not OK: load model, download model, mutate server state, run request probe
Provider-specific model lifecycle (load/unload) belongs in app/provider-management commands, not in refreshModels().
Models.stream() finds the provider by model.provider, resolves auth, merges it into request options, and delegates:
function stream(model, context, options) {
const provider = this.getProvider(model.provider);
if (!provider) {
// produce an error stream, not a throw — see Error behavior
}
// async setup happens inside the returned stream (lazyStream pattern)
const resolution = await this.getAuth(model);
const requestModel = resolution?.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
const requestOptions = mergeAuth(options, resolution?.auth); // explicit options win per-field
return provider.stream(requestModel, context, requestOptions);
}
stream() returns AssistantMessageEventStream synchronously; async setup (auth resolution, lazy module load) happens inside the returned stream. The forwarding pattern already exists in today's register-builtins.ts (createLazyStream); extract it as lazyStream() in src/api/lazy.ts.
No request hot-path model canonicalization: stream() uses the supplied model object as-is. If an app wants fresh model metadata, it refreshes the provider and re-reads (await models.refresh(p); models.getModel(p, id)) before starting the turn.
src/apiAn API implementation is reusable stream behavior. It is not a provider.
Uniform export contract — every real implementation module exports exactly:
// src/api/anthropic-messages.ts — imports SDKs
export function stream(model, context, options) { ... }
export function streamSimple(model, context, options) { ... }
This makes the module itself satisfy ProviderStreams, so the lazy wrapper is one generic helper instead of bespoke per-API plumbing. ProviderStreams is the untyped dispatch shape (implementation modules export concretely typed functions, which would not be assignable to a generic method); per-API option typing lives on the modules themselves and on Provider.stream() via ApiStreamOptions:
export interface ProviderStreams {
stream(model: Model<Api>, context: Context, options?: StreamOptions): AssistantMessageEventStream;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
// src/api/lazy.ts
export function lazyApi(load: () => Promise<ProviderStreams>): ProviderStreams;
// src/api/anthropic-messages.lazy.ts
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));
Import chain:
provider module -> lazy API wrapper -> dynamic import(real API impl) -> SDK deps
Notes:
importNodeOnlyProvider, .ts/.js specifier rewrite) inside its lazy wrapper. setBedrockProviderModule() (used by the Bun build) moves into the bedrock lazy wrapper module.openai-responses-shared.ts, google-shared.ts, transform-messages.ts, prompt-cache, copilot headers) move to src/api/ alongside the implementations.Many concrete providers share an API implementation (OpenAI-completions: OpenRouter, Groq, Cerebras, xAI, ZAI, ...). They share lazy API objects by reference:
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
export function openrouterProvider(): Provider {
return createProvider({
id: "openrouter",
name: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
models: OPENROUTER_MODELS,
api: openAICompletionsApi(),
});
}
This copies Vercel AI SDK's useful property: users import concrete providers; shared protocol implementation is internal.
Request auth output stays small:
export interface ModelAuth {
apiKey?: string;
headers?: Record<string, string>;
baseUrl?: string;
}
If a value cannot be expressed as apiKey, headers, or baseUrl, it is provider config, not auth (Vertex project/location, Bedrock region/profile, Azure apiVersion are provider factory options).
Provider.auth has exactly two slots; real providers have at most one api-key path and at most one OAuth path, and the slot names carry the UI's oauth-vs-api-key split without a kind discriminant or method ids:
export interface ProviderAuth {
apiKey?: ApiKeyAuth; // stored key/provider env + ambient env/files/ADC/IAM
oauth?: OAuthAuth; // login flow + refresh
}
export interface ApiKeyAuth {
name: string; // "Anthropic API key"
/** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */
login?(callbacks: AuthLoginCallbacks): Promise<ApiKeyCredential>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (credential.key ?? env("..."), credential.env?.NAME ?? env("...")).
* undefined = not configured.
*/
resolve(input: {
model: Model<Api>;
ctx: AuthContext;
credential?: ApiKeyCredential;
}): Promise<AuthResult | undefined>;
}
export interface OAuthAuth {
name: string; // "Anthropic (Claude Pro/Max)"
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>;
/** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
/** Side-effect-free derivation of request auth from a valid credential. Covers Copilot-style per-credential baseUrl. Async so lazy wrappers can load the implementation. */
toAuth(credential: OAuthCredential): Promise<ModelAuth>;
}
export interface AuthResult {
auth: ModelAuth;
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
source?: string;
}
export interface AuthContext {
env(name: string): Promise<string | undefined>;
fileExists(path: string): Promise<boolean>; // supports leading ~
}
The OAuth split (refresh + toAuth instead of one resolve) matches the old OAuthProviderInterface (refreshToken + getApiKey) and lets Models own the locking pattern without closure gymnastics: refresh produces a credential, toAuth derives request auth from whatever credential ends up stored.
There is no usesCallbackServer flag. With prompt()/notify() callbacks the flow self-describes at runtime: a flow that runs a callback server issues a manual_code prompt racing the server and aborts the prompt when the callback wins. The UI needs no static foreknowledge.
One credential per provider, type-tagged — exactly the shape of today's auth.json (type: "api_key" | "oauth" per provider id):
export interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv; // e.g. Cloudflare account/gateway ids, Azure/Vertex/Bedrock scoped config
}
export interface OAuthCredential extends OAuthCredentials {
type: "oauth"; // access, refresh, expires from OAuthCredentials
}
export type Credential = ApiKeyCredential | OAuthCredential;
ApiKeyCredential.env stores provider-scoped environment/config values alongside or instead of a key. ApiKeyAuth.resolve() merges per field: credential.key ?? env("CLOUDFLARE_API_KEY"), credential.env?.CLOUDFLARE_ACCOUNT_ID ?? env("CLOUDFLARE_ACCOUNT_ID"), etc. The credential discriminator intentionally matches today's auth.json (api_key) so the file-backed store does not need lossy type translation.
The app injects storage; pi-ai ships an in-memory default. Keyed by provider id, one credential per provider:
export interface CredentialStore {
/** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */
read(providerId: string): Promise<Credential | undefined>;
/**
* Serialized write — the only write path. fn sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it;
* return the new credential, or undefined to leave the entry unchanged.
* Mutual exclusion per provider id, cross-process too where the backing
* store supports it (file lock). Resolves with the post-write credential.
*/
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined>;
/** Remove (logout). Serialized against modify. */
delete(providerId: string): Promise<void>;
}
There is deliberately no set: an unserialized write path invites read-modify-write races (login-during-refresh clobbering a fresh credential, double token refresh). Call sites:
await store.modify(pid, async () => credential); // login: store this
await store.read(pid); // status UI ("logged in via OAuth")
await store.delete(pid); // logout
// refresh RMW happens inside Models.getAuth
Error semantics: read resolves undefined for missing entries; methods reject only on storage failure, and Models wraps such rejections in ModelsError code "auth". Best-effort stores that serve an in-memory view and record persistence errors internally (today's AuthStorage behavior) are valid implementations.
Models.getAuth(model) is a decision tree, not a loop. A stored credential owns the provider — ambient/env is consulted only when nothing is stored (AuthStorage parity: no silent env fallback after a failed refresh or for an unmatched credential type):
const stored = await store.read(provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
const oauth = provider.auth.oauth;
let credential = stored;
if (Date.now() >= credential.expires) { // optimistic check, lock-free
const post = await store.modify(provider.id, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
return Date.now() >= current.expires // authoritative check, under lock
? oauth.refresh(current) // throws -> ModelsError("oauth")
: undefined; // another process/request refreshed
});
if (post?.type !== "oauth") return undefined;
credential = post;
}
return { auth: await oauth.toAuth(credential), source: "OAuth" };
}
if (stored.type === "api_key" && provider.auth.apiKey) {
return provider.auth.apiKey.resolve({ model, ctx, credential: stored });
}
return undefined; // stored credential without matching handler blocks ambient
}
return provider.auth.apiKey?.resolve({ model, ctx, credential: undefined }); // ambient
Properties:
refreshOAuthTokenWithLock: valid tokens cost one read and zero locks; expired tokens lock, re-check under the lock, refresh once globally, persist before release.apiKey/headers) is merged per-field on top in stream(), winning over everything.ModelsError("oauth"); the stored credential is untouched (preserved for retry). Request paths surface this as a stream error with the real cause ("run /login"); status/availability UIs catch the rejection and render "needs re-login" — documented contract on getAuth.The end state for coding-agent: AuthStorage is deleted; its capabilities map onto a CredentialStore implementation plus composition.
Today's getApiKey priority and its new home:
| AuthStorage today | New design |
|---|---|
runtime override (CLI --api-key) | withRuntimeOverrides(store, overrides) decorator: read returns the override as an ApiKeyCredential; never persisted |
stored api_key (with $ENV/!command via resolveConfigValue) | stored ApiKeyCredential; config-value resolution happens at read in coding-agent's adapter/decorator (command execution stays app policy) |
stored oauth + locked refresh, undefined on failure | getAuth decision tree above; failure rejects with cause instead of silently unconfiguring |
| env var (only when nothing stored) | ambient branch of apiKey.resolve |
fallbackResolver (models.json custom providers) | gone — custom providers carry their own auth.apiKey |
FileCredentialStore ports AuthStorage's lock backend: read = memory snapshot,
modify = withLockAsync(re-read, fn, merge-write), delete,
internal error recording (drainErrors equivalent)
└─ withConfigValues $ENV / !command at read
└─ withRuntimeOverrides --api-key
└─ createModels({ credentials: store })
login/logout UI provider.auth.{oauth,apiKey}.login(callbacks) + store.modify/delete
status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection)
getOAuthProviders presence of provider.auth.oauth across registered providers
One interface serves api-key and OAuth login:
export interface AuthLoginCallbacks {
/** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */
signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>;
notify(event: AuthEvent): void;
}
/** `signal` lets the flow cancel a pending prompt when an out-of-band event resolves the step. */
export type AuthPrompt = { signal?: AbortSignal } & (
| { type: "text"; message: string; placeholder?: string }
| { type: "secret"; message: string; placeholder?: string }
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
| { type: "manual_code"; message: string; placeholder?: string }
);
export type AuthEvent =
| { type: "auth_url"; url: string; instructions?: string }
| { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }
| { type: "progress"; message: string };
prompt() returns the entered/selected string (select returns the option id). Flows race a manual_code prompt against a callback server by setting AuthPrompt.signal and aborting the prompt when the callback wins.
Providers that support OAuth always attach it. There is no factory toggle: the flow is lazy-loaded, so advertising OAuth costs nothing until login()/refresh() actually runs, and a host that never logs in never loads it.
export function anthropicProvider(): Provider {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
auth: {
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]),
oauth: lazyOAuth({
name: "Anthropic (Claude Pro/Max)",
load: () => import("../utils/oauth/anthropic.ts").then((m) => m.anthropicOAuth),
}),
},
models: ANTHROPIC_MODELS,
api: anthropicMessagesApi(),
});
}
lazyOAuth() wraps a dynamically imported OAuthAuth so provider definitions can advertise OAuth without importing the implementation (toAuth is async for exactly this reason):
export function lazyOAuth(input: {
name: string;
load: () => Promise<OAuthAuth>;
}): OAuthAuth;
OAuth must not force Node-only code (node:http, node:crypto) into browser bundles: the dynamic import inside lazyOAuth() uses the same bundler-opaque variable-specifier trick as the bedrock lazy wrapper. Browser hosts never trigger the load (no stored node OAuth credentials, no login flow). If web OAuth lands later (sitegeist proved feasibility: Web Crypto PKCE, auth tab, fetch token exchange, device-code polling), it is just a different OAuthAuth implementation — no reserved option values.
The existing flows in src/utils/oauth/ (anthropic, openai-codex, github-copilot) are adapted to OAuthAuth (login/refresh/toAuth, replacing login/refreshToken/getApiKey/modifyModels) with the new callbacks, staying Node-targeted and lazy-loaded. Copilot's modifyModels baseUrl rewriting becomes toAuth returning ModelAuth.baseUrl.
models.json is a provider wrapper layer. It does not mutate providers in place:
function withProviderOverrides(base: Provider, overrides: ProviderOverrides): Provider {
return {
...base,
name: overrides.name ?? base.name,
baseUrl: overrides.baseUrl ?? base.baseUrl,
headers: mergeHeaders(base.headers, overrides.headers),
getModels: () => applyModelOverrides(base.getModels(), overrides.models),
refreshModels: base.refreshModels?.bind(base),
stream: base.stream,
streamSimple: base.streamSimple,
};
}
This composes with dynamic providers because getModels() delegates to the base source and refreshModels() passes through.
Request-auth config from models.json ($ENV, !command, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom ApiKeyAuth the app sets on the wrapped provider's auth.apiKey.
One helper builds providers from parts; it handles both single-API and mixed-API providers:
export function createProvider(input: {
id: string;
name?: string; // default: id
baseUrl?: string;
headers?: Record<string, string>;
auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers)
/** Initial model list (empty for purely dynamic providers). */
models: readonly Model<Api>[];
/** Dynamic providers: fetch the current list; createProvider stores it and dedupes in-flight calls. */
refreshModels?: () => Promise<readonly Model<Api>[]>;
/** Single implementation, or map keyed by model.api for mixed-API providers. */
api: ProviderStreams | Record<string, ProviderStreams>;
}): Provider;
api: all models stream through it.api: stream()/streamSimple() dispatch on model.api; unknown api produces a stream error.Mixed-API custom providers must be supported (opencode Go/Zen-style providers expose models backed by different APIs under one provider id).
Built-in provider factories use createProvider() internally. models.json custom providers map onto it directly:
{
"providers": {
"my-openai-proxy": {
"api": "openai-completions",
"baseUrl": "https://proxy.example/v1",
"models": [ ... ]
}
}
}
@earendil-works/pi-ai/compat preserves the old global API surface until the coding-agent migration deletes it. New code never imports it.
Old semantics being preserved: global stream() can still dispatch by model.api through the legacy api-registry for custom providers, mutated models, and tests/extensions that override a built-in API implementation.
stream/complete/streamSimple/completeSimple(model, ctx, opts): real built-in provider/model/api matches route through a singleton builtinModels() collection, so provider auth/env/baseUrl behavior is shared with the new runtime. Unknown providers, mutated models, or overridden API registrations fall back to api-registry dispatch plus getEnvApiKey injection.registerApiProvider()/unregisterApiProviders() keep feeding the compat-local registry; resetApiProviders() clears and re-registers builtins.getModel/getModels/getProviders are deprecated aliases of getBuiltinModel/getBuiltinModels/getBuiltinProviders from providers/all (they were always pure generated-catalog reads — verified: nothing ever mutated the old modelRegistry).setBedrockProviderModule), env-api-keys.ts, and the image-generation registry/catalogs; none of these stay on the root barrel.export * from "./index.ts": compat is a strict superset of the core entrypoint, so consumers switch a file's import path wholesale without symbol surgery.coding-agent (and the interim agent package) switch imports of these symbols from @earendil-works/pi-ai to @earendil-works/pi-ai/compat (import-path-only change) and are otherwise untouched until the ModelManager migration.
Extension grace period: the coding-agent extension loader (jiti aliases + Bun virtualModules) resolves the @earendil-works/pi-ai ROOT specifier to the compat entrypoint. Existing user extensions using the old global API (complete, getModel, registerApiProvider, ...) keep working at runtime without changes; they break only when compat is removed at the ModelManager migration, with a migration guide in the changelog. Typechecking is the nudge: editors resolve the root to the slim core types, so extension sources that typecheck must import old globals from /compat — which is what the repo example extensions demonstrate.
Typed, sync, generated-catalog-only helpers live with the catalogs (exported from providers/all):
getBuiltinModel(provider, id) // sync, typed overloads from generated catalog
getBuiltinModels(provider) // sync
getBuiltinProviders() // sync
Runtime lookup through a Models instance is sync over the last-known provider lists: models.getModel(...). Freshness-critical callers run await models.refresh(provider) first.
Generated catalogs are split per provider (providers/<id>.models.ts) by updating packages/ai/scripts/generate-models.ts. If the generator change turns out too large for this pass, splitting may be deferred; providers/all and provider factories may temporarily import the monolithic models.generated.ts, relying on sideEffects: false for pruning.
Rules:
@earendil-works/pi-ai import is core-only.lazyOAuth() and lazy-loaded behind a bundler-opaque dynamic import; provider metadata never eagerly imports Node-only OAuth code.providers/all imports every built-in provider factory and all catalogs. It is the explicit heavy entrypoint.package.json lists only effectful compat/image registration files in sideEffects; root and provider modules stay tree-shakeable.providers/all then pulls all statically visible SDKs. Bedrock is the exception because its AWS SDK implementation is behind a bundler-opaque Node-only import and needs setBedrockProviderModule() for standalone single-file bundles.Exports map sketch:
{
"exports": {
".": "./dist/index.js",
"./compat": "./dist/compat.js",
"./providers/all": "./dist/providers/all.js",
"./providers/openai": "./dist/providers/openai.js",
"./providers/anthropic": "./dist/providers/anthropic.js",
"./providers/*": "./dist/providers/*.js",
"./api/*": "./dist/api/*.js"
}
}
Browser smoke check (scripts/check-browser-smoke.mjs) must keep passing: bundling the core entrypoint (and any non-node provider entrypoint) must not pull node:http/node:crypto.
AgentHarness receives a Models instance.
AgentHarnessOptions.models is required.Models into turn state.this.models.streamSimple(model, context, options); same for compaction/branch-summarization paths.models.getModel() to canonicalize; if model metadata needs refresh, the app updates the selected model before starting a turn.createModels() and install the faux provider (fauxProvider() factory from providers/faux).coding-agent builds providers in layers and binds them per session:
built-in providers (builtinModels)
-> models.json provider wrappers / custom providers (createProvider)
-> extension provider wrappers/additions
sessionModels.clearProviders();
for (const provider of layeredProviders) sessionModels.setProvider(provider);
coding-agent owns: FileCredentialStore + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar ($ENV, !command), command execution policy, provider status labels (from AuthResult.source), login/logout UI (driving auth.{apiKey,oauth}.login() with prompt()/notify()), extension lifecycle, provider-management slash commands.
Current interim state:
AgentHarness already accepts a Models instance and uses it for turn streaming, compaction, and branch summaries.AgentHarness yet; AgentSession still drives the low-level Agent with a streamFn.AuthStorage + ModelRegistry and imports old global pi-ai APIs through @earendil-works/pi-ai/compat./compat as the runtime grace period for old extensions.Check items off as they land. Keep this list current; it is the working state for resumed sessions.
types.ts Provider alias to ProviderId; fix call sites.ApiOptionsMap and ApiStreamOptions<TApi> to types.ts (type-only imports).models.ts: Provider<TApi> interface, hasApi() guard, ModelsError + codes. Auth types live in src/auth/types.ts (ProviderAuth = { apiKey?, oauth? }, credentials, CredentialStore (read/modify/delete, one credential per provider), AuthResult, AuthContext, ModelAuth, login callbacks), in-memory store in src/auth/credential-store.ts, default context in src/auth/context.ts (browser-safe node:fs trick), lazyStream() in src/api/lazy.ts.Models/MutableModels/createModels({ credentials?, authContext? }) with provider map, sync getModel(s) (per-provider failure isolation), explicit async refresh(provider?), getAuth (decision tree, double-checked locked refresh), stream/complete/streamSimple/completeSimple with per-field auth merge. Tests: packages/ai/test/models-runtime.test.ts.calculateCost, getSupportedThinkingLevels, clampThinkingLevel, modelsAreEqual.src/api/src/providers/ to src/api/, renamed by API id (anthropic.ts -> api/anthropic-messages.ts, etc.).stream and streamSimple.openai-responses-shared, google-shared, transform-messages, openai-prompt-cache, github-copilot-headers, cloudflare, simple-options) to src/api/.lazyStream()/lazyApi() into src/api/lazy.ts.*.lazy.ts wrappers per API; bedrock keeps node-only import trick and setBedrockProviderModule().providers/register-builtins.ts. Interim until Phase 5 compat: builtin api-registry registration lives in stream.ts; lazy API wrappers are exported from the root barrel.src/auth/helpers.ts: envApiKeyAuth() (with secret-prompt login), lazyOAuth(). OAuth flow loads go through utils/oauth/load.ts (bundler-opaque dynamic import); the OAuthAuth exports it references land in Phase 4.createProvider() in models.ts (single + mixed api map, dispatch on model.api, unknown api -> stream error).src/providers/ for all built-in catalog providers; OAuth attached via lazyOAuth() (anthropic, openai-codex, github-copilot); ambient ApiKeyAuth for amazon-bedrock (AWS env/profile) and google-vertex (key or ADC+project+location).providers/all.ts: builtinProviders(), builtinModels(), getBuiltinModel/getBuiltinModels/getBuiltinProviders re-exports.fauxProvider() in providers/faux.ts) for tests; legacy registerFauxProvider() kept until compat dies.scripts/generate-models.ts (providers/<id>.models.ts); models.generated.ts becomes a generated aggregator.utils/oauth/anthropic.ts, openai-codex.ts, github-copilot.ts to OAuthAuth (login/refresh/toAuth) + prompt()/notify(); modifyModels baseUrl rewriting becomes toAuth().baseUrl. New exports (anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth) sit next to the old OAuthProviderInterface objects, which survive until Phase 7.usesCallbackServer on OAuthAuth: callback-server flows race a manual_code prompt (aborted via AuthPrompt.signal once the flow settles). The old interface keeps its flag until it dies with compat.index.ts core-only and side-effect free (no catalogs, no provider factories, no api-registry, no env-api-keys, no images, no OAuth, no compat). Typed catalog reads (getBuiltin*) implemented in providers/all.ts; models.ts no longer imports models.generated.ts.compat.ts: superset of index + old api-dispatch globals, deprecated getModel/getModels/getProviders aliases, lazy api wrappers + setBedrockProviderModule, getEnvApiKey, images. Registration side effect lives here (skip-if-present)../compat, ./providers/*, ./api/*); sideEffects array listing the effectful modules (compat, images registration) instead of false./compat) + shrinkwrap checks green. Internal old-global imports switched to /compat already (42 files in agent/coding-agent/examples; vitest configs alias /compat to src; spawn-CLI tests resolve workspace dist, so packages/ai + packages/agent dists were rebuilt).AgentHarnessOptions.models required (readonly models on the harness); the harness stream path uses models.streamSimple(). StreamFn redefined structurally (no compat type dependency); Models.streamSimple satisfies it.Models instance. getApiKeyAndHeaders is removed entirely — Models is the only auth path; per-request key resolution becomes provider auth on the collection. compact()/generateSummary()/generateBranchSummary() lose their explicit apiKey/headers parameters.createModels() + fauxProvider() with unique per-fake provider ids; no global api-registry state, no unregister bookkeeping.@earendil-works/pi-ai/compat (landed with Phase 5; compat is a superset so the switch was path-only). Extension loader resolves the pi-ai root to compat as the runtime grace period.Models instance — coding-agent's AgentSession drives the low-level Agent via streamFn, not the harness — and moved to Phase 9../test.sh green throughout).packages/ai/CHANGELOG.md: ### Breaking Changes with migration guide (compat entrypoint, Provider -> ProviderId, api module moves) + ### Added for the new Models/provider/auth API.packages/coding-agent/CHANGELOG.md: ### Changed entry for extension authors — runtime unaffected (loader resolves the pi-ai root to compat), typecheck nudges to /compat or the new API; removal happens later with a migration guide.packages/agent/CHANGELOG.md: ### Breaking Changes for required AgentHarnessOptions.models, compaction signature changes, structural StreamFn.npm run check clean.coding-agent replaces AuthStorage and ModelRegistry's internals with FileCredentialStore + a MutableModels collection. AgentSession itself stays (AgentHarness adoption is pi 2.0); only its model/auth substrate swaps. Layering is strictly one-directional:
FileCredentialStore (auth.json, locked) + --api-key overlay + $ENV/!command resolution
↑
MutableModels: builtin factories (wrapped per models.json config) + custom providers (models.json ∪ extensions)
↑
ModelRegistry: async facade — reads delegate to the collection; registerProvider/login/logout/status for extensions + UI
↑
AgentSession / sdk / interactive-mode (await added; stream via models)
Decisions:
AuthStorage is deleted as a type — it would otherwise depend on provider auth while provider auth depends on its store (circular). Its surface splits: get/set/remove -> CredentialStore; getApiKey -> Models.getAuth; login/logout/getAuthStatus -> ModelRegistry facade methods over provider.auth.oauth + the store.--api-key overrides are a store overlay (an override reads as an ephemeral stored api-key credential, masking stored OAuth — matches today's priority). Every registered provider is guaranteed an apiKey auth slot so overrides apply to OAuth-only providers too.ModelRegistry.getAll/find/getAvailable become async, delegating to the collection (no materialized snapshot, no sync lies; dynamic providers like llama.cpp work). The extension-facing modelRegistry surface changes accordingly (breaking, changelogged); extensions also get the collection itself as the forward API.getModels() applies provider baseUrl/compat overlays, modelOverrides, and custom-model merges (async-safe); provider apiKey/headers/authHeader configs become that provider's ApiKeyAuth (config first, factory auth fallback); parse errors keep getError() semantics.ProviderConfig parity: provider-keyed streamSimple, old-style oauth adapted to OAuthAuth (modifyModels -> getModels wrap + toAuth), full model replacement per provider. Legacy registerApiProvider writes stay compat-local for consumers that call global complete(); they die with compat.getModels() (extension-visible models stay correct) plus per-request toAuth().baseUrl.CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_GATEWAY_ID from credential env or ambient AuthContext.env() -> ModelAuth.baseUrl). Built-in compat calls route through Models, so they use the same provider auth path.Ordering for new sessions:
Provider.getModels() sync + optional refreshModels(); Models.getModels/getModel sync, Models.refresh(provider?) async; createProvider takes models array + optional refreshModels fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension find/getAll).baseUrl, provider-scoped env, and header suppression/override metadata from provider auth.FileCredentialStore in coding-agent.
CredentialStore interface over the existing auth.json lock backend (FileAuthStorageBackend / InMemoryAuthStorageBackend can be reused or renamed).ApiKeyCredential uses { type: "api_key", key?, env? }, matching today's auth.json; do not translate env into metadata or rewrite discriminators.read(provider) returns the current credential snapshot and records parse/storage errors for status UI parity.modify(provider, fn) must lock, re-read, run fn, merge-write the provider entry, chmod 0600, and return the post-write credential.delete(provider) must lock and remove only that provider's entry.api_key reads, OAuth reads, provider env preservation, delete, parse errors, and concurrent refresh-style modifications.withConfigValues(store, policy) resolves stored API-key credentials whose key or env values use $ENV or !command, using existing resolve-config-value.ts semantics. Command execution stays in coding-agent, not pi-ai.withRuntimeOverrides(store, overrides) implements CLI --api-key: read returns an ephemeral { type: "api_key", key } for each overridden provider, masking stored OAuth/API credentials without persisting.apiKey auth slot so the overlay is meaningful.models.json.
getModels() so provider-level baseUrl/headers/compat, per-model modelOverrides, and custom model merges apply on every sync read.refreshModels() passthrough so dynamic providers compose with decorations.apiKey/headers/authHeader models.json config into a wrapped ApiKeyAuth that resolves config values first and falls back to the base provider auth.models use createProvider() with the appropriate lazy API wrapper or extension-provided stream implementation.ModelRegistry.getError() behavior: built-ins remain available, and the error is visible.getModels() baseUrl wrap.
toAuth() already returns per-credential request baseUrl for streaming.getModels() when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL.OAuthProviderInterface configs to pi-ai OAuthAuth.login maps old callbacks/events to prompt()/notify().refreshToken maps to refresh.getApiKey maps to toAuth.modifyModels becomes a provider getModels() wrapper plus toAuth().baseUrl where applicable./compat alias until Phase 10.ModelRegistry over MutableModels.
MutableModels instance built from decorated built-ins + models.json custom providers + extension providers.getAll(), find(), and getAvailable() become async and delegate to the collection/status checks. Update extension-facing types and changelog this breaking change.refresh() rebuilds provider layers and calls models.refresh() where needed; no global api-registry reset should be part of the new path except compat-only grace behavior.registerProvider()/unregisterProvider() mutate provider layers and rebuild the collection.login, logout, provider status, available OAuth providers) drive provider.auth.{apiKey,oauth} and the CredentialStore; no AuthStorage type remains.registerApiProvider writes stay only for /compat callers and are removed in Phase 10.AgentSession stream function resolves through ModelRegistry/Models, not getApiKeyAndHeaders() + compat globals.authStorage with credentials?: CredentialStore or an agent-dir-backed default; update sdk.md and examples.model-resolver, --list-models, model selector, login/logout/status UI, and provider attribution await async model/auth APIs.--api-key populates the runtime override decorator instead of mutating AuthStorage.FileCredentialStore, config-value decorators, provider decoration, extension OAuth adapter, ModelRegistry async facade, and consumer rewiring.--api-key precedence, $ENV/!command resolution, and stored credential blocking ambient fallback.ModelRegistry.getAll/find/getAvailable.Models./compat imports to the new API: every package's src, all tests, and the example extensions (examples then demonstrate the new API). Nothing inside the repo may import /compat at that point./compat, env-api-keys.ts, the extension-loader root-to-compat alias, the old pi-ai/oauth registry and OAuthProviderInterface (incl. usesCallbackServer), and the compat-local legacy API registry. This is the extension-author breaking release; changelog carries the migration guide.OAuthAuth.ImagesModels/ImagesProvider/createImagesProvider mirror the chat-side design (sync reads, explicit refresh, never-reject generation); auth resolution shared with the chat side via the free-standing resolveProviderAuth() in auth/resolve.ts (which also owns ModelsError; both collections pass their store/context as arguments — no resolver object). openrouterImagesProvider() factory + builtinImagesProviders()/builtinImagesModels() in providers/all; impl moved to api/openrouter-images.ts with a lazy wrapper. The old global image API (registry + getImageModel* + generateImages) stays on compat; ImagesProvider id alias in types.ts renamed to ImagesProviderId (mirror of Provider -> ProviderId).undefined means not found or not configured. Real failures reject or become stream errors.
export type ModelsErrorCode =
| "model_source" // provider model refresh failed
| "model_validation" // model object invalid
| "provider" // unknown provider, dispatch failure
| "stream" // stream setup failure
| "auth" // auth resolution failure
| "oauth"; // oauth login/refresh failure
Models.stream() produces stream errors (error event + error result) for async setup failures; it does not throw after returning the stream.Models.getModels() is a sync best-effort read: a provider whose getModels() throws yields no models. Models.refresh(provider) rejects on that provider's fetch failure; Models.refresh() (all providers) is concurrent best-effort. Apps that need a concrete listing failure refresh the single provider.ModelsError codes auth/oauth); silent fallback to a different auth path after a failure risks billing surprises. A stored credential always blocks ambient/env fallback, including after a failed refresh.getAuth rejections and render "needs re-login"; they do not treat rejection as "unconfigured".