docs(agent): add models architecture design

This commit is contained in:
Mario Zechner
2026-06-08 17:04:57 +02:00
Unverified
parent d8aef0feff
commit 216ba41fc3
+860
View File
@@ -0,0 +1,860 @@
# 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:
- `Models` is 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.json` and 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 `ModelRegistry` yet.
- 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:
```txt
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:
```ts
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:
```ts
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:
```ts
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.
```ts
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:
```txt
remove Models.setStreamFunctions()
remove Models.getStreamFunctions()
remove api-registry as real API
```
No provider builder mutation API as public API:
```txt
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`.
```ts
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.
```ts
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:
```txt
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.
```ts
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:
```ts
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:
```ts
// src/ai/openai-compatible.ts
import OpenAI from "openai";
export function streamOpenAICompatible(...) { ... }
export function streamSimpleOpenAICompatible(...) { ... }
```
Example lazy wrapper:
```ts
// 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.
```txt
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.
```ts
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 SDKs useful property: users import concrete providers, while shared protocol implementation is internal.
## Auth
Request auth output stays small.
```ts
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:
```ts
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
```ts
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
```ts
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.
```ts
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:
```ts
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: false` unless we intentionally want Node defaults
- `providers/all` for pi CLI/coding-agent calls providers with `oauth: "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:
```ts
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:
```txt
provider local auth defaults
-> CredentialStore local/OAuth credential
-> explicit request auth
```
coding-agent later adds models.json and CLI policy:
```txt
provider local auth defaults
-> AuthStorage credential
-> models.json auth sidecar
-> CLI/runtime explicit request auth
```
Auth values merge:
- later `apiKey` wins
- later `baseUrl` wins
- 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:
```ts
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:
```json
{
"providers": {
"my-openai-proxy": {
"api": "openai-completions",
"baseUrl": "https://proxy.example/v1",
"models": [ ... ]
}
}
}
```
coding-agent/pi-ai helper can build:
```ts
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.
```ts
createDispatchProvider({
id,
models,
apis: {
"openai-completions": openAICompatibleApi(),
"anthropic": anthropicApi(),
},
});
```
The returned provider still exposes only:
```ts
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:
1. Main `@earendil-works/pi-ai` import is core-only.
2. Provider modules import metadata, model catalog, auth helpers, and lazy API wrappers only.
3. Lazy API wrappers dynamically import real API implementations.
4. Real API implementations import SDK dependencies.
5. 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.
6. `providers/all` is explicit and allowed to import all provider metadata, but still no eager SDK imports.
7. Provider modules are side-effect-free; importing a provider does not register it globally.
8. `package.json` should set `sideEffects: false` if all entrypoints are side-effect-free.
Example exports:
```json
{
"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:
```ts
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
```ts
getBuiltinModel(provider, id) // sync, generated catalog only
getBuiltinModels(provider) // sync, generated catalog only
getBuiltinProviders() // sync, generated catalog only
```
Runtime lookup is always:
```ts
await models.getModel(provider, id)
await models.getModels(provider)
```
## AgentHarness integration
`AgentHarness` receives a `Models` instance.
Rules:
- `AgentHarnessOptions.models` is required
- harness does not snapshot `Models` into 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:
```txt
built-in providers
-> models.json provider wrappers
-> extension provider wrappers/additions
```
Then:
```ts
sessionModels.clearProviders();
for (const provider of layeredProviders) sessionModels.setProvider(provider);
sessionModels.setAuthResolver(codingAgentResolver);
```
coding-agent owns:
- AuthStorage local/OAuth files
- models.json auth sidecar
- `$ENV` and `!command`
- command execution policy
- provider status labels
- login/logout UI
- extension lifecycle
- provider-management slash commands
## Migration TODOs
1. Restore/remove half-implemented old auth/stream-registry changes before starting this design.
2. Redesign `packages/ai/src/models.ts` around provider-owned streams.
3. Remove `StreamFunctions` registry from `Models` public API.
4. Introduce `Provider` with required `auth`, async `getModels()`, `stream()`, and `streamSimple()`.
5. Add lazy API wrappers under `packages/ai/src/ai/`.
6. Move real API implementations under `packages/ai/src/ai/` or adapt existing stream files into that layout.
7. Add concrete provider factories under `packages/ai/src/providers/`.
8. Add `providers/all` explicit aggregate.
9. Add `lazyOAuthProvider()`, `OAuthTarget`, and provider factory options such as `anthropicProvider({ oauth: "node" | "web" | false })`.
10. Convert built-in OAuth attachment to lazy target-specific wrappers.
11. Split generated model catalogs per provider, or mark as follow-up if too large.
12. Replace old global `defaultModels()`/global helpers with explicit instance usage or compatibility entrypoint.
13. Add custom provider helpers:
- `createApiBackedProvider()`
- `createDispatchProvider()` for required mixed-API providers
14. Update `AgentHarness` to use provider-owned `models.streamSimple()` without stream registry lookups.
15. Keep coding-agent compatibility only as needed until the coding-agent `ModelManager` migration.
16. Update tests to construct explicit `Models` instances 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:
```ts
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.