25 KiB
Models architecture
This document describes the target design for the next pi-ai model/provider refactor. It intentionally describes the desired shape, not the current implementation.
Goals:
Modelsis a dumb runtime collection of providers.- Concrete providers own metadata, auth, model listing, and stream behavior.
- API implementations live under
src/ai/and are reusable/lazy. - Concrete provider factories live under
src/providers/. - Users can import only the providers they need.
- Importing a provider should not eagerly import heavy SDKs.
- Dynamic model lists are first-class and side-effect-free.
models.jsonand extensions layer by wrapping providers, not by mutating provider internals ad hoc.
Non-goals for the immediate pi-ai pass:
- Do not migrate coding-agent
ModelRegistryyet. - Do not preserve old process-global APIs unless as explicit temporary compatibility shims.
- Do not keep the stream/API registry inside
Models.
Package layout
Target source layout:
packages/ai/src/
index.ts # core exports only; no built-in provider imports
models.ts # Models, Provider, auth, runtime types
auth/ # shared auth helpers, local/OAuth wrappers
ai/ # API implementations and lazy API wrappers
openai-compatible.ts # real implementation, imports SDKs
openai-compatible-lazy.ts # lightweight lazy wrapper
anthropic.ts
anthropic-lazy.ts
bedrock.ts
bedrock-lazy.ts
...
providers/ # concrete provider factories and per-provider catalogs
openai.ts
openai.models.ts # OpenAI provider catalog
openai-codex.ts
openai-codex.models.ts # OpenAI Codex provider catalog
openrouter.ts
openrouter.models.ts
anthropic.ts
anthropic.models.ts
google-vertex.ts
google-vertex.models.ts
bedrock.ts
bedrock.models.ts
cloudflare-ai-gateway.ts
cloudflare-ai-gateway.models.ts
all.ts # explicit aggregate for pi CLI/coding-agent
src/index.ts must stay core-only. It must not import:
- all generated model metadata
- built-in provider factories
- provider SDK implementations
- Node-only OAuth modules
providers/all
Provider and API entrypoints are explicit subpath exports.
Public usage
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 = await models.getModel("openai", "gpt-4o-mini");
if (!model) throw new Error("model not found");
const response = await models.complete(model, context);
Multiple providers:
import { createModels } from "@earendil-works/pi-ai";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
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 heavy SDK implementations; provider streams use lazy wrappers.
Core runtime: Models
Models is a provider collection plus auth application and stream convenience. It does not contain a stream registry.
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
getModels(provider?: string, options?: { forceRefresh?: boolean }): Promise<readonly Model<Api>[]>;
getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise<Model<Api> | undefined>;
getAuth(model: Model<Api>): Promise<ModelAuth | 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<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream;
completeSimple<TApi extends Api>(
model: Model<TApi>,
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;
getAuthResolver(): ModelAuthResolver;
setAuthResolver(resolver: ModelAuthResolver): void;
}
No stream registry:
remove Models.setStreamFunctions()
remove Models.getStreamFunctions()
remove api-registry as real API
No provider builder mutation API as public API:
remove/avoid Models.provider(id)
remove setModel/upsertModel/patchModel public lifecycle
A MutableModels implementation may still use internal maps, but the public object is provider-oriented.
Provider
A provider is the concrete runtime unit. It owns:
- id/name/base metadata
- auth behavior
- model listing
- stream behavior
Full stream options are API-specific. The generic Model<TApi> only pays off if the stream option type is derived from TApi.
export type ApiStreamOptions<TApi extends Api> = StreamOptionsForApi<TApi>;
export interface Provider {
readonly id: string;
readonly name: string;
/** Default model API metadata/diagnostics, not Models dispatch. */
readonly api?: Api;
readonly baseUrl?: string;
readonly headers?: Record<string, string>;
/** Required. Use {} for no-auth providers. */
readonly auth: ProviderAuth;
getModels(options?: { forceRefresh?: boolean }): Promise<readonly Model<Api>[]>;
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
): AssistantMessageEventStream;
streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream;
}
Model.api should remain for now because:
- existing metadata and tests use it
- it is useful for diagnostics
- custom provider helpers may use it for API implementation selection
But Models no longer dispatches through model.api. The provider does.
Provider model sources
Provider model listing is async.
export type ProviderModelSource =
| readonly Model<Api>[]
| ((options?: { forceRefresh?: boolean }) => Promise<readonly Model<Api>[]>);
Provider helpers can accept ProviderModel[] and resolve provider defaults, but public Provider.getModels() returns full Model<Api> objects.
Dynamic model sources 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 belongs in app/provider-management commands, not in getModels().
Streaming path
Models.stream() finds the provider by model.provider, resolves request auth, applies request-scoped auth, and delegates to the provider.
async function stream(model, context, options) {
const provider = getProvider(model.provider);
if (!provider) throw new ModelsError(...);
const auth = await getAuth(model);
const requestModel = auth?.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const requestOptions = mergeAuthIntoOptions(options, auth);
return provider.stream(requestModel, context, requestOptions);
}
stream() still returns AssistantMessageEventStream synchronously. Async setup happens inside the returned stream, as today.
No request hot-path model canonicalization. If an app wants fresh model metadata after refresh, it must call:
const model = await models.getModel(provider, id, { forceRefresh: true });
before starting the turn.
API implementations under src/ai
An API implementation is reusable stream behavior. It is not a provider.
Example real implementation:
// src/ai/openai-compatible.ts
import OpenAI from "openai";
export function streamOpenAICompatible(...) { ... }
export function streamSimpleOpenAICompatible(...) { ... }
Example lazy wrapper:
// src/ai/openai-compatible-lazy.ts
export function openAICompatibleApi(): ProviderStreams {
return {
stream(model, context, options) {
return lazyStream(() =>
import("./openai-compatible.ts").then((m) =>
m.streamOpenAICompatible(model, context, options),
),
);
},
streamSimple(model, context, options) {
return lazyStream(() =>
import("./openai-compatible.ts").then((m) =>
m.streamSimpleOpenAICompatible(model, context, options),
),
);
},
};
}
Provider modules import lazy API wrappers, never real SDK-heavy implementation modules.
provider module -> lazy API wrapper -> dynamic import(real API impl) -> SDK deps
This preserves both:
- provider-owned stream behavior
- lazy SDK loading
Shared API implementations across concrete providers
Many concrete providers share an API implementation. Example:
- OpenAI
- OpenRouter
- Groq
- Together
- DeepSeek
- Cloudflare AI Gateway OpenAI-compatible models
They should share lazy API objects by reference, not through Models stream registry.
import { openAICompatibleApi } from "../ai/openai-compatible-lazy.ts";
const api = openAICompatibleApi();
export function openrouterProvider(): Provider {
return {
id: "openrouter",
name: "OpenRouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
auth: { local: envLocalAuth(["OPENROUTER_API_KEY"]) },
getModels: staticModels(OPENROUTER_MODELS),
stream: api.stream,
streamSimple: api.streamSimple,
};
}
This copies Vercel AI SDK’s useful property: users import concrete providers, while shared protocol implementation is internal.
Auth
Request auth output stays small.
export interface ModelAuth {
apiKey?: string;
headers?: Record<string, string>;
baseUrl?: string;
}
No streamOptions in auth. If a value cannot be expressed as apiKey, headers, or baseUrl, it is provider config, not auth.
Provider auth:
export interface ProviderAuth {
local?: LocalAuthProvider;
oauth?: OAuthProvider;
}
auth is required on Provider; no-auth providers use {}.
Local auth
Local auth covers non-OAuth credentials:
- env API keys
- files on disk
- ambient SDK credentials
- AuthStorage local credentials
- models.json local credentials
- provider-specific credential metadata
export interface ProviderAuthContext {
env(name: string): Promise<string | undefined>;
fileExists(path: string): Promise<boolean>; // supports leading ~
}
export interface LocalCredential {
type: "local";
key?: string;
metadata?: Record<string, string>;
}
export interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
export type Credential = LocalCredential | OAuthCredential;
export interface LocalAuthProvider {
id: string;
name: string;
login?(callbacks: AuthLoginCallbacks): Promise<LocalCredential>;
resolve(input: {
model: Model<Api>;
ctx: ProviderAuthContext;
credential?: LocalCredential;
}): Promise<AuthResolution | undefined>;
}
export interface AuthResolution {
auth: ModelAuth;
sources: readonly ProviderAuthSource[];
}
export type ProviderAuthSource =
| { type: "env"; name: string }
| { type: "file"; path: string; label?: string }
| { type: "ambient"; label: string };
Local auth receives an optional credential from the app. It does not read AuthStorage itself.
Examples:
- OpenAI:
credential.key ?? env("OPENAI_API_KEY")->{ apiKey } - Bedrock: bearer token ->
{ apiKey }; AWS profile/IAM/ECS/IRSA ->{} - Vertex: API key ->
{ apiKey }; ADC files ->{} - Cloudflare: key + account/gateway metadata/env ->
{ apiKey, baseUrl }
OAuth
export interface OAuthProvider {
id: string;
name: string;
usesCallbackServer?: boolean;
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>;
resolve(credentials: OAuthCredential): Promise<{
credentials: OAuthCredential;
auth: ModelAuth;
}>;
}
OAuth receives stored OAuth credentials, may refresh them, and returns updated credentials for the app to persist.
Login callbacks
One callback interface serves local and OAuth login. Use the nicer prompt() / notify() shape now instead of carrying forward the ad hoc OAuth callback bag.
export interface AuthLoginCallbacks {
signal?: AbortSignal;
prompt<TPrompt extends AuthPrompt>(
prompt: TPrompt,
options?: { signal?: AbortSignal },
): Promise<AuthPromptResult<TPrompt>>;
notify(event: AuthEvent): void;
}
export type AuthPrompt =
| {
type: "text";
id: string;
message: string;
placeholder?: string;
allowEmpty?: boolean;
required?: boolean;
}
| {
type: "secret";
id: string;
message: string;
placeholder?: string;
required?: boolean;
}
| {
type: "select";
id: string;
message: string;
options: readonly { id: string; label: string; description?: string }[];
}
| {
type: "manual_code";
id: string;
message: string;
placeholder?: string;
};
export type AuthPromptResult<TPrompt extends AuthPrompt> = 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 };
Codex browser login can race a manual_code prompt against a callback server by passing an abort signal to prompt(..., { signal }) and aborting the prompt when the callback wins.
OAuth implementation target
OAuth providers must not force Node-only code into browser bundles. Keep OAuth lazy, and let each concrete provider factory decide whether to attach a Node OAuth implementation, a web OAuth implementation, or no OAuth implementation.
Do not build a universal OAuth runtime abstraction in this refactor. The provider factory option is enough:
export type OAuthTarget = "node" | "web" | false;
export interface AnthropicProviderOptions {
oauth?: OAuthTarget;
}
export function anthropicProvider(options: AnthropicProviderOptions = {}): Provider {
return {
id: "anthropic",
name: "Anthropic",
api: "anthropic",
baseUrl: "https://api.anthropic.com/v1",
auth: {
local: envLocalAuth("anthropic-api-key", "Anthropic API key", ["ANTHROPIC_API_KEY"]),
oauth:
options.oauth === "node"
? lazyOAuthProvider({
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
usesCallbackServer: true,
load: () => import("../oauth/anthropic-node.ts").then((m) => m.anthropicOAuthProvider),
})
: options.oauth === "web"
? lazyOAuthProvider({
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
load: () => import("../oauth/anthropic-web.ts").then((m) => m.anthropicOAuthProvider),
})
: undefined,
},
getModels: staticModels(ANTHROPIC_MODELS),
stream: anthropicApi().stream,
streamSimple: anthropicApi().streamSimple,
};
}
Recommended defaults:
- individual provider factories default to
oauth: falseunless we intentionally want Node defaults providers/allfor pi CLI/coding-agent calls providers withoauth: "node"- browser users call providers with
oauth: "web" - users that only want API-key/env auth leave OAuth disabled
Sitegeist demonstrates that browser-compatible OAuth is practical for Anthropic, OpenAI Codex, GitHub Copilot, and Gemini CLI. The browser implementations use Web Crypto, auth tabs, localhost redirect URL watching through extension tab APIs, fetch for token exchange, CORS permissions/proxies where needed, and device-code polling for Copilot.
So the target is not “OAuth is Node-only”. The target is: provider factories attach the right lazy OAuth module for the runtime the caller asked for.
Use a lazy wrapper so provider definitions can advertise OAuth without importing the actual implementation:
export function lazyOAuthProvider(input: {
id: string;
name: string;
usesCallbackServer?: boolean;
load: () => Promise<OAuthProvider>;
}): OAuthProvider {
return {
id: input.id,
name: input.name,
usesCallbackServer: input.usesCallbackServer,
async login(callbacks) {
return (await input.load()).login(callbacks);
},
async resolve(credentials) {
return (await input.load()).resolve(credentials);
},
};
}
Auth resolution policy
pi-ai can ship a default resolver using injected context/store. Applications can replace it.
Recommended default order, low to high precedence:
provider local auth defaults
-> CredentialStore local/OAuth credential
-> explicit request auth
coding-agent later adds models.json and CLI policy:
provider local auth defaults
-> AuthStorage credential
-> models.json auth sidecar
-> CLI/runtime explicit request auth
Auth values merge:
- later
apiKeywins - later
baseUrlwins - headers shallow-merge; later wins per header
Cloudflare requires merge, not early return. It may need env account/gateway + stored key, or stored metadata + env token.
Provider wrappers and models.json
models.json is naturally a provider wrapper layer.
It should not mutate a provider in place. It should wrap:
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),
async getModels(options) {
const models = await base.getModels(options);
return applyModelOverrides(models, overrides.models);
},
stream: base.stream,
streamSimple: base.streamSimple,
};
}
This composes with dynamic providers because getModels() delegates to the base provider source.
Request-auth config from models.json remains app-owned sidecar state. It is not stored in Provider unless it is true provider metadata such as base URL or headers.
Custom providers from models.json
A models.json custom provider must become a concrete Provider object.
Single API custom provider
If all models use one known API:
{
"providers": {
"my-openai-proxy": {
"api": "openai-completions",
"baseUrl": "https://proxy.example/v1",
"models": [ ... ]
}
}
}
coding-agent/pi-ai helper can build:
createApiBackedProvider({
id: "my-openai-proxy",
name: "my-openai-proxy",
api: "openai-completions",
baseUrl: "https://proxy.example/v1",
auth: {},
models,
apiImplementation: openAICompatibleApi(),
});
This helper lives outside Models; it is provider construction sugar.
Mixed API custom provider
Custom providers with mixed APIs must be supported. Existing providers such as opencode-go/zen can expose models backed by different APIs under one provider id. In this design that means the provider dispatches internally.
createDispatchProvider({
id,
models,
apis: {
"openai-completions": openAICompatibleApi(),
"anthropic": anthropicApi(),
},
});
The returned provider still exposes only:
stream(model, context, options)
streamSimple(model, context, options)
Internally it switches on model.api and calls the right lazy API implementation.
This preserves the rule that Models has no stream registry while supporting required mixed-API providers.
Tree-shaking and lazy imports
Rules:
- Main
@earendil-works/pi-aiimport is core-only. - Provider modules import metadata, model catalog, auth helpers, and lazy API wrappers only.
- Lazy API wrappers dynamically import real API implementations.
- Real API implementations import SDK dependencies.
- OAuth providers are selected by provider factory option (
oauth: "node" | "web" | false) and lazy-loaded; provider metadata must not eagerly import Node-only OAuth code. providers/allis explicit and allowed to import all provider metadata, but still no eager SDK imports.- Provider modules are side-effect-free; importing a provider does not register it globally.
package.jsonshould setsideEffects: falseif all entrypoints are side-effect-free.
Example exports:
{
"exports": {
".": "./dist/index.js",
"./providers/openai": "./dist/providers/openai.js",
"./providers/anthropic": "./dist/providers/anthropic.js",
"./providers/openrouter": "./dist/providers/openrouter.js",
"./providers/all": "./dist/providers/all.js",
"./ai/openai-compatible": "./dist/ai/openai-compatible-lazy.js"
}
}
To avoid metadata bloat for minimal users, generated model catalogs should be split per provider. Until then, any provider module importing the monolithic generated catalog can pull more metadata than necessary.
Static typed helpers
The old global sync helpers are incompatible with dynamic providers:
getModel(...)
getModels(...)
getProviders(...)
If they mean runtime lookup, they must be async. If they remain sync and read only built-ins, they are misleading.
Target:
- remove old global runtime helpers, or make them async and default-instance backed only in a compatibility entrypoint
- add explicit static catalog helpers if type-safe built-in lookup is still desired
getBuiltinModel(provider, id) // sync, generated catalog only
getBuiltinModels(provider) // sync, generated catalog only
getBuiltinProviders() // sync, generated catalog only
Runtime lookup is always:
await models.getModel(provider, id)
await models.getModels(provider)
AgentHarness integration
AgentHarness receives a Models instance.
Rules:
AgentHarnessOptions.modelsis required- harness does not snapshot
Modelsinto turn state - request path calls
models.streamSimple(model, context, options)or equivalent - request path does not call async
models.getModel()to canonicalize - if model metadata needs refresh, app updates the selected model before starting a turn
coding-agent next phase
coding-agent should build providers in layers:
built-in providers
-> models.json provider wrappers
-> extension provider wrappers/additions
Then:
sessionModels.clearProviders();
for (const provider of layeredProviders) sessionModels.setProvider(provider);
sessionModels.setAuthResolver(codingAgentResolver);
coding-agent owns:
- AuthStorage local/OAuth files
- models.json auth sidecar
$ENVand!command- command execution policy
- provider status labels
- login/logout UI
- extension lifecycle
- provider-management slash commands
Migration TODOs
- Restore/remove half-implemented old auth/stream-registry changes before starting this design.
- Redesign
packages/ai/src/models.tsaround provider-owned streams. - Remove
StreamFunctionsregistry fromModelspublic API. - Introduce
Providerwith requiredauth, asyncgetModels(),stream(), andstreamSimple(). - Add lazy API wrappers under
packages/ai/src/ai/. - Move real API implementations under
packages/ai/src/ai/or adapt existing stream files into that layout. - Add concrete provider factories under
packages/ai/src/providers/. - Add
providers/allexplicit aggregate. - Add
lazyOAuthProvider(),OAuthTarget, and provider factory options such asanthropicProvider({ oauth: "node" | "web" | false }). - Convert built-in OAuth attachment to lazy target-specific wrappers.
- Split generated model catalogs per provider, or mark as follow-up if too large.
- Replace old global
defaultModels()/global helpers with explicit instance usage or compatibility entrypoint. - Add custom provider helpers:
createApiBackedProvider()createDispatchProvider()for required mixed-API providers
- Update
AgentHarnessto use provider-ownedmodels.streamSimple()without stream registry lookups. - Keep coding-agent compatibility only as needed until the coding-agent
ModelManagermigration. - Update tests to construct explicit
Modelsinstances and install only needed providers/faux providers.
Error behavior
undefined means not found or not configured.
Real failures reject or become stream errors.
Recommended error codes:
export type ModelsErrorCode =
| "model_source"
| "model_validation"
| "provider"
| "stream"
| "auth"
| "oauth";
Models.stream() should produce stream errors for async setup failures. getModels() should isolate provider source failures when listing all providers if possible, so one dynamic provider failure does not prevent listing other providers.