feat(coding-agent): replace model registry with model runtime
Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
This commit is contained in:
+68
-19
@@ -17,6 +17,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
||||
- [Dynamic Providers](#dynamic-providers)
|
||||
- [Auth](#auth)
|
||||
- [How Auth Resolves](#how-auth-resolves)
|
||||
- [Transforming Request Headers](#transforming-request-headers)
|
||||
- [Credential Store](#credential-store)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Tools](#tools)
|
||||
@@ -334,18 +335,45 @@ await models.complete(model, context);
|
||||
await models.complete(model, context, { apiKey: 'sk-explicit' });
|
||||
```
|
||||
|
||||
You can inspect resolution without making a request — useful for status UIs:
|
||||
You can inspect resolution without making a request. Pass a provider ID for provider-scoped auth, or a model to include its static `model.headers`:
|
||||
|
||||
```typescript
|
||||
const auth = await models.getAuth(model);
|
||||
if (auth) {
|
||||
console.log(`configured via ${auth.source}`); // e.g. "ANTHROPIC_API_KEY", "OAuth", "stored credential"
|
||||
const providerAuth = await models.getAuth(model.provider);
|
||||
const modelAuth = await models.getAuth(model);
|
||||
|
||||
if (modelAuth) {
|
||||
console.log(`configured via ${modelAuth.source}`); // e.g. "ANTHROPIC_API_KEY", "OAuth", "stored credential"
|
||||
console.log(modelAuth.auth.headers); // Provider auth headers + model.headers
|
||||
} else {
|
||||
console.log('not configured');
|
||||
}
|
||||
```
|
||||
|
||||
`getAuth()` resolves `undefined` for unconfigured providers and rejects with `ModelsError` when something is actually broken (`"oauth"`: token refresh failed, credential preserved for re-login; `"auth"`: key resolution or credential store failure). Request paths surface the same failures as stream errors.
|
||||
Both overloads resolve credentials, refresh expired OAuth when necessary, and may return an auth-derived `apiKey`, `headers`, or `baseUrl`. `getAuth()` resolves `undefined` for unconfigured providers and rejects with `ModelsError` when something is actually broken (`"oauth"`: token refresh failed, credential preserved for re-login; `"auth"`: key resolution or credential store failure). Request paths surface the same failures as stream errors.
|
||||
|
||||
### Transforming Request Headers
|
||||
|
||||
`Models.stream()`, `complete()`, `streamSimple()`, and `completeSimple()` accept a Models-only `transformHeaders` option. It runs once after provider auth, `model.headers`, and explicit `options.headers` have been merged, but before provider dispatch:
|
||||
|
||||
```typescript
|
||||
const response = await models.completeSimple(model, context, {
|
||||
headers: { "X-Client": "my-app" },
|
||||
transformHeaders: async (headers) => ({
|
||||
...headers,
|
||||
"X-Request-ID": crypto.randomUUID(),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The ordering is:
|
||||
|
||||
```text
|
||||
provider auth headers -> model.headers -> explicit options.headers -> transformHeaders -> Provider.stream*()
|
||||
```
|
||||
|
||||
Header names are merged case-insensitively. Explicit headers override auth/model headers, and the transform has final control; returning `null` for a header suppresses lower-level defaults that support deletion.
|
||||
|
||||
`transformHeaders` belongs to `Models`, not `Provider`. A `Models` implementation must consume it and remove it before calling `Provider.stream*()`. Provider implementations continue receiving ordinary `ApiStreamOptions` or `SimpleStreamOptions` and never handle the transform themselves. Use this option instead of calling `getAuth(model)` before `stream*()`, which would resolve request auth twice.
|
||||
|
||||
### Credential Store
|
||||
|
||||
@@ -359,7 +387,7 @@ const models = createModels({ credentials: myFileBackedStore });
|
||||
// const models = builtinModels({ credentials: myFileBackedStore });
|
||||
```
|
||||
|
||||
The contract is small: `read(providerId)`, `modify(providerId, fn)` (the only write path — a serialized read-modify-write), and `delete(providerId)`. OAuth token refresh runs inside `modify`, so concurrent requests and processes cannot double-refresh a rotated token. A stored credential *owns* its provider: environment variables are only consulted when nothing is stored, and a failed refresh never silently falls back to an env key.
|
||||
The contract is small: `read(providerId)`, `list()` for non-secret `{ providerId, type }` metadata, `modify(providerId, fn)` (the only write path — a serialized read-modify-write), and `delete(providerId)`. Enumeration must not resolve secrets or execute configured key commands. OAuth token refresh runs inside `modify`, so concurrent requests and processes cannot double-refresh a rotated token. A stored credential *owns* its provider: environment variables are only consulted when nothing is stored, and a failed refresh never silently falls back to an env key.
|
||||
|
||||
API-key credentials use the same discriminator as pi's `auth.json` and can carry provider-scoped env/config values:
|
||||
|
||||
@@ -412,7 +440,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
|
||||
| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` |
|
||||
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` |
|
||||
|
||||
Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens). Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location.
|
||||
Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -660,7 +688,7 @@ for (const block of result.output) {
|
||||
}
|
||||
```
|
||||
|
||||
Like the chat side, you can build the collection from parts: `createImagesModels({ credentials?, authContext? })`, the `openrouterImagesProvider()` factory from `@earendil-works/pi-ai/providers/openrouter-images`, and `createImagesProvider({ id, auth, models, refreshModels?, api })` for custom image providers (with `imagesModels.refresh(provider?)` for dynamic lists). Failures never reject — they return an `AssistantImages` with `stopReason: "error"`. The collection's `getAuth(model)` works exactly like the chat-side one.
|
||||
Like the chat side, you can build the collection from parts: `createImagesModels({ credentials?, authContext? })`, the `openrouterImagesProvider()` factory from `@earendil-works/pi-ai/providers/openrouter-images`, and `createImagesProvider({ id, auth, models, refreshModels?, api })` for custom image providers (with `imagesModels.refresh(provider?)` for dynamic lists). Failures never reject — they return an `AssistantImages` with `stopReason: "error"`. The collection's provider-scoped `getAuth(providerId)` works exactly like the chat-side one.
|
||||
|
||||
The old global API (`getImageModel()` / `getImageModels()` / `getImageProviders()` / `generateImages()`) remains available on the [compat entrypoint](#migrating-from-the-old-global-api):
|
||||
|
||||
@@ -982,6 +1010,25 @@ const gateway = createProvider({
|
||||
});
|
||||
```
|
||||
|
||||
Provider-wide endpoint or request transformations belong in the provider's API implementation: wrap the `ProviderStreams` you pass as `api` so every request goes through the transformation before dispatch. The Cloudflare providers do this to materialize account/gateway endpoint placeholders from the resolved provider env:
|
||||
|
||||
```typescript
|
||||
function tenantStreams(streams: ProviderStreams): ProviderStreams {
|
||||
const withTenant = (model: Model<Api>) => ({ ...model, baseUrl: model.baseUrl.replace('{tenant}', tenantId) });
|
||||
return {
|
||||
stream: (model, context, options) => streams.stream(withTenant(model), context, options),
|
||||
streamSimple: (model, context, options) => streams.streamSimple(withTenant(model), context, options),
|
||||
};
|
||||
}
|
||||
|
||||
const tenantGateway = createProvider({
|
||||
id: 'tenant-gateway',
|
||||
auth: { apiKey: envApiKeyAuth('Gateway key', ['GATEWAY_API_KEY']) },
|
||||
models: [/* ... */],
|
||||
api: tenantStreams(openAICompletionsApi()),
|
||||
});
|
||||
```
|
||||
|
||||
Dynamic model lists use `refreshModels`; the provider lists empty until the first `models.refresh()`:
|
||||
|
||||
```typescript
|
||||
@@ -997,7 +1044,7 @@ models.setProvider(llamacpp);
|
||||
await models.refresh('llamacpp');
|
||||
```
|
||||
|
||||
Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags — see [OpenAI Compatibility Settings](#openai-compatibility-settings).
|
||||
Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags. `Models.getAuth(model)` includes those model headers, and stream methods merge them before explicit request headers and `transformHeaders`. See [OpenAI Compatibility Settings](#openai-compatibility-settings).
|
||||
|
||||
Some OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so the system prompt is sent as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too. This commonly applies to Ollama, vLLM, SGLang, and similar OpenAI-compatible servers.
|
||||
|
||||
@@ -1368,7 +1415,7 @@ Several providers support OAuth authentication instead of static API keys:
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||
- **GitHub Copilot** (Copilot subscription)
|
||||
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(callbacks)` runs the interactive flow and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth()` and the request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh.
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh.
|
||||
|
||||
```typescript
|
||||
import { createModels } from '@earendil-works/pi-ai';
|
||||
@@ -1377,34 +1424,36 @@ import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
|
||||
const models = createModels({ credentials: myStore }); // persistent CredentialStore
|
||||
models.setProvider(anthropicProvider());
|
||||
|
||||
// Login: drive the flow with prompt()/notify() callbacks, persist the credential
|
||||
const provider = models.getProvider('anthropic')!;
|
||||
const credential = await provider.auth.oauth!.login({
|
||||
// Login: Models drives the flow and persists the credential
|
||||
await models.login('anthropic', 'oauth', {
|
||||
prompt: async (p) => {
|
||||
// p.type: 'text' | 'secret' | 'select' | 'manual_code'
|
||||
// manual_code prompts race a local callback server; p.signal aborts them when the server wins
|
||||
return await askUser(p.message);
|
||||
},
|
||||
notify: (event) => {
|
||||
// event.type: 'auth_url' | 'device_code' | 'progress'
|
||||
// event.type: 'info' | 'auth_url' | 'device_code' | 'progress'
|
||||
if (event.type === 'info') {
|
||||
console.log(event.message);
|
||||
for (const link of event.links ?? []) console.log(`${link.label ?? 'More information'}: ${link.url}`);
|
||||
}
|
||||
if (event.type === 'auth_url') console.log(`Open: ${event.url}`);
|
||||
if (event.type === 'device_code') console.log(`Code: ${event.userCode} at ${event.verificationUri}`);
|
||||
if (event.type === 'progress') console.log(event.message);
|
||||
},
|
||||
});
|
||||
await myStore.modify('anthropic', async () => credential);
|
||||
|
||||
// From here on, requests resolve and refresh the token automatically
|
||||
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
|
||||
await models.complete(model, context);
|
||||
|
||||
// Logout
|
||||
await myStore.delete('anthropic');
|
||||
await models.logout('anthropic');
|
||||
```
|
||||
|
||||
### Vertex AI
|
||||
|
||||
Vertex AI models support either a Google Cloud API key or Application Default Credentials (ADC):
|
||||
Vertex AI models support either a Google Cloud API key or Application Default Credentials (ADC). Its provider-owned API-key login flow can configure either method:
|
||||
|
||||
- **API key**: Set `GOOGLE_CLOUD_API_KEY` or pass `apiKey` in the call options.
|
||||
- **Local development (ADC)**: Run `gcloud auth application-default login`
|
||||
@@ -1438,7 +1487,7 @@ Credentials are saved to `auth.json` in the current directory.
|
||||
|
||||
### Programmatic OAuth
|
||||
|
||||
The legacy flow functions remain available via the `@earendil-works/pi-ai/oauth` entry point (`loginAnthropic`, `loginOpenAICodex`, `loginGitHubCopilot`, `refreshOAuthToken`, `getOAuthApiKey`); credential storage is the caller's responsibility there. New code should prefer the provider-owned `OAuthAuth` shown above — it composes with the credential store and gets locked auto-refresh for free.
|
||||
Built-in login and refresh flows are private provider implementations. Use provider-owned `OAuthAuth`, which composes with `CredentialStore` and gets locked auto-refresh through `Models`. The `@earendil-works/pi-ai/oauth` entry point retains only type declarations required by coding-agent extension OAuth compatibility.
|
||||
|
||||
Provider notes:
|
||||
|
||||
@@ -1468,7 +1517,7 @@ Compat is a strict superset of the root entrypoint, so a file can switch its imp
|
||||
| `getModels('anthropic')` / `getProviders()` | `models.getModels('anthropic')` / `models.getProviders()` or `getBuiltin*` |
|
||||
| `stream(model, ctx, opts)` (env-key injection) | `models.stream(model, ctx, opts)` (provider auth resolution) |
|
||||
| `registerApiProvider({ api, stream, streamSimple })` | `createProvider({ id, auth, models, api })` + `models.setProvider()` |
|
||||
| `getEnvApiKey('openai')` | `await models.getAuth(model)` |
|
||||
| `getEnvApiKey('openai')` | `await models.getAuth(model.provider)` |
|
||||
| `streamAnthropic(model, ctx, opts)` | `stream` from `@earendil-works/pi-ai/api/anthropic-messages`, or a provider in a collection |
|
||||
| `registerFauxProvider()` | `fauxProvider()` + `models.setProvider()` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user