feat(coding-agent): merge model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:26:11 +02:00
146 changed files with 5908 additions and 4743 deletions
+19 -21
View File
@@ -68,7 +68,7 @@ packages/ai/src/
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
auth/oauth/ # Canonical OAuth implementations (node), lazy-loaded
```
`src/index.ts` must stay core-only. It must not import:
@@ -407,7 +407,7 @@ 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>;
login?(interaction: AuthInteraction): Promise<ApiKeyCredential>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
@@ -424,7 +424,7 @@ export interface ApiKeyAuth {
export interface OAuthAuth {
name: string; // "Anthropic (Claude Pro/Max)"
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>;
login(interaction: AuthInteraction): Promise<OAuthCredential>;
/** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
@@ -445,9 +445,9 @@ export interface AuthContext {
}
```
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.
The `refresh`/`toAuth` split lets `Models` own the locked refresh pattern without closure gymnastics: refresh produces a credential, while `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.
OAuth implementations use the provider-neutral `AuthInteraction` protocol directly. A callback-server flow issues a `manual_code` prompt racing the server and aborts the prompt when the callback wins, so the UI needs no provider-specific callback or static callback-server flag.
### Credentials
@@ -564,7 +564,7 @@ FileCredentialStore ports AuthStorage's lock backend: read = memory snaps
└─ withRuntimeOverrides --api-key
└─ createModels({ credentials: store })
login/logout UI provider.auth.{oauth,apiKey}.login(callbacks) + store.modify/delete
login/logout UI provider.auth.{oauth,apiKey}.login(interaction) + store.modify/delete
status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection)
getOAuthProviders presence of provider.auth.oauth across registered providers
```
@@ -574,7 +574,7 @@ getOAuthProviders presence of provider.auth.oauth across registered pro
One interface serves api-key and OAuth login:
```ts
export interface AuthLoginCallbacks {
export interface AuthInteraction {
/** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */
signal?: AbortSignal;
@@ -612,7 +612,7 @@ export function anthropicProvider(): Provider {
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),
load: () => import("../auth/oauth/anthropic.ts").then((m) => m.anthropicOAuth),
}),
},
models: ANTHROPIC_MODELS,
@@ -632,7 +632,7 @@ export function lazyOAuth(input: {
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`.
The built-in flows in `src/auth/oauth/` implement `OAuthAuth` and `AuthInteraction` directly while remaining Node-targeted and lazy-loaded. Copilot derives its credential-specific request endpoint through `toAuth().baseUrl`.
## Provider wrappers and models.json
@@ -817,7 +817,7 @@ Check items off as they land. Keep this list current; it is the working state fo
### Phase 3 — provider factories + catalogs
- [x] Auth helpers in `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.
- [x] Auth helpers in `src/auth/helpers.ts`: `envApiKeyAuth()` (with secret-prompt `login`), `lazyOAuth()`. OAuth flow loads go through `auth/oauth/load.ts` (bundler-opaque dynamic import); the `OAuthAuth` exports it references land in Phase 4.
- [x] `createProvider()` in `models.ts` (single + mixed `api` map, dispatch on `model.api`, unknown api -> stream error).
- [x] Per-provider factories under `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).
- [x] `providers/all.ts`: `builtinProviders()`, `builtinModels()`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders` re-exports.
@@ -826,8 +826,8 @@ Check items off as they land. Keep this list current; it is the working state fo
### Phase 4 — OAuth adaptation
- [x] Adapt `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.
- [x] No `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.
- [x] Built-in implementations live under `auth/oauth/` and implement `OAuthAuth` directly through `AuthInteraction.prompt()`/`notify()`. They are private provider implementations loaded lazily by provider factories.
- [x] Callback-server flows race a `manual_code` prompt, aborted through `AuthPrompt.signal` once the flow settles. The public `oauth` subpath retains only coding-agent extension compatibility types.
### Phase 5 — packaging
@@ -876,7 +876,7 @@ Decisions:
- Runtime `--api-key` overrides are an explicit 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` stay sync for SDK and extension compatibility, delegating to the collection's last-known sync model lists and fast configured-looking status checks. Dynamic providers update through explicit async `refresh()`, and request auth remains async through `getApiKeyAndHeaders()`/`Models.getAuth()`. Extensions also get the collection itself as the forward API.
- models.json keeps FULL feature parity, implemented as provider decoration: builtin factories wrapped so `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.
- Extension `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.
- Extension `ProviderConfig` parity: provider-keyed `streamSimple`, legacy extension OAuth callbacks adapted to `OAuthAuth`, and full model replacement per provider. Legacy `registerApiProvider` writes stay compat-local for consumers that call global `complete()`; they die with compat.
- Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`.
- Cloudflare: provider-auth substitution (key + `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.
@@ -909,13 +909,11 @@ Ordering for new sessions:
- Wrap Copilot's provider `getModels()` when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL.
- Keep API-key/env-token Copilot behavior unchanged.
- Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout.
7. [ ] Extension OAuth adapter.
- Adapt old extension `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.
- Preserve existing extension runtime compatibility through the `/compat` alias until Phase 10.
7. [x] Extension OAuth adapter.
- Keep only the legacy callback/credential declarations required by coding-agent `ProviderConfig.oauth`.
- `login` maps legacy callbacks/events to `AuthInteraction.prompt()`/`notify()`.
- `refreshToken` maps to `refresh`; `getApiKey` maps to `toAuth`.
- Preserve the type-only pi-ai `oauth` barrel and extension-loader aliases.
8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`.
- It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers.
- `getAll()`, `find()`, and `getAvailable()` remain sync compatibility methods over last-known model lists and fast configured-looking auth status. Do not break the extension-facing `modelRegistry` surface for these reads.
@@ -939,7 +937,7 @@ Ordering for new sessions:
- [ ] AgentSession -> AgentHarness; the registry facade dies in favor of harness `Models`.
- [ ] Move ALL internal `/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.
- [ ] Delete `/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.
- [ ] Delete `/compat`, `env-api-keys.ts`, the extension-loader root-to-compat alias, and the compat-local legacy API registry. The old OAuth registry/provider interface is already gone; the type-only `oauth` barrel remains for extension compatibility.
### Deferred / follow-ups
+16
View File
@@ -4,17 +4,33 @@
### Breaking Changes
- Changed runtime authentication to provider-scoped `Models.checkAuth()`, `getAuth()`, `login()`, and `logout()` APIs. `checkAuth()` now returns `AuthCheck | undefined`, and API-key auth resolvers no longer receive a model.
- Removed the legacy built-in OAuth provider objects, global OAuth registry APIs, and public low-level built-in login/refresh functions. Use canonical `Provider.auth.oauth` methods instead; the `oauth` subpath now retains only extension compatibility types.
- Renamed the canonical login interaction interface from `AuthLoginCallbacks` to `AuthInteraction`; it exposes the provider-neutral `prompt()`/`notify()` protocol used by API-key and OAuth flows.
- Changed the `Models` request contract: `getAuth(model)` now includes model headers, while `getAuth(providerId)` remains provider-scoped, and Models stream options may include `transformHeaders`. Custom `Models` implementations must execute the transform after merging auth/model and explicit headers, then remove it before provider dispatch.
- Changed dynamic model refresh to `Models.refresh(options)`, which refreshes every configured dynamic provider and returns per-provider errors/cancellation state. `Provider.refreshModels(context)` now receives the effective credential, scoped model storage, network policy, and abort signal.
- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)).
### Added
- Added provider-owned authentication and availability resolution to `Models`, including stored OAuth refresh and interactive login support through `CredentialStore`.
- Added async non-secret credential enumeration through `CredentialStore.list()` and credential-aware `Provider.filterModels()` availability policy.
- Added neutral auth-flow information/link events and provider-owned Amazon Bedrock and Google Vertex AI credential selection flows.
- Added `ModelsStore` with an in-memory default for restoring and persisting dynamic provider catalogs.
- Added the dynamic Radius `pi-messages` gateway provider with OAuth and credential-specific catalog refresh.
- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)).
- Added native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)).
- Added `toolChoice` support to OpenAI Codex Responses, including `"required"` to force a tool call.
- Added `toolChoice` support to OpenAI Responses, including required and named tool selection.
### Changed
- Changed `Models.getAuth(model)` to include model headers and added a Models-only `transformHeaders` stream option that runs after auth and explicit header assembly but is not forwarded to providers.
### Fixed
- Fixed Cloudflare Workers AI and AI Gateway streams to materialize account and gateway endpoint placeholders after auth resolution, including compat streaming with custom model objects.
- Fixed lazy provider streams to preserve their final assistant message when forwarding an inner stream.
- Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)).
- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint.
- Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token.
+76 -22
View File
@@ -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,22 +1010,46 @@ const gateway = createProvider({
});
```
Dynamic model lists use `refreshModels`; the provider lists empty until the first `models.refresh()`:
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 `fetchModels`. `Models.refresh()` refreshes every configured dynamic provider, passing its effective API-key or refreshed OAuth credential. A `ModelsStore` persists dynamic catalogs; both stores default to in-memory implementations.
```typescript
const models = createModels({ credentials, modelsStore });
const llamacpp = createProvider({
id: 'llamacpp',
auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => fetchModelsFromServer('http://localhost:8080'),
fetchModels: async ({ signal }) => fetchModelsFromServer('http://localhost:8080', signal),
api: openAICompletionsApi(),
});
models.setProvider(llamacpp);
await models.refresh('llamacpp');
const result = await models.refresh({ signal });
if (result.aborted) console.log('refresh cancelled');
for (const [provider, error] of result.errors) console.error(provider, error);
```
Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags — see [OpenAI Compatibility Settings](#openai-compatibility-settings).
Use `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access. Model reads stay synchronous and return the last restored or refreshed list.
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.
@@ -1369,7 +1421,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';
@@ -1378,34 +1430,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`
@@ -1439,7 +1493,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:
@@ -1469,7 +1523,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()` |
+15 -10
View File
@@ -22,13 +22,20 @@ function createSetupErrorMessage(model: Model<Api>, error: unknown): AssistantMe
};
}
function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable<AssistantMessageEvent>): void {
(async () => {
for await (const event of source) {
target.push(event);
}
target.end();
})();
function hasResult(
source: AsyncIterable<AssistantMessageEvent>,
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
return typeof (source as { result?: unknown }).result === "function";
}
async function forwardStream(
target: AssistantMessageEventStream,
source: AsyncIterable<AssistantMessageEvent>,
): Promise<void> {
for await (const event of source) {
target.push(event);
}
target.end(hasResult(source) ? await source.result() : undefined);
}
/**
@@ -43,9 +50,7 @@ export function lazyStream(
const outer = new AssistantMessageEventStream();
setup()
.then((inner) => {
forwardStream(outer, inner);
})
.then((inner) => forwardStream(outer, inner))
.catch((error) => {
const message = createSetupErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Credential, CredentialStore } from "./types.ts";
import type { Credential, CredentialInfo, CredentialStore } from "./types.ts";
/**
* Default in-memory credential store. Apps inject persistent stores.
@@ -27,6 +27,10 @@ export class InMemoryCredentialStore implements CredentialStore {
return this.credentials.get(providerId);
}
async list(): Promise<readonly CredentialInfo[]> {
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
+3 -3
View File
@@ -9,8 +9,8 @@ import type { ApiKeyAuth, OAuthAuth } from "./types.ts";
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {
return {
name,
login: async (callbacks) => {
const key = await callbacks.prompt({ type: "secret", message: `Enter ${name}` });
login: async (interaction) => {
const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });
return { type: "api_key", key };
},
resolve: async ({ ctx, credential }) => {
@@ -39,7 +39,7 @@ export function lazyOAuth(input: { name: string; load: () => Promise<OAuthAuth>
};
return {
name: input.name,
login: async (callbacks) => (await loaded()).login(callbacks),
login: async (interaction) => (await loaded()).login(interaction),
refresh: async (credential) => (await loaded()).refresh(credential),
toAuth: async (credential) => (await loaded()).toAuth(credential),
};
@@ -6,11 +6,10 @@
*/
import type { Server } from "node:http";
import type { OAuthAuth } from "../../auth/types.ts";
import { getProviderEnvValue } from "../provider-env.ts";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
type CallbackServerInfo = {
server: Server;
@@ -193,7 +192,7 @@ async function exchangeAuthorizationCode(
state: string,
verifier: string,
redirectUri: string,
): Promise<OAuthCredentials> {
): Promise<OAuthCredential> {
let responseBody: string;
try {
responseBody = await postJson(TOKEN_URL, {
@@ -220,27 +219,21 @@ async function exchangeAuthorizationCode(
}
return {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
};
}
/**
* Login with Anthropic OAuth (authorization code + PKCE)
*/
export async function loginAnthropic(options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
}): Promise<OAuthCredentials> {
async function loginAnthropic(interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
let code: string | undefined;
let state: string | undefined;
let redirectUriForExchange = REDIRECT_URI;
let manualInput: string | undefined;
let manualError: Error | undefined;
try {
const authParams = new URLSearchParams({
@@ -253,93 +246,58 @@ export async function loginAnthropic(options: {
code_challenge_method: "S256",
state: verifier,
});
options.onAuth({
interaction.notify({
type: "auth_url",
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
instructions:
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
if (options.onManualCodeInput) {
let manualInput: string | undefined;
let manualError: Error | undefined;
const manualPromise = options
.onManualCodeInput()
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((err) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) {
throw manualError;
}
if (result?.code) {
code = result.code;
state = result.state;
redirectUriForExchange = REDIRECT_URI;
} else if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
code = parsed.code;
state = parsed.state ?? verifier;
}
if (!code) {
await manualPromise;
if (manualError) {
throw manualError;
}
if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
code = parsed.code;
state = parsed.state ?? verifier;
}
}
} else {
const result = await server.waitForCode();
if (result?.code) {
code = result.code;
state = result.state;
redirectUriForExchange = REDIRECT_URI;
}
}
if (!code) {
const input = await options.onPrompt({
message: "Paste the authorization code or full redirect URL:",
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
state = result.state;
} else if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
if (!code) {
throw new Error("Missing authorization code");
await manualPromise;
if (manualError) throw manualError;
if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
code = parsed.code;
state = parsed.state ?? verifier;
}
}
if (!state) {
throw new Error("Missing OAuth state");
}
options.onProgress?.("Exchanging authorization code for tokens...");
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange);
if (!code) throw new Error("Missing authorization code");
if (!state) throw new Error("Missing OAuth state");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI);
} finally {
manualAbort.abort();
server.server.close();
}
}
@@ -347,7 +305,7 @@ export async function loginAnthropic(options: {
/**
* Refresh Anthropic OAuth token
*/
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredential> {
let responseBody: string;
try {
responseBody = await postJson(TOKEN_URL, {
@@ -374,6 +332,7 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
}
return {
type: "oauth",
refresh: data.refresh_token,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
@@ -382,59 +341,10 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)",
async login(callbacks) {
// The manual_code prompt races the local callback server; abort it once
// the flow settles so the UI can dismiss the pending input.
const manualAbort = new AbortController();
try {
const credentials = await loginAnthropic({
onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onManualCodeInput: () =>
callbacks.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
}),
});
return { ...credentials, type: "oauth" };
} finally {
manualAbort.abort();
}
},
async refresh(credential) {
return { ...(await refreshAnthropicToken(credential.refresh)), type: "oauth" };
},
login: loginAnthropic,
refresh: (credential) => refreshAnthropicToken(credential.refresh),
async toAuth(credential) {
return { apiKey: credential.access };
},
};
export const anthropicOAuthProvider: OAuthProviderInterface = {
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginAnthropic({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
return refreshAnthropicToken(credentials.refresh);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
};
@@ -2,16 +2,9 @@
* GitHub Copilot OAuth flow
*/
import type { OAuthAuth, OAuthCredential } from "../../auth/types.ts";
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts";
import type { Api, Model } from "../../types.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
type CopilotCredentials = OAuthCredentials & {
enterpriseUrl?: string;
availableModelIds: string[];
};
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
@@ -44,7 +37,7 @@ type DeviceTokenErrorResponse = {
interval?: number;
};
export function normalizeDomain(input: string): string | null {
function normalizeDomain(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
@@ -81,7 +74,7 @@ function getBaseUrlFromToken(token: string): string | null {
return `https://${apiHost}`;
}
export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
// If we have a token, extract the base URL from proxy-ep
if (token) {
const urlFromToken = getBaseUrlFromToken(token);
@@ -251,7 +244,7 @@ async function pollForGitHubAccessToken(
async function refreshGitHubCopilotAccessToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
): Promise<OAuthCredential> {
const domain = enterpriseDomain || "github.com";
const urls = getUrls(domain);
@@ -275,6 +268,7 @@ async function refreshGitHubCopilotAccessToken(
}
return {
type: "oauth",
refresh: refreshToken,
access: token,
expires: expiresAt * 1000 - 5 * 60 * 1000,
@@ -285,10 +279,7 @@ async function refreshGitHubCopilotAccessToken(
/**
* Refresh GitHub Copilot token
*/
export async function refreshGitHubCopilotToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
async function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string): Promise<OAuthCredential> {
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain);
return {
...credentials,
@@ -326,68 +317,41 @@ async function enableGitHubCopilotModel(token: string, modelId: string, enterpri
* Enable all known GitHub Copilot models that may require policy acceptance.
* Called after successful login to ensure all models are available.
*/
async function enableAllGitHubCopilotModels(
token: string,
enterpriseDomain?: string,
onProgress?: (model: string, success: boolean) => void,
): Promise<void> {
async function enableAllGitHubCopilotModels(token: string, enterpriseDomain?: string): Promise<void> {
const models = Object.values(GITHUB_COPILOT_MODELS);
await Promise.all(
models.map(async (model) => {
const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain);
onProgress?.(model.id, success);
await enableGitHubCopilotModel(token, model.id, enterpriseDomain);
}),
);
}
/**
* Login with GitHub Copilot OAuth (device code flow)
*
* @param options.onDeviceCode - Callback with URL and user code
* @param options.onPrompt - Callback to prompt user for input
* @param options.onProgress - Optional progress callback
* @param options.signal - Optional AbortSignal for cancellation
*/
export async function loginGitHubCopilot(options: {
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
const input = await options.onPrompt({
async function loginGitHubCopilot(interaction: AuthInteraction): Promise<OAuthCredential> {
const input = await interaction.prompt({
type: "text",
message: "GitHub Enterprise URL/domain (blank for github.com)",
placeholder: "company.ghe.com",
allowEmpty: true,
});
if (options.signal?.aborted) {
throw new Error("Login cancelled");
}
if (interaction.signal?.aborted) throw new Error("Login cancelled");
const trimmed = input.trim();
const enterpriseDomain = normalizeDomain(input);
if (trimmed && !enterpriseDomain) {
throw new Error("Invalid GitHub Enterprise URL/domain");
}
if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain");
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
options.onDeviceCode({
interaction.notify({
type: "device_code",
userCode: device.user_code,
verificationUri: device.verification_uri,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal);
const githubAccessToken = await pollForGitHubAccessToken(domain, device, interaction.signal);
const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined);
// Enable all models after successful login
options.onProgress?.("Enabling models...");
interaction.notify({ type: "progress", message: "Enabling models..." });
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined);
// Fetch availability after policy enable so newly enabled models are included,
// while unavailable models are still filtered out.
return {
...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined),
@@ -402,26 +366,10 @@ function copilotEnterpriseDomain(credential: OAuthCredential): string | undefine
export const githubCopilotOAuth: OAuthAuth = {
name: "GitHub Copilot",
login: loginGitHubCopilot,
refresh: (credential) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential)),
async login(callbacks) {
const credentials = await loginGitHubCopilot({
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
signal: callbacks.signal,
});
return { ...credentials, type: "oauth" };
},
async refresh(credential) {
return {
...(await refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential))),
type: "oauth",
};
},
/** Per-credential baseUrl from the token's proxy endpoint replaces the old `modifyModels` rewriting. */
/** Derive the credential-specific proxy endpoint for each request. */
async toAuth(credential) {
return {
apiKey: credential.access,
@@ -429,41 +377,3 @@ export const githubCopilotOAuth: OAuthAuth = {
};
},
};
export const githubCopilotOAuthProvider: OAuthProviderInterface = {
id: "github-copilot",
name: "GitHub Copilot",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginGitHubCopilot({
onDeviceCode: callbacks.onDeviceCode,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
signal: callbacks.signal,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
const creds = credentials as CopilotCredentials;
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
modifyModels(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[] {
const creds = credentials as CopilotCredentials;
const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined;
const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain);
// Older stored Pi auth entries do not have account-specific model IDs yet;
// keep their existing generated-catalog behavior until the next refresh/login.
const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined;
return models.flatMap((m) => {
if (m.provider !== "github-copilot") return [m];
if (availableModelIds && !availableModelIds.has(m.id)) return [];
return [{ ...m, baseUrl }];
});
},
};
@@ -1,4 +1,4 @@
import type { OAuthAuth } from "../../auth/types.ts";
import type { OAuthAuth } from "../types.ts";
/**
* Loads an OAuth flow module through a variable specifier so bundlers cannot
@@ -19,3 +19,10 @@ export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> =>
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> =>
((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise<OAuthAuth> =>
(
(await importOAuthModule("./radius.ts")) as {
createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth;
}
).createRadiusOAuth(options);
@@ -17,18 +17,11 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { OAuthAuth } from "../../auth/types.ts";
import { getProviderEnvValue } from "../provider-env.ts";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type {
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderInterface,
} from "./types.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_BASE_URL = "https://auth.openai.com";
@@ -40,8 +33,8 @@ const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
@@ -406,13 +399,14 @@ function getAccountId(accessToken: string): string | null {
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
function credentialsFromToken(token: OAuthToken): OAuthCredentials {
function credentialsFromToken(token: OAuthToken): OAuthCredential {
const accountId = getAccountId(token.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
type: "oauth",
access: token.access,
refresh: token.refresh,
expires: token.expires,
@@ -425,132 +419,83 @@ async function exchangeAuthorizationCodeForCredentials(
verifier: string,
redirectUri: string,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
): Promise<OAuthCredential> {
return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal));
}
/**
* Login with OpenAI Codex OAuth using the Codex device-code flow.
*/
export async function loginOpenAICodexDeviceCode(options: {
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
const device = await startOpenAICodexDeviceAuth(options.signal);
options.onDeviceCode({
async function loginOpenAICodexDeviceCode(interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await startOpenAICodexDeviceAuth(interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: DEVICE_VERIFICATION_URI,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
});
const code = await pollOpenAICodexDeviceAuth(device, options.signal);
const code = await pollOpenAICodexDeviceAuth(device, interaction.signal);
return exchangeAuthorizationCodeForCredentials(
code.authorizationCode,
code.codeVerifier,
DEVICE_REDIRECT_URI,
options.signal,
interaction.signal,
);
}
/**
* Login with OpenAI Codex OAuth
*
* @param options.onAuth - Called with URL and instructions when auth starts
* @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput)
* @param options.onProgress - Optional progress messages
* @param options.onManualCodeInput - Optional promise that resolves with user-pasted code.
* Races with browser callback - whichever completes first wins.
* Useful for showing paste input immediately alongside browser flow.
* @param options.originator - OAuth originator parameter (defaults to "pi")
*/
export async function loginOpenAICodex(options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
originator?: string;
}): Promise<OAuthCredentials> {
const { verifier, state, url } = await createAuthorizationFlow(options.originator);
async function loginOpenAICodex(interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
const manualAbort = new AbortController();
let code: string | undefined;
let manualCode: string | undefined;
let manualError: Error | undefined;
interaction.notify({
type: "auth_url",
url,
instructions: "A browser window should open. Complete login to finish.",
});
try {
if (options.onManualCodeInput) {
// Race between browser callback and manual input
let manualCode: string | undefined;
let manualError: Error | undefined;
const manualPromise = options
.onManualCodeInput()
.then((input) => {
manualCode = input;
server.cancelWait();
})
.catch((err) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await server.waitForCode();
// If manual input was cancelled, throw that error
if (manualError) {
throw manualError;
}
if (result?.code) {
// Browser callback won
code = result.code;
} else if (manualCode) {
// Manual input won (or callback timed out and user had entered code)
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
code = parsed.code;
}
// If still no code, wait for manual promise to complete and try that
if (!code) {
await manualPromise;
if (manualError) {
throw manualError;
}
if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
code = parsed.code;
}
}
} else {
// Original flow: wait for callback, then prompt if needed
const result = await server.waitForCode();
if (result?.code) {
code = result.code;
}
}
// Fallback to onPrompt if still no code
if (!code) {
const input = await options.onPrompt({
message: "Paste the authorization code (or full redirect URL):",
const manualPromise = interaction
.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
})
.then((input) => {
manualCode = input;
server.cancelWait();
})
.catch((error) => {
manualError = error instanceof Error ? error : new Error(String(error));
server.cancelWait();
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
const result = await server.waitForCode();
if (manualError) throw manualError;
if (result?.code) {
code = result.code;
} else if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
if (!code) {
throw new Error("Missing authorization code");
await manualPromise;
if (manualError) throw manualError;
if (manualCode) {
const parsed = parseAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
}
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI);
if (!code) throw new Error("Missing authorization code");
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
} finally {
manualAbort.abort();
server.close();
}
}
@@ -558,15 +503,15 @@ export async function loginOpenAICodex(options: {
/**
* Refresh OpenAI Codex OAuth token
*/
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredential> {
return credentialsFromToken(await refreshAccessToken(refreshToken));
}
export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
async login(callbacks) {
const method = await callbacks.prompt({
async login(interaction) {
const method = await interaction.prompt({
type: "select",
message: "Select OpenAI Codex login method:",
options: [
@@ -576,89 +521,18 @@ export const openaiCodexOAuth: OAuthAuth = {
});
if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
const credentials = await loginOpenAICodexDeviceCode({
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
signal: callbacks.signal,
});
return { ...credentials, type: "oauth" };
return loginOpenAICodexDeviceCode(interaction);
}
if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
}
// The manual_code prompt races the local callback server; abort it once
// the flow settles so the UI can dismiss the pending input.
const manualAbort = new AbortController();
try {
const credentials = await loginOpenAICodex({
onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onManualCodeInput: () =>
callbacks.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
}),
});
return { ...credentials, type: "oauth" };
} finally {
manualAbort.abort();
}
return loginOpenAICodex(interaction);
},
async refresh(credential) {
return { ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" };
},
refresh: (credential) => refreshOpenAICodexToken(credential.refresh),
async toAuth(credential) {
return { apiKey: credential.access };
},
};
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
id: "openai-codex",
name: "ChatGPT Plus/Pro (Codex Subscription)",
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const loginMethod = await callbacks.onSelect({
message: "Select OpenAI Codex login method:",
options: [
{ id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" },
],
});
if (!loginMethod) {
throw new Error("Login cancelled");
}
if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
return loginOpenAICodexDeviceCode({
onDeviceCode: callbacks.onDeviceCode,
signal: callbacks.signal,
});
}
if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`);
}
return loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
return refreshOpenAICodexToken(credentials.refresh);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
};
@@ -1,10 +1,8 @@
/**
* Radius gateway OAuth flow and model catalog loading.
* Radius gateway OAuth flow.
*
* Radius is a pi-messages gateway. OAuth endpoints are discovered from the
* gateway (`/v1/oauth`); the model catalog comes from `/v1/config` and is
* cached on the stored credential (`gatewayConfig`) so models are available
* at startup and refreshed whenever the token refreshes.
* gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
*
* NOTE: This module uses node:http for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
@@ -18,13 +16,11 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { Api, Model, ThinkingLevelMap } from "../../types.ts";
import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev";
const CALLBACK_HOST = "127.0.0.1";
const CALLBACK_PORT = 1456;
@@ -34,27 +30,6 @@ const TOKEN_EXPIRY_SKEW_MS = 60_000;
const LOGIN_METHOD_BROWSER = "browser";
const LOGIN_METHOD_DEVICE_CODE = "device-code";
/** Model metadata served by the gateway config endpoint. */
export type RadiusGatewayModel = {
id: string;
name: string;
reasoning: boolean;
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: Model<Api>["cost"];
contextWindow: number;
maxTokens: number;
};
export type RadiusGatewayConfig = {
baseUrl: string;
models: RadiusGatewayModel[];
};
export type RadiusOAuthCredentials = OAuthCredentials & {
gatewayConfig?: RadiusGatewayConfig;
};
type RadiusOAuthConfig = {
issuer: string;
authorizationEndpoint: string;
@@ -76,80 +51,6 @@ type DeviceAuthorizationResponse = {
interval?: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeRadiusGatewayUrl(value: string): string {
const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`;
return withScheme.replace(/\/+$/u, "");
}
// The gateway is a trusted first-party service. The shape checks below only
// guard against version skew and stale credential caches: malformed entries
// are dropped rather than failing the whole catalog, and nested fields (e.g.
// `input` members, `cost` rates) are intentionally not validated in depth.
// Do not turn this into strict validation.
function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel {
if (!isRecord(value)) {
return false;
}
return (
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.reasoning === "boolean" &&
Array.isArray(value.input) &&
isRecord(value.cost) &&
typeof value.contextWindow === "number" &&
typeof value.maxTokens === "number"
);
}
function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined {
if (!isRecord(config)) {
return undefined;
}
const baseUrl = config.baseUrl;
const models = config.models;
if (typeof baseUrl !== "string" || !Array.isArray(models)) {
return undefined;
}
return {
baseUrl,
models: models.filter(isRadiusGatewayModel).map((model) => ({ ...model })),
};
}
function getRadiusCredentialConfig(credentials: OAuthCredentials | undefined): RadiusGatewayConfig | undefined {
return sanitizeRadiusGatewayConfig((credentials as RadiusOAuthCredentials | undefined)?.gatewayConfig);
}
function truncateHttpBody(body: string): string {
const trimmed = body.trim();
return trimmed.length > 512 ? `${trimmed.slice(0, 512)}` : trimmed;
}
async function loadRadiusGatewayConfig(gateway: string, apiKey?: string): Promise<RadiusGatewayConfig> {
const headers: Record<string, string> = { accept: "application/json" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const response = await fetch(new URL("/v1/config", gateway), { headers });
if (!response.ok) {
throw new Error(
`Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`,
);
}
const config = sanitizeRadiusGatewayConfig(await response.json());
if (!config) {
throw new Error(`Invalid Radius config from ${gateway}`);
}
return config;
}
async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {
const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
@@ -202,7 +103,7 @@ async function requestOAuthToken(
oauth: RadiusOAuthConfig,
body: URLSearchParams,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
): Promise<OAuthCredential> {
let response: Response;
try {
response = await fetch(oauth.tokenEndpoint, {
@@ -230,6 +131,7 @@ async function requestOAuthToken(
};
return {
type: "oauth",
access: data.access_token,
refresh: data.refresh_token,
expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
@@ -318,7 +220,7 @@ function startOAuthCallbackServer(
});
}
async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
const authorizeUrl = new URL(oauth.authorizationEndpoint);
@@ -333,9 +235,10 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, callbacks.signal);
callbacks.onProgress?.(`Listening for OAuth callback on ${REDIRECT_URI}`);
callbacks.onAuth({
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions: "Continue in your browser.",
});
@@ -343,7 +246,7 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
try {
const code = await callbackServer.waitForCode();
if (!code) {
if (callbacks.signal?.aborted) {
if (interaction.signal?.aborted) {
throw new Error("Login cancelled");
}
throw new Error("OAuth callback did not complete.");
@@ -357,7 +260,7 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
code,
code_verifier: verifier,
}),
callbacks.signal,
interaction.signal,
);
} finally {
callbackServer.close();
@@ -402,22 +305,20 @@ async function requestDeviceAuthorization(
};
}
async function loginWithDeviceCode(
oauth: RadiusOAuthConfig,
callbacks: OAuthLoginCallbacks,
): Promise<OAuthCredentials> {
const device = await requestDeviceAuthorization(oauth, callbacks.signal);
callbacks.onDeviceCode({
async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceAuthorization(oauth, interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.user_code,
verificationUri: device.verification_uri || oauth.verificationEndpoint,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
return pollOAuthDeviceCodeFlow<OAuthCredentials>({
return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal: callbacks.signal,
signal: interaction.signal,
poll: async () => {
try {
const credentials = await requestOAuthToken(
@@ -427,7 +328,7 @@ async function loginWithDeviceCode(
client_id: oauth.clientId,
device_code: device.device_code,
}),
callbacks.signal,
interaction.signal,
);
return { status: "complete", value: credentials };
} catch (error) {
@@ -451,43 +352,21 @@ async function loginWithDeviceCode(
});
}
async function attachGatewayConfig(
gateway: string,
credentials: OAuthCredentials,
previous?: OAuthCredentials,
): Promise<RadiusOAuthCredentials> {
try {
const config = await loadRadiusGatewayConfig(gateway, credentials.access);
return { ...credentials, gatewayConfig: config };
} catch (error) {
// Keep the previous catalog so models do not vanish on transient
// config failures; the next token refresh retries.
const previousConfig = getRadiusCredentialConfig(previous);
if (previousConfig) {
return { ...credentials, gatewayConfig: previousConfig };
}
// No catalog to retain (e.g. initial login): fail loudly instead of
// completing a sign-in that would register no models.
throw error;
}
}
export interface RadiusOAuthProviderOptions {
id: string;
export interface RadiusOAuthOptions {
name: string;
gateway: string;
}
export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions): OAuthProviderInterface {
export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
const gateway = normalizeRadiusGatewayUrl(options.gateway);
return {
id: options.id,
name: options.name,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
async login(interaction): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const loginMethod = await callbacks.onSelect({
const loginMethod = await interaction.prompt({
type: "select",
message: `Sign in to ${options.name}:`,
options: [
{ id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" },
@@ -497,61 +376,35 @@ export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions):
},
],
});
if (!loginMethod) {
throw new Error("Login cancelled");
}
let credentials: OAuthCredentials;
let credential: OAuthCredential;
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
credentials = await loginWithDeviceCode(oauth, callbacks);
credential = await loginWithDeviceCode(oauth, interaction);
} else if (loginMethod === LOGIN_METHOD_BROWSER) {
credentials = await loginWithBrowser(oauth, callbacks);
credential = await loginWithBrowser(oauth, interaction);
} else {
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
}
return attachGatewayConfig(gateway, credentials);
return credential;
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
async refresh(credential, signal): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const refreshed = await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: "refresh_token",
client_id: oauth.clientId,
refresh_token: credentials.refresh,
refresh_token: credential.refresh,
}),
signal,
);
return attachGatewayConfig(gateway, refreshed, credentials);
return refreshed;
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
modifyModels(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[] {
const config = getRadiusCredentialConfig(credentials);
if (!config) {
return models;
}
// Keep models already registered for this provider (e.g. models.json
// custom entries) and add catalog models that are not present.
const existingIds = new Set(models.filter((model) => model.provider === options.id).map((model) => model.id));
const added = config.models
.filter((model) => !existingIds.has(model.id))
.map(
(model) =>
({
...model,
api: "pi-messages",
provider: options.id,
baseUrl: config.baseUrl,
}) as Model<Api>,
);
return [...models, ...added];
async toAuth(credential) {
return { apiKey: credential.access };
},
};
}
+9 -11
View File
@@ -1,4 +1,4 @@
import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv } from "../types.ts";
import type { ProviderEnv } from "../types.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
@@ -28,9 +28,6 @@ export class ModelsError extends Error {
}
}
/** Model shape auth resolution receives: chat or image-generation models. */
export type AuthModel = Model<Api> | ImagesModel<ImagesApi>;
/**
* Auth resolution shared by the `Models` and `ImagesModels` collections.
* A stored credential owns the provider: ambient/env is consulted only when
@@ -39,7 +36,6 @@ export type AuthModel = Model<Api> | ImagesModel<ImagesApi>;
*/
export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth },
model: AuthModel,
credentials: CredentialStore,
authContext: AuthContext,
overrides?: AuthResolutionOverrides,
@@ -47,7 +43,7 @@ export async function resolveProviderAuth(
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, {
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
type: "api_key",
key: overrides.apiKey,
env: overrides.env,
@@ -61,13 +57,15 @@ export async function resolveProviderAuth(
}
if (stored.type === "api_key" && provider.auth.apiKey) {
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;
return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, credential);
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential);
}
return undefined;
}
// Ambient (env vars, AWS profiles, ADC files).
return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, model, undefined) : undefined;
return provider.auth.apiKey
? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined)
: undefined;
}
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
@@ -122,13 +120,13 @@ async function resolveStoredOAuth(
async function resolveApiKey(
authContext: AuthContext,
apiKey: ApiKeyAuth,
model: AuthModel,
providerId: string,
credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> {
try {
return await apiKey.resolve({ model, ctx: authContext, credential });
return await apiKey.resolve({ ctx: authContext, credential });
} catch (error) {
throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error });
throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error });
}
}
+49 -14
View File
@@ -1,5 +1,4 @@
import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv, ProviderHeaders } from "../types.ts";
import type { OAuthCredentials } from "../utils/oauth/types.ts";
import type { ProviderEnv, ProviderHeaders } from "../types.ts";
/**
* Request auth for a single model request. If a value cannot be expressed as
@@ -21,7 +20,15 @@ export interface ApiKeyCredential {
env?: ProviderEnv;
}
/** Stored OAuth credential (`access`, `refresh`, `expires` from OAuthCredentials). */
/** OAuth token data returned by extension compatibility flows. */
export interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
/** Stored canonical OAuth credential. */
export interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
@@ -29,6 +36,12 @@ export interface OAuthCredential extends OAuthCredentials {
/** One type-tagged credential per provider — the shape of today's auth.json. */
export type Credential = ApiKeyCredential | OAuthCredential;
/** Non-secret credential metadata for account/status enumeration. */
export interface CredentialInfo {
providerId: string;
type: Credential["type"];
}
/**
* App-owned credential storage, keyed by `Provider.id`, one credential per
* provider. `modify` is the only write path, so every mutation is a
@@ -51,6 +64,12 @@ export interface CredentialStore {
*/
read(providerId: string): Promise<Credential | undefined>;
/**
* List stored credential metadata without resolving or exposing secrets.
* Implementations must not execute configured API-key commands while listing.
*/
list(): Promise<readonly CredentialInfo[]>;
/**
* Serialized write — the only write path. `fn` sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it;
@@ -84,6 +103,13 @@ export interface AuthResult {
source?: string;
}
export interface AuthCheck {
source?: string;
type: "api_key" | "oauth";
}
export type AuthType = "api_key" | "oauth";
/**
* Prompt shown to the user during login. `signal` lets the flow cancel a
* pending prompt when an out-of-band event resolves the step, e.g. a
@@ -97,7 +123,13 @@ export type AuthPrompt = { signal?: AbortSignal } & (
| { type: "manual_code"; message: string; placeholder?: string }
);
export interface AuthInfoLink {
url: string;
label?: string;
}
export type AuthEvent =
| { type: "info"; message: string; links?: readonly AuthInfoLink[] }
| { type: "auth_url"; url: string; instructions?: string }
| {
type: "device_code";
@@ -115,7 +147,7 @@ export type AuthEvent =
* id). Rejects on cancel/abort. `signal` aborts the whole login flow;
* per-prompt cancellation uses `AuthPrompt.signal`.
*/
export interface AuthLoginCallbacks {
export interface AuthInteraction {
signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>;
@@ -131,19 +163,22 @@ export interface ApiKeyAuth {
name: string;
/** Interactive setup (prompt for key/provider env). Absent = ambient-only. */
login?(callbacks: AuthLoginCallbacks): Promise<ApiKeyCredential>;
login?(interaction: AuthInteraction): Promise<ApiKeyCredential>;
/**
* Optional side-effect-free availability check. Use this when `resolve()` may
* execute commands or perform other request-time work. Missing means Models
* checks availability by resolving auth.
*/
check?(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthCheck | undefined>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`).
* undefined = not configured. Receives the chat or image-generation model
* the request is for (both carry `provider` and `baseUrl`).
* undefined = not configured. Resolution is provider-scoped; model-specific
* endpoint preparation happens after auth has been resolved.
*/
resolve(input: {
model: Model<Api> | ImagesModel<ImagesApi>;
ctx: AuthContext;
credential?: ApiKeyCredential;
}): Promise<AuthResult | undefined>;
resolve(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthResult | undefined>;
}
/**
@@ -155,13 +190,13 @@ export interface OAuthAuth {
/** Display name, e.g. "Anthropic (Claude Pro/Max)". */
name: string;
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>;
login(interaction: AuthInteraction): Promise<OAuthCredential>;
/**
* Exchange the refresh token. Network call; throws on failure
* (invalid_grant etc.). `Models` runs this under the store lock.
*/
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
refresh(credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredential>;
/**
* Side-effect-free derivation of request auth from a valid credential.
+63 -92
View File
@@ -1,71 +1,74 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
import { builtinProviders } from "./providers/all.ts";
const AUTH_FILE = "auth.json";
const PROVIDERS = getOAuthProviders();
const PROVIDERS = builtinProviders().filter(
(provider): provider is Provider & { auth: { oauth: NonNullable<Provider["auth"]["oauth"]> } } =>
provider.auth.oauth !== undefined,
);
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
function loadAuth(): Record<string, { type: "oauth" } & OAuthCredentials> {
function loadAuth(): Record<string, OAuthCredential> {
if (!existsSync(AUTH_FILE)) return {};
try {
return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
return JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as Record<string, OAuthCredential>;
} catch {
return {};
}
}
function saveAuth(auth: Record<string, { type: "oauth" } & OAuthCredentials>): void {
function saveAuth(auth: Record<string, OAuthCredential>): void {
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
}
async function login(providerId: OAuthProviderId): Promise<void> {
const provider = getOAuthProvider(providerId);
if (!provider) {
console.error(`Unknown provider: ${providerId}`);
process.exit(1);
async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {
if (authPrompt.type === "select") {
console.log(`\n${authPrompt.message}`);
for (let index = 0; index < authPrompt.options.length; index++) {
console.log(` ${index + 1}. ${authPrompt.options[index].label}`);
}
const choice = Number.parseInt(await prompt(rl, `Enter number (1-${authPrompt.options.length}): `), 10) - 1;
const selected = authPrompt.options[choice];
if (!selected) throw new Error("Invalid selection");
return selected.id;
}
return prompt(rl, `${authPrompt.message}${authPrompt.placeholder ? ` (${authPrompt.placeholder})` : ""}: `);
}
async function login(providerId: string): Promise<void> {
const provider = PROVIDERS.find((entry) => entry.id === providerId);
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
try {
const credentials = await provider.login({
onAuth: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.url}`);
if (info.instructions) console.log(info.instructions);
console.log();
},
onDeviceCode: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
console.log(`Enter code: ${info.userCode}`);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
onSelect: async (p) => {
console.log(`\n${p.message}`);
for (let i = 0; i < p.options.length; i++) {
console.log(` ${i + 1}. ${p.options[i].label}`);
const credential = await provider.auth.oauth.login({
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
notify: (event) => {
switch (event.type) {
case "auth_url":
console.log(`\nOpen this URL in your browser:\n${event.url}`);
if (event.instructions) console.log(event.instructions);
break;
case "device_code":
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
console.log(`Enter code: ${event.userCode}`);
break;
case "info":
case "progress":
console.log(event.message);
break;
}
const choice = await promptFn(`Enter number (1-${p.options.length}):`);
const index = parseInt(choice, 10) - 1;
return p.options[index]?.id;
},
onProgress: (msg) => console.log(msg),
});
const auth = loadAuth();
auth[providerId] = { type: "oauth", ...credentials };
auth[providerId] = credential;
saveAuth(auth);
console.log(`\nCredentials saved to ${AUTH_FILE}`);
} finally {
rl.close();
@@ -75,73 +78,41 @@ async function login(providerId: OAuthProviderId): Promise<void> {
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") {
const providerList = PROVIDERS.map((p) => ` ${p.id.padEnd(20)} ${p.name}`).join("\n");
console.log(`Usage: npx @earendil-works/pi-ai <command> [provider]
Commands:
login [provider] Login to an OAuth provider
list List available providers
Providers:
${providerList}
Examples:
npx @earendil-works/pi-ai login # interactive provider selection
npx @earendil-works/pi-ai login anthropic # login to specific provider
npx @earendil-works/pi-ai list # list providers
`);
const providerList = PROVIDERS.map((provider) => ` ${provider.id.padEnd(20)} ${provider.name}`).join("\n");
console.log(
`Usage: npx @earendil-works/pi-ai <command> [provider]\n\nCommands:\n login [provider] Login to an OAuth provider\n list List available providers\n\nProviders:\n${providerList}`,
);
return;
}
if (command === "list") {
console.log("Available OAuth providers:\n");
for (const p of PROVIDERS) {
console.log(` ${p.id.padEnd(20)} ${p.name}`);
}
for (const provider of PROVIDERS) console.log(`${provider.id.padEnd(20)} ${provider.name}`);
return;
}
if (command === "login") {
let provider = args[1] as OAuthProviderId | undefined;
if (!provider) {
let providerId = args[1];
if (!providerId) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider:\n");
for (let i = 0; i < PROVIDERS.length; i++) {
console.log(` ${i + 1}. ${PROVIDERS[i].name}`);
try {
for (let index = 0; index < PROVIDERS.length; index++) {
console.log(` ${index + 1}. ${PROVIDERS[index].name}`);
}
const index = Number.parseInt(await prompt(rl, `Enter number (1-${PROVIDERS.length}): `), 10) - 1;
providerId = PROVIDERS[index]?.id;
} finally {
rl.close();
}
console.log();
const choice = await prompt(rl, `Enter number (1-${PROVIDERS.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= PROVIDERS.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = PROVIDERS[index].id;
}
if (!PROVIDERS.some((p) => p.id === provider)) {
console.error(`Unknown provider: ${provider}`);
console.error(`Use 'npx @earendil-works/pi-ai list' to see available providers`);
process.exit(1);
if (!providerId || !PROVIDERS.some((provider) => provider.id === providerId)) {
throw new Error(`Unknown provider: ${providerId ?? ""}`);
}
console.log(`Logging in to ${provider}...`);
await login(provider);
await login(providerId);
return;
}
console.error(`Unknown command: ${command}`);
console.error(`Use 'npx @earendil-works/pi-ai --help' for usage`);
process.exit(1);
throw new Error(`Unknown command: ${command}`);
}
main().catch((err) => {
console.error("Error:", err.message);
main().catch((error: unknown) => {
console.error("Error:", error instanceof Error ? error.message : String(error));
process.exit(1);
});
+21 -7
View File
@@ -39,6 +39,7 @@ import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
import { piMessagesApi } from "./api/pi-messages.lazy.ts";
import { getEnvApiKey } from "./env-api-keys.ts";
import type { ModelsApiStreamOptions } from "./models.ts";
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts";
export type { BuiltinProvider } from "./providers/all.ts";
@@ -228,9 +229,14 @@ function withEnvApiKey<TOptions extends StreamOptions>(
return { ...options, apiKey } as TOptions;
}
function shouldUseBuiltinModels(model: Model<Api>): boolean {
const builtin = compatModels.getModel(model.provider, model.id);
return builtin?.api === model.api && getApiProvider(model.api) === builtinApiProviderInstances.get(model.api);
function hasResolvedCloudflareAuth(options: StreamOptions | undefined): boolean {
return hasExplicitApiKey(options?.apiKey) || typeof options?.headers?.["cf-aig-authorization"] === "string";
}
function getBuiltinProviderForModel(model: Model<Api>) {
if (getApiProvider(model.api) !== builtinApiProviderInstances.get(model.api)) return undefined;
const provider = compatModels.getProvider(model.provider);
return provider?.getModels().some((candidate) => candidate.api === model.api) ? provider : undefined;
}
function resolveApiProvider(api: Api) {
@@ -246,8 +252,12 @@ export function stream<TApi extends Api>(
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStream {
if (shouldUseBuiltinModels(model)) {
return compatModels.stream(model, context, options as ApiStreamOptions<TApi> | undefined);
const builtinProvider = getBuiltinProviderForModel(model);
if (builtinProvider) {
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
return compatModels.stream(model, context, options as ModelsApiStreamOptions<TApi> | undefined);
}
return builtinProvider.stream(model, context, withEnvApiKey(model, options) as ApiStreamOptions<TApi>);
}
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
@@ -267,8 +277,12 @@ export function streamSimple<TApi extends Api>(
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
if (shouldUseBuiltinModels(model)) {
return compatModels.streamSimple(model, context, options);
const builtinProvider = getBuiltinProviderForModel(model);
if (builtinProvider) {
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
return compatModels.streamSimple(model, context, options);
}
return builtinProvider.streamSimple(model, context, withEnvApiKey(model, options));
}
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, withEnvApiKey(model, options));
@@ -0,0 +1,45 @@
import type { OAuthCredentials } from "../auth/types.ts";
/** Legacy extension OAuth prompt. */
export interface OAuthPrompt {
message: string;
placeholder?: string;
allowEmpty?: boolean;
}
/** Legacy extension OAuth authorization link. */
export interface OAuthAuthInfo {
url: string;
instructions?: string;
}
/** Legacy extension OAuth device-code notification. */
export interface OAuthDeviceCodeInfo {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}
export interface OAuthSelectOption {
id: string;
label: string;
}
export interface OAuthSelectPrompt {
message: string;
options: OAuthSelectOption[];
}
/** Callback surface retained only for coding-agent extension compatibility. */
export interface OAuthLoginCallbacks {
onAuth(info: OAuthAuthInfo): void;
onDeviceCode(info: OAuthDeviceCodeInfo): void;
onPrompt(prompt: OAuthPrompt): Promise<string>;
onProgress?(message: string): void;
onManualCodeInput?(): Promise<string>;
onSelect(prompt: OAuthSelectPrompt): Promise<string | undefined>;
signal?: AbortSignal;
}
export type { OAuthCredentials };
+1 -1
View File
@@ -82,7 +82,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
radius: "PI_GATEWAY_API_KEY",
radius: "RADIUS_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
+14 -7
View File
@@ -1,6 +1,6 @@
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import type { CreateModelsOptions } from "./models.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts";
@@ -68,11 +68,12 @@ export interface ImagesModels {
refresh(provider?: string): Promise<void>;
/**
* Resolve request auth for an image model. Same contract as
* Resolve request auth by provider id or image model. Same contract as
* `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
* `ModelsError` ("oauth"/"auth") on real failures.
*/
getAuth(model: ImagesModel<ImagesApi>): Promise<AuthResult | undefined>;
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/**
* Generate images through the owning provider with auth resolved and
@@ -167,10 +168,16 @@ class ImagesModelsImpl implements MutableImagesModels {
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
}
async getAuth(model: ImagesModel<ImagesApi>): Promise<AuthResult | undefined> {
const provider = this.providers.get(model.provider);
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | ImagesModel<ImagesApi>,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
const provider = this.providers.get(providerId);
if (!provider) return undefined;
return resolveProviderAuth(provider, model, this.credentials, this.authContext);
return resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
}
async generateImages(
@@ -184,7 +191,7 @@ class ImagesModelsImpl implements MutableImagesModels {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
}
const resolution = await resolveProviderAuth(provider, model, this.credentials, this.authContext, {
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
});
+9 -13
View File
@@ -22,27 +22,23 @@ export * from "./auth/context.ts";
export * from "./auth/credential-store.ts";
export * from "./auth/helpers.ts";
export * from "./auth/types.ts";
export type {
OAuthAuthInfo,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./compat/extension-oauth-types.ts";
export * from "./images-models.ts";
export * from "./models.ts";
export * from "./models-store.ts";
export * from "./providers/faux.ts";
export * from "./session-resources.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
export * from "./utils/typebox-helpers.ts";
+35
View File
@@ -0,0 +1,35 @@
import type { Api, Model } from "./types.ts";
/** Persistent model catalogs keyed by provider ID. */
export interface ModelsStore {
read(providerId: string): Promise<readonly Model<Api>[] | undefined>;
write(providerId: string, models: readonly Model<Api>[]): Promise<void>;
delete(providerId: string): Promise<void>;
}
/** ModelsStore scoped to one provider. Providers cannot access other providers' catalogs. */
export interface ProviderModelsStore {
read(): Promise<readonly Model<Api>[] | undefined>;
write(models: readonly Model<Api>[]): Promise<void>;
delete(): Promise<void>;
}
export class InMemoryModelsStore implements ModelsStore {
private readonly models = new Map<string, readonly Model<Api>[]>();
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
const models = this.models.get(providerId);
return models?.map((model) => structuredClone(model));
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
this.models.set(
providerId,
models.map((model) => structuredClone(model)),
);
}
async delete(providerId: string): Promise<void> {
this.models.delete(providerId);
}
}
+323 -80
View File
@@ -1,8 +1,18 @@
import { lazyStream } from "./api/lazy.ts";
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type {
AuthCheck,
AuthContext,
AuthInteraction,
AuthResult,
AuthType,
Credential,
CredentialStore,
ProviderAuth,
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ProviderModelsStore } from "./models-store.ts";
import type {
Api,
ApiStreamOptions,
@@ -19,7 +29,35 @@ import type {
Usage,
} from "./types.ts";
export { type AuthModel, ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
export interface RefreshModelsContext {
/** Effective configured credential. OAuth credentials are refreshed before network access. */
credential?: Credential;
/** Persistent model storage scoped to this provider ID. */
store: ProviderModelsStore;
/** False during offline/cache-only initialization. */
allowNetwork: boolean;
signal?: AbortSignal;
}
export interface ModelsRefreshOptions {
allowNetwork?: boolean;
signal?: AbortSignal;
}
export interface ModelsRefreshResult {
aborted: boolean;
errors: ReadonlyMap<string, Error>;
}
export interface ModelsStreamTransforms {
/** Transform fully assembled model/auth/request headers before provider dispatch. */
transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;
}
export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsStreamTransforms;
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms;
/**
* A provider is the concrete runtime unit. It owns id/name/base metadata,
@@ -55,13 +93,18 @@ export interface Provider<TApi extends Api = Api> {
getModels(): readonly Model<TApi>[];
/**
* Dynamic providers only: fetch and update the model list. Side-effect-free
* discovery (no loading/downloading); provider-specific model lifecycle
* belongs in app commands. Concurrent calls share one in-flight fetch.
* May reject (network); on rejection the model list stays at its last-known
* state and a later call retries.
* Dynamic providers only: restore the provider-scoped stored catalog and optionally fetch
* a newer list using the effective credential. Implementations must retain their previous
* list on failure and honor the shared abort signal for network requests.
*/
refreshModels?(): Promise<void>;
refreshModels?(context: RefreshModelsContext): Promise<void>;
/**
* Optional provider policy for credential-specific model availability.
* `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
* applies this filter after confirming that provider auth is configured.
*/
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
stream<T extends TApi>(
model: Model<T>,
@@ -94,38 +137,49 @@ export interface Models {
getModel(provider: string, id: string): Model<Api> | undefined;
/**
* Ask dynamic providers to re-fetch their model lists. With a provider id,
* rejects with `ModelsError` ("model_source") on that provider's fetch
* failure; without one, refreshes all providers concurrently best-effort.
* Static providers (no `refreshModels`) are no-ops.
* Refresh every configured dynamic provider concurrently. Provider errors and cancellation
* are returned without rejecting; static and unconfigured providers are skipped.
*/
refresh(provider?: string): Promise<void>;
refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
checkAuth(providerId: string): Promise<AuthCheck | undefined>;
/** Return models whose providers have complete auth configuration. */
getAvailable(providerId?: string): Promise<readonly Model<Api>[]>;
/**
* Resolve request auth for a model. Includes a source label for status UI.
* Resolve provider-scoped auth by provider id, or provider auth plus static
* model headers when passed a model. Includes a source label for status UI.
* Resolves `undefined` when the provider is unknown or unconfigured.
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
* stored credential is preserved for retry; re-login fixes it), code "auth"
* when api-key resolution or the credential store fails. Request paths
* surface rejections as stream errors; status/availability UIs catch them
* and render "needs re-login" instead of treating them as unconfigured.
* surface rejections as stream errors.
*/
getAuth(model: Model<Api>): Promise<AuthResult | undefined>;
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/** Run a provider-owned login flow and persist its returned credential. */
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
/** Remove the stored credential for a provider. */
logout(providerId: string): Promise<void>;
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream;
complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
}
export interface MutableModels extends Models {
@@ -137,16 +191,35 @@ export interface MutableModels extends Models {
export interface CreateModelsOptions {
credentials?: CredentialStore;
modelsStore?: ModelsStore;
authContext?: AuthContext;
}
function mergeHeaders(
base: ProviderHeaders | undefined,
override: ProviderHeaders | undefined,
): ProviderHeaders | undefined {
if (!base && !override) return undefined;
const merged = { ...base };
for (const [name, value] of Object.entries(override ?? {})) {
const lowerName = name.toLowerCase();
for (const existingName of Object.keys(merged)) {
if (existingName.toLowerCase() === lowerName) delete merged[existingName];
}
merged[name] = value;
}
return merged;
}
class ModelsImpl implements MutableModels {
private providers = new Map<string, Provider>();
private credentials: CredentialStore;
private modelsStore: ModelsStore;
private authContext: AuthContext;
constructor(options?: CreateModelsOptions) {
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
this.authContext = options?.authContext ?? defaultAuthContext();
}
@@ -196,28 +269,177 @@ class ModelsImpl implements MutableModels {
return this.getModels(provider).find((model) => model.id === id);
}
async refresh(provider?: string): Promise<void> {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry?.refreshModels) return;
try {
await entry.refreshModels();
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error });
}
return;
}
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
const allowNetwork = options.allowNetwork ?? true;
const errors = new Map<string, Error>();
const refreshable = Array.from(this.providers.values()).filter(
(provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
provider.refreshModels !== undefined,
);
// Cannot reject: the async mapper turns even sync throws from ill-behaved
// providers into rejections, and allSettled captures all of them.
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
await Promise.all(
refreshable.map(async (provider) => {
if (options.signal?.aborted) return;
const store: ProviderModelsStore = {
read: () => this.modelsStore.read(provider.id),
write: (models) => this.modelsStore.write(provider.id, models),
delete: () => this.modelsStore.delete(provider.id),
};
let stored: Credential | undefined;
try {
stored = await this.readCredential(provider.id);
const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal);
if (!credential) return;
await provider.refreshModels({ credential, store, allowNetwork, signal: options.signal });
} catch (error) {
if (!options.signal?.aborted) {
errors.set(
provider.id,
error instanceof Error
? error
: new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),
);
}
try {
await provider.refreshModels({
credential: stored,
store,
allowNetwork: false,
signal: options.signal,
});
} catch {
// Preserve the original auth/network error; cache restoration is best-effort here.
}
}
}),
);
return { aborted: options.signal?.aborted ?? false, errors };
}
async getAuth(model: Model<Api>): Promise<AuthResult | undefined> {
const provider = this.providers.get(model.provider);
private async resolveRefreshCredential(
provider: Provider,
stored: Credential | undefined,
allowNetwork: boolean,
signal?: AbortSignal,
): Promise<Credential | undefined> {
if (stored?.type === "oauth") {
const oauth = provider.auth.oauth;
if (!oauth) return undefined;
if (!allowNetwork || Date.now() < stored.expires) return stored;
if (signal?.aborted) return undefined;
const post = await this.credentials.modify(provider.id, async (current) => {
if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
return oauth.refresh(current, signal);
});
return post?.type === "oauth" ? post : undefined;
}
const apiKey = provider.auth.apiKey;
if (!apiKey) return undefined;
const credential = stored?.type === "api_key" ? stored : undefined;
const result = await apiKey.resolve({ ctx: this.authContext, credential });
if (!result) return undefined;
return { type: "api_key", key: result.auth.apiKey, env: result.env };
}
private async readCredential(providerId: string): Promise<Credential | undefined> {
try {
return await this.credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
}
private async checkProviderAuth(
provider: Provider,
credential: Credential | undefined,
): Promise<AuthCheck | undefined> {
if (credential?.type === "oauth") {
return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;
}
const apiKey = provider.auth.apiKey;
if (!apiKey) return undefined;
if (apiKey.check) {
try {
return await apiKey.check({
ctx: this.authContext,
credential: credential?.type === "api_key" ? credential : undefined,
});
} catch (error) {
throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });
}
}
const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext);
return resolution ? { source: resolution.source, type: "api_key" } : undefined;
}
async checkAuth(providerId: string): Promise<AuthCheck | undefined> {
const provider = this.providers.get(providerId);
if (!provider) return undefined;
return resolveProviderAuth(provider, model, this.credentials, this.authContext);
return this.checkProviderAuth(provider, await this.readCredential(providerId));
}
async getAvailable(providerId?: string): Promise<readonly Model<Api>[]> {
const providers = providerId
? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
: this.getProviders();
const checks = await Promise.all(
providers.map(async (provider) => {
const credential = await this.readCredential(provider.id);
return { provider, credential, auth: await this.checkProviderAuth(provider, credential) };
}),
);
return checks.flatMap(({ provider, credential, auth }) => {
if (!auth) return [];
const models = provider.getModels();
return provider.filterModels?.(models, credential) ?? models;
});
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
const provider = this.providers.get(providerId);
if (!provider) return undefined;
const result = await resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;
return {
...result,
auth: {
...result.auth,
headers: mergeHeaders(result.auth.headers, providerOrModel.headers),
},
};
}
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
const provider = this.providers.get(providerId);
if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);
const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const credential = await method.login(interaction);
try {
await this.credentials.modify(providerId, async () => credential);
} catch (error) {
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
return credential;
}
async logout(providerId: string): Promise<void> {
try {
await this.credentials.delete(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });
}
}
private requireProvider(model: Model<Api>): Provider {
@@ -228,30 +450,28 @@ class ModelsImpl implements MutableModels {
return provider;
}
private async applyAuth<TOptions extends StreamOptions>(
private async applyAuth<TOptions extends StreamOptions & ModelsStreamTransforms>(
model: Model<Api>,
options: TOptions | undefined,
): Promise<{ requestModel: Model<Api>; requestOptions: TOptions | undefined }> {
const resolution = await resolveProviderAuth(
this.requireProvider(model),
model,
this.credentials,
this.authContext,
{
apiKey: options?.apiKey,
env: options?.env,
},
);
const auth = resolution?.auth;
if (!auth) return { requestModel: model, requestOptions: options };
): Promise<{ requestModel: Model<Api>; requestOptions: StreamOptions | undefined }> {
this.requireProvider(model);
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
});
if (!resolution) {
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
}
const auth = resolution.auth;
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
// Explicit request options win per-field; headers/env merge per key.
// Explicit request options win per-field; the Models-only transform runs last.
const apiKey = options?.apiKey ?? auth.apiKey;
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
let headers = mergeHeaders(auth.headers, options?.headers);
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
const requestOptions = { ...options, apiKey, headers, env } as TOptions;
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions;
return { requestModel, requestOptions };
}
@@ -259,11 +479,14 @@ class ModelsImpl implements MutableModels {
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options as StreamOptions | undefined);
const { requestModel, requestOptions } = await this.applyAuth(
model,
options as ModelsApiStreamOptions<Api> | undefined,
);
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
});
}
@@ -271,20 +494,24 @@ class ModelsImpl implements MutableModels {
async complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage> {
return this.stream(model, context, options).result();
}
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, context, requestOptions);
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
});
}
async completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage> {
async completeSimple(
model: Model<Api>,
context: Context,
options?: ModelsSimpleStreamOptions,
): Promise<AssistantMessage> {
return this.streamSimple(model, context, options).result();
}
}
@@ -301,16 +528,11 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
headers?: ProviderHeaders;
/** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth;
/** Initial model list (empty for purely dynamic providers). */
/** Static baseline model list (empty for purely dynamic providers). */
models: readonly Model<TApi>[];
/**
* Dynamic providers: fetch the current list. Stored on success; concurrent
* calls share one in-flight fetch. May reject: the stored list then stays
* at its last-known state, the rejection propagates to the caller of
* `refreshModels()` (wrapped as ModelsError "model_source" by
* `Models.refresh(provider)`), and a later call retries.
*/
refreshModels?: () => Promise<readonly Model<TApi>[]>;
/** Fetch a dynamic model overlay. createProvider restores/persists it through ModelsStore. */
fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
}
@@ -322,9 +544,19 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
* produces a stream error.
*/
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
let models = input.models;
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
let inflightRefresh: Promise<void> | undefined;
const refreshModels = input.refreshModels;
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
};
const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
@@ -350,12 +582,22 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: () => models,
refreshModels: refreshModels
? () => {
getModels: currentModels,
refreshModels: fetchModels
? (context) => {
inflightRefresh ??= (async () => {
try {
models = await refreshModels();
const stored = await context.store.read();
if (stored) {
dynamicModels = stored
.filter((model) => model.provider === input.id)
.map((model) => model as Model<TApi>);
}
if (!context.allowNetwork || context.signal?.aborted) return;
const refreshed = await fetchModels(context);
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write(refreshed);
} finally {
inflightRefresh = undefined;
}
@@ -363,6 +605,7 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
return inflightRefresh;
}
: undefined,
filterModels: input.filterModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
+10 -1
View File
@@ -1 +1,10 @@
export * from "./utils/oauth/index.ts";
/** Type-only compatibility entry point for coding-agent extension OAuth declarations. */
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./compat/extension-oauth-types.ts";
+4
View File
@@ -29,6 +29,7 @@ import { opencodeProvider } from "./opencode.ts";
import { opencodeGoProvider } from "./opencode-go.ts";
import { openrouterProvider } from "./openrouter.ts";
import { openrouterImagesProvider } from "./openrouter-images.ts";
import { radiusProvider } from "./radius.ts";
import { togetherProvider } from "./together.ts";
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
import { xaiProvider } from "./xai.ts";
@@ -39,6 +40,8 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
import { zaiProvider } from "./zai.ts";
import { zaiCodingCnProvider } from "./zai-coding-cn.ts";
export { radiusProvider };
/** Providers present in the generated catalog. `KnownProvider` additionally
* includes purely dynamic providers (e.g. "radius") that have no static
* catalog entry. */
@@ -100,6 +103,7 @@ export function builtinProviders(): Provider[] {
opencodeProvider(),
opencodeGoProvider(),
openrouterProvider(),
radiusProvider(),
togetherProvider(),
vercelAIGatewayProvider(),
xaiProvider(),
@@ -1290,6 +1290,60 @@ export const AMAZON_BEDROCK_MODELS = {
contextWindow: 272000,
maxTokens: 128000,
} satisfies Model<"bedrock-converse-stream">,
"openai.gpt-5.6-luna": {
id: "openai.gpt-5.6-luna",
name: "GPT-5.6 Luna",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 1,
output: 6,
cacheRead: 0.1,
cacheWrite: 1.25,
},
contextWindow: 272000,
maxTokens: 128000,
} satisfies Model<"bedrock-converse-stream">,
"openai.gpt-5.6-sol": {
id: "openai.gpt-5.6-sol",
name: "GPT-5.6 Sol",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 5,
output: 30,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 272000,
maxTokens: 128000,
} satisfies Model<"bedrock-converse-stream">,
"openai.gpt-5.6-terra": {
id: "openai.gpt-5.6-terra",
name: "GPT-5.6 Terra",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
thinkingLevelMap: {"xhigh":"xhigh"},
input: ["text", "image"],
cost: {
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 3.125,
},
contextWindow: 272000,
maxTokens: 128000,
} satisfies Model<"bedrock-converse-stream">,
"openai.gpt-oss-120b": {
id: "openai.gpt-oss-120b",
name: "gpt-oss-120b",
+50 -9
View File
@@ -4,20 +4,61 @@ import { createProvider, type Provider } from "../models.ts";
import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.models.ts";
/**
* Bedrock auth is ambient: the AWS SDK's default credential chain handles the
* actual signing, so `resolve` only reports whether the provider is
* configured. A stored credential key is surfaced as the bearer token.
* Bedrock accepts a bearer token or the AWS SDK's default credential chain.
* The login flow can store a token/profile choice; resolve also detects ambient
* AWS credentials without copying them into pi's credential store.
*/
const bedrockAuth: ApiKeyAuth = {
name: "Bedrock API key or AWS credentials",
login: async (callbacks) => ({
type: "api_key",
key: await callbacks.prompt({ type: "secret", message: "Enter Bedrock API key" }),
}),
name: "AWS credentials or bearer token",
login: async (interaction) => {
const method = await interaction.prompt({
type: "select",
message: "Select Amazon Bedrock authentication method:",
options: [
{ id: "bearer-token", label: "Bearer token" },
{ id: "aws-profile", label: "AWS profile" },
{ id: "credential-chain", label: "Existing AWS credential chain" },
],
});
if (method === "bearer-token") {
return {
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter Amazon Bedrock bearer token" }),
};
}
interaction.notify({
type: "info",
message: "Amazon Bedrock supports AWS profiles, IAM credentials, and role-based credentials.",
links: [
{
label: "AWS credential provider chain",
url: "https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html",
},
],
});
if (method === "aws-profile") {
return {
type: "api_key",
env: { AWS_PROFILE: await interaction.prompt({ type: "text", message: "Enter AWS profile name" }) },
};
}
if (method !== "credential-chain") throw new Error(`Unknown Amazon Bedrock auth method: ${method}`);
await interaction.prompt({
type: "text",
message: "Configure AWS credentials, then press Enter to continue",
});
return { type: "api_key" };
},
resolve: async ({ ctx, credential }) => {
if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" };
if (await ctx.env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" };
if (await ctx.env("AWS_PROFILE")) return { auth: {}, source: "AWS_PROFILE" };
if (credential?.env?.AWS_PROFILE ?? (await ctx.env("AWS_PROFILE"))) {
return {
auth: {},
env: credential?.env,
source: credential?.env?.AWS_PROFILE ? "stored credential" : "AWS_PROFILE",
};
}
if ((await ctx.env("AWS_ACCESS_KEY_ID")) && (await ctx.env("AWS_SECRET_ACCESS_KEY"))) {
return { auth: {}, source: "AWS access keys" };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadAnthropicOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { loadAnthropicOAuth } from "../utils/oauth/load.ts";
import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
export function anthropicProvider(): Provider<"anthropic-messages"> {
@@ -660,6 +660,23 @@ export const AZURE_OPENAI_RESPONSES_MODELS = {
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"azure-openai-responses">,
"gpt-realtime-2.1": {
id: "gpt-realtime-2.1",
name: "GPT-Realtime-2.1",
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "",
reasoning: true,
input: ["text", "image"],
cost: {
input: 4,
output: 24,
cacheRead: 0.4,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 32000,
} satisfies Model<"azure-openai-responses">,
"o1": {
id: "o1",
name: "o1",
+1 -1
View File
@@ -52,7 +52,7 @@ export const CEREBRAS_MODELS = {
cost: {
input: 2.25,
output: 2.75,
cacheRead: 0,
cacheRead: 2.25,
cacheWrite: 0,
},
contextWindow: 131072,
@@ -528,6 +528,60 @@ export const CLOUDFLARE_AI_GATEWAY_MODELS = {
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-luna": {
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
api: "openai-responses",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
reasoning: true,
thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 1,
output: 6,
cacheRead: 0.1,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-sol": {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
api: "openai-responses",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
reasoning: true,
thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 5,
output: 30,
cacheRead: 0.5,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-terra": {
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
api: "openai-responses",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
reasoning: true,
thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"o1": {
id: "o1",
name: "o1",
@@ -685,4 +739,22 @@ export const CLOUDFLARE_AI_GATEWAY_MODELS = {
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"workers-ai/@cf/zai-org/glm-5.2": {
id: "workers-ai/@cf/zai-org/glm-5.2",
name: "Glm 5.2",
api: "openai-completions",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat",
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true},
reasoning: true,
input: ["text"],
cost: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
} as const;
@@ -4,6 +4,7 @@ import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
import { createProvider, type Provider } from "../models.ts";
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts";
import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts";
import { cloudflareStreams } from "./cloudflare-stream.ts";
export function cloudflareAIGatewayProvider(): Provider<
"anthropic-messages" | "openai-completions" | "openai-responses"
@@ -14,9 +15,9 @@ export function cloudflareAIGatewayProvider(): Provider<
auth: { apiKey: cloudflareAIGatewayAuth() },
models: Object.values(CLOUDFLARE_AI_GATEWAY_MODELS),
api: {
"anthropic-messages": anthropicMessagesApi(),
"openai-completions": openAICompletionsApi(),
"openai-responses": openAIResponsesApi(),
"anthropic-messages": cloudflareStreams(anthropicMessagesApi()),
"openai-completions": cloudflareStreams(openAICompletionsApi()),
"openai-responses": cloudflareStreams(openAIResponsesApi()),
},
});
}
+14 -27
View File
@@ -1,5 +1,5 @@
import type { ApiKeyAuth, ApiKeyCredential, AuthContext } from "../auth/types.ts";
import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv } from "../types.ts";
import type { ProviderEnv } from "../types.ts";
const CLOUDFLARE_API_KEY = "CLOUDFLARE_API_KEY";
const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID";
@@ -23,22 +23,11 @@ async function resolveValue(
return fromCredential ?? (await ctx.env(name));
}
function resolveCloudflareBaseUrl(
model: Model<Api> | ImagesModel<ImagesApi>,
accountId: string,
gatewayId: string | undefined,
): string {
return model.baseUrl
.replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, accountId)
.replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, gatewayId ?? "");
}
async function resolveCloudflareEnv(
kind: CloudflareAuthKind,
model: Model<Api> | ImagesModel<ImagesApi>,
ctx: AuthContext,
credential: ApiKeyCredential | undefined,
): Promise<{ apiKey: string; env: ProviderEnv; baseUrl: string; source: string } | undefined> {
): Promise<{ apiKey: string; env: ProviderEnv; source: string } | undefined> {
const apiKey = await resolveValue(CLOUDFLARE_API_KEY, ctx, credential);
const accountId = await resolveValue(CLOUDFLARE_ACCOUNT_ID, ctx, credential);
const gatewayId = kind === "ai-gateway" ? await resolveValue(CLOUDFLARE_GATEWAY_ID, ctx, credential) : undefined;
@@ -51,7 +40,6 @@ async function resolveCloudflareEnv(
CLOUDFLARE_ACCOUNT_ID: accountId,
...(gatewayId ? { CLOUDFLARE_GATEWAY_ID: gatewayId } : {}),
},
baseUrl: resolveCloudflareBaseUrl(model, accountId, gatewayId),
source: credential ? "stored credential" : CLOUDFLARE_API_KEY,
};
}
@@ -59,16 +47,16 @@ async function resolveCloudflareEnv(
export function cloudflareWorkersAIAuth(): ApiKeyAuth {
return {
name: "Cloudflare API key",
login: async (callbacks) => {
const key = await callbacks.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" });
login: async (interaction) => {
const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
return { type: "api_key", key, env: { CLOUDFLARE_ACCOUNT_ID: accountId } };
},
resolve: async ({ model, ctx, credential }) => {
const resolved = await resolveCloudflareEnv("workers-ai", model, ctx, credential);
resolve: async ({ ctx, credential }) => {
const resolved = await resolveCloudflareEnv("workers-ai", ctx, credential);
if (!resolved) return undefined;
return {
auth: { apiKey: resolved.apiKey, baseUrl: resolved.baseUrl },
auth: { apiKey: resolved.apiKey },
env: resolved.env,
source: resolved.source,
};
@@ -79,18 +67,18 @@ export function cloudflareWorkersAIAuth(): ApiKeyAuth {
export function cloudflareAIGatewayAuth(): ApiKeyAuth {
return {
name: "Cloudflare API key",
login: async (callbacks) => {
const key = await callbacks.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" });
const gatewayId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" });
login: async (interaction) => {
const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
const gatewayId = await interaction.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" });
return {
type: "api_key",
key,
env: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId },
};
},
resolve: async ({ model, ctx, credential }) => {
const resolved = await resolveCloudflareEnv("ai-gateway", model, ctx, credential);
resolve: async ({ ctx, credential }) => {
const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential);
if (!resolved) return undefined;
return {
auth: {
@@ -99,7 +87,6 @@ export function cloudflareAIGatewayAuth(): ApiKeyAuth {
Authorization: null,
"x-api-key": null,
},
baseUrl: resolved.baseUrl,
},
env: resolved.env,
source: resolved.source,
@@ -0,0 +1,28 @@
import type { Api, Model, ProviderEnv, ProviderStreams } from "../types.ts";
const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID";
const CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID";
export function resolveCloudflareModel<TApi extends Api>(
model: Model<TApi>,
env: ProviderEnv | undefined,
): Model<TApi> {
if (!env) return model;
const baseUrl = model.baseUrl
.replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, env[CLOUDFLARE_ACCOUNT_ID] ?? `{${CLOUDFLARE_ACCOUNT_ID}}`)
.replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, env[CLOUDFLARE_GATEWAY_ID] ?? `{${CLOUDFLARE_GATEWAY_ID}}`);
return baseUrl === model.baseUrl ? model : { ...model, baseUrl };
}
/**
* Wrap an API implementation so Cloudflare account/gateway endpoint
* placeholders materialize from the resolved provider env before dispatch.
*/
export function cloudflareStreams(streams: ProviderStreams): ProviderStreams {
return {
stream: (model, context, options) =>
streams.stream(resolveCloudflareModel(model, options?.env), context, options),
streamSimple: (model, context, options) =>
streams.streamSimple(resolveCloudflareModel(model, options?.env), context, options),
};
}
@@ -1,6 +1,7 @@
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { createProvider, type Provider } from "../models.ts";
import { cloudflareWorkersAIAuth } from "./cloudflare-auth.ts";
import { cloudflareStreams } from "./cloudflare-stream.ts";
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./cloudflare-workers-ai.models.ts";
export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> {
@@ -9,6 +10,6 @@ export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> {
name: "Cloudflare Workers AI",
auth: { apiKey: cloudflareWorkersAIAuth() },
models: Object.values(CLOUDFLARE_WORKERS_AI_MODELS),
api: openAICompletionsApi(),
api: cloudflareStreams(openAICompletionsApi()),
});
}
@@ -253,7 +253,7 @@ export const GITHUB_COPILOT_MODELS = {
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 200000,
contextWindow: 1000000,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"gemini-3.5-flash": {
@@ -446,6 +446,63 @@ export const GITHUB_COPILOT_MODELS = {
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-luna": {
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
api: "openai-responses",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"},
reasoning: true,
thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 1,
output: 6,
cacheRead: 0.1,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-sol": {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
api: "openai-responses",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"},
reasoning: true,
thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 5,
output: 30,
cacheRead: 0.5,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-5.6-terra": {
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
api: "openai-responses",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"},
reasoning: true,
thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh","max":"max"},
input: ["text", "image"],
cost: {
input: 2.5,
output: 15,
cacheRead: 0.25,
cacheWrite: 0,
},
contextWindow: 1050000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"kimi-k2.7-code": {
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
+10 -1
View File
@@ -2,8 +2,8 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadGitHubCopilotOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { loadGitHubCopilotOAuth } from "../utils/oauth/load.ts";
import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts";
export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> {
@@ -16,6 +16,15 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai
oauth: lazyOAuth({ name: "GitHub Copilot", load: loadGitHubCopilotOAuth }),
},
models: Object.values(GITHUB_COPILOT_MODELS),
filterModels: (models, credential) => {
if (credential?.type !== "oauth") return models;
const availableModelIds = credential.availableModelIds;
if (!Array.isArray(availableModelIds) || !availableModelIds.every((id) => typeof id === "string")) {
return models;
}
const available = new Set(availableModelIds);
return models.filter((model) => available.has(model.id));
},
api: {
"anthropic-messages": anthropicMessagesApi(),
"openai-completions": openAICompletionsApi(),
+60 -5
View File
@@ -12,16 +12,71 @@ const VERTEX_ADC_PATH = "~/.config/gcloud/application_default_credentials.json";
*/
const vertexAuth: ApiKeyAuth = {
name: "Google Cloud credentials",
login: async (interaction) => {
const method = await interaction.prompt({
type: "select",
message: "Select Google Vertex AI authentication method:",
options: [
{ id: "api-key", label: "Google Cloud API key" },
{ id: "adc", label: "Application Default Credentials" },
{ id: "service-account", label: "Service account credentials file" },
],
});
if (method === "api-key") {
return {
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter Google Cloud API key" }),
};
}
if (method !== "adc" && method !== "service-account") {
throw new Error(`Unknown Google Vertex AI auth method: ${method}`);
}
interaction.notify({
type: "info",
message:
method === "adc"
? "Run `gcloud auth application-default login`, then provide the project and location."
: "Provide a service account credentials file, project, and location.",
links: [
{
label: "Application Default Credentials",
url: "https://cloud.google.com/docs/authentication/provide-credentials-adc",
},
],
});
const project = await interaction.prompt({ type: "text", message: "Enter Google Cloud project ID" });
const location = await interaction.prompt({ type: "text", message: "Enter Google Cloud location" });
const credentialsPath =
method === "service-account"
? await interaction.prompt({ type: "text", message: "Enter service account credentials file path" })
: undefined;
return {
type: "api_key",
env: {
GOOGLE_CLOUD_PROJECT: project,
GOOGLE_CLOUD_LOCATION: location,
...(credentialsPath ? { GOOGLE_APPLICATION_CREDENTIALS: credentialsPath } : {}),
},
};
},
resolve: async ({ ctx, credential }) => {
const key = credential?.key ?? (await ctx.env("GOOGLE_CLOUD_API_KEY"));
if (key) return { auth: { apiKey: key }, source: credential?.key ? "stored credential" : "GOOGLE_CLOUD_API_KEY" };
const adcPath = await ctx.env("GOOGLE_APPLICATION_CREDENTIALS");
const adcPath =
credential?.env?.GOOGLE_APPLICATION_CREDENTIALS ?? (await ctx.env("GOOGLE_APPLICATION_CREDENTIALS"));
const hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH);
const hasProject = Boolean((await ctx.env("GOOGLE_CLOUD_PROJECT")) ?? (await ctx.env("GCLOUD_PROJECT")));
const hasLocation = Boolean(await ctx.env("GOOGLE_CLOUD_LOCATION"));
if (hasCredentials && hasProject && hasLocation) {
return { auth: {}, source: "gcloud application default credentials" };
const project =
credential?.env?.GOOGLE_CLOUD_PROJECT ??
(await ctx.env("GOOGLE_CLOUD_PROJECT")) ??
(await ctx.env("GCLOUD_PROJECT"));
const location = credential?.env?.GOOGLE_CLOUD_LOCATION ?? (await ctx.env("GOOGLE_CLOUD_LOCATION"));
if (hasCredentials && project && location) {
return {
auth: {},
env: credential?.env,
source: credential ? "stored credential" : "gcloud application default credentials",
};
}
return undefined;
},
+1 -1
View File
@@ -1,7 +1,7 @@
import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts";
import { lazyOAuth } from "../auth/helpers.ts";
import { loadOpenAICodexOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts";
import { loadOpenAICodexOAuth } from "../utils/oauth/load.ts";
import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts";
export function openaiCodexProvider(): Provider<"openai-codex-responses"> {
@@ -674,6 +674,23 @@ export const OPENAI_MODELS = {
contextWindow: 272000,
maxTokens: 128000,
} satisfies Model<"openai-responses">,
"gpt-realtime-2.1": {
id: "gpt-realtime-2.1",
name: "GPT-Realtime-2.1",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 4,
output: 24,
cacheRead: 0.4,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 32000,
} satisfies Model<"openai-responses">,
"o1": {
id: "o1",
name: "o1",
@@ -0,0 +1,95 @@
import type { OAuthCredential } from "../auth/types.ts";
import type { Model, ThinkingLevelMap } from "../types.ts";
export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev";
export type RadiusGatewayModel = {
id: string;
name: string;
reasoning: boolean;
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: Model<"pi-messages">["cost"];
contextWindow: number;
maxTokens: number;
};
export type RadiusGatewayConfig = {
baseUrl: string;
models: RadiusGatewayModel[];
};
export type RadiusOAuthCredential = OAuthCredential & {
gatewayConfig?: RadiusGatewayConfig;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.reasoning === "boolean" &&
Array.isArray(value.input) &&
isRecord(value.cost) &&
typeof value.contextWindow === "number" &&
typeof value.maxTokens === "number"
);
}
function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined {
if (!isRecord(config) || typeof config.baseUrl !== "string" || !Array.isArray(config.models)) return undefined;
return {
baseUrl: config.baseUrl,
models: config.models.filter(isRadiusGatewayModel).map((model) => ({ ...model })),
};
}
export function normalizeRadiusGatewayUrl(value: string): string {
const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`;
return withScheme.replace(/\/+$/u, "");
}
export function getRadiusCredentialConfig(credential: OAuthCredential | undefined): RadiusGatewayConfig | undefined {
return sanitizeRadiusGatewayConfig((credential as RadiusOAuthCredential | undefined)?.gatewayConfig);
}
export function getRadiusModelsFromConfig(providerId: string, config: RadiusGatewayConfig): Model<"pi-messages">[] {
return config.models.map((model) => ({
...model,
api: "pi-messages",
provider: providerId,
baseUrl: config.baseUrl,
}));
}
export function getRadiusModels(providerId: string, credential: OAuthCredential | undefined): Model<"pi-messages">[] {
const config = getRadiusCredentialConfig(credential);
return config ? getRadiusModelsFromConfig(providerId, config) : [];
}
function truncateHttpBody(body: string): string {
const trimmed = body.trim();
return trimmed.length > 512 ? `${trimmed.slice(0, 512)}` : trimmed;
}
export async function loadRadiusGatewayConfig(
gateway: string,
apiKey?: string,
signal?: AbortSignal,
): Promise<RadiusGatewayConfig> {
const headers: Record<string, string> = { accept: "application/json" };
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const response = await fetch(new URL("/v1/config", gateway), { headers, signal });
if (!response.ok) {
throw new Error(
`Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`,
);
}
const config = sanitizeRadiusGatewayConfig(await response.json());
if (!config) throw new Error(`Invalid Radius config from ${gateway}`);
return config;
}
+67
View File
@@ -0,0 +1,67 @@
import { piMessagesApi } from "../api/pi-messages.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadRadiusOAuth } from "../auth/oauth/load.ts";
import type { Provider } from "../models.ts";
import {
DEFAULT_RADIUS_GATEWAY,
getRadiusModels,
getRadiusModelsFromConfig,
loadRadiusGatewayConfig,
normalizeRadiusGatewayUrl,
} from "./radius-config.ts";
export interface RadiusProviderOptions {
id?: string;
name?: string;
gateway?: string;
}
/** Radius gateway provider with a persisted, dynamically refreshed catalog. */
export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"pi-messages"> {
const id = options.id ?? "radius";
const name = options.name ?? "Radius";
const gateway = normalizeRadiusGatewayUrl(options.gateway ?? DEFAULT_RADIUS_GATEWAY);
let models = getRadiusModels(id, undefined);
let inflightRefresh: Promise<void> | undefined;
const streams = piMessagesApi();
return {
id,
name,
auth: {
apiKey: envApiKeyAuth("Radius API key", ["RADIUS_API_KEY"]),
oauth: lazyOAuth({ name, load: () => loadRadiusOAuth({ name, gateway }) }),
},
getModels: () => models,
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const stored = await context.store.read();
if (stored) models = stored.filter((model) => model.provider === id) as typeof models;
// Import catalogs cached by the pre-ModelsStore Radius implementation.
if (!stored && context.credential?.type === "oauth") {
const legacy = getRadiusModels(id, context.credential);
if (legacy.length > 0) {
models = legacy;
await context.store.write(legacy);
}
}
if (!context.allowNetwork || context.signal?.aborted) return;
const apiKey =
context.credential?.type === "oauth" ? context.credential.access : context.credential?.key;
const config = await loadRadiusGatewayConfig(gateway, apiKey, context.signal);
if (context.signal?.aborted) return;
models = getRadiusModelsFromConfig(id, config);
await context.store.write(models);
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
},
stream: (model, context, streamOptions) => streams.stream(model, context, streamOptions),
streamSimple: (model, context, streamOptions) => streams.streamSimple(model, context, streamOptions),
};
}
@@ -497,23 +497,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 200000,
maxTokens: 4096,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-3.5-haiku": {
id: "anthropic/claude-3.5-haiku",
name: "Claude 3.5 Haiku",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.8,
output: 4,
cacheRead: 0.08,
cacheWrite: 1,
},
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-fable-5": {
id: "anthropic/claude-fable-5",
name: "Claude Fable 5",
@@ -565,7 +548,7 @@ export const VERCEL_AI_GATEWAY_MODELS = {
cacheWrite: 18.75,
},
contextWindow: 200000,
maxTokens: 32000,
maxTokens: 8192,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-opus-4.1": {
id: "anthropic/claude-opus-4.1",
@@ -673,7 +656,7 @@ export const VERCEL_AI_GATEWAY_MODELS = {
cacheWrite: 3.75,
},
contextWindow: 1000000,
maxTokens: 64000,
maxTokens: 8192,
} satisfies Model<"anthropic-messages">,
"anthropic/claude-sonnet-4.5": {
id: "anthropic/claude-sonnet-4.5",
@@ -730,23 +713,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"arcee-ai/trinity-large-preview": {
id: "arcee-ai/trinity-large-preview",
name: "Trinity Large Preview",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0.25,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131000,
maxTokens: 131000,
} satisfies Model<"anthropic-messages">,
"arcee-ai/trinity-large-thinking": {
id: "arcee-ai/trinity-large-thinking",
name: "Trinity Large Thinking",
@@ -875,12 +841,12 @@ export const VERCEL_AI_GATEWAY_MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.6,
output: 1.7,
cacheRead: 0,
input: 0.21,
output: 0.79,
cacheRead: 0.13,
cacheWrite: 0,
},
contextWindow: 128000,
contextWindow: 163840,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"deepseek/deepseek-v3.1-terminus": {
@@ -1206,6 +1172,23 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 1000000,
maxTokens: 32000,
} satisfies Model<"anthropic-messages">,
"kwaipilot/kat-coder-air-v2.5": {
id: "kwaipilot/kat-coder-air-v2.5",
name: "Kat Coder Air V2.5",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 0.15,
output: 0.6,
cacheRead: 0.03,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 80000,
} satisfies Model<"anthropic-messages">,
"kwaipilot/kat-coder-pro-v1": {
id: "kwaipilot/kat-coder-pro-v1",
name: "KAT-Coder-Pro V1",
@@ -1240,39 +1223,22 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"anthropic-messages">,
"meituan/longcat-flash-chat": {
id: "meituan/longcat-flash-chat",
name: "LongCat Flash Chat",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 100000,
} satisfies Model<"anthropic-messages">,
"meituan/longcat-flash-thinking-2601": {
id: "meituan/longcat-flash-thinking-2601",
name: "LongCat Flash Thinking 2601",
"kwaipilot/kat-coder-pro-v2.5": {
id: "kwaipilot/kat-coder-pro-v2.5",
name: "Kat Coder Pro V2.5",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
input: 0.74,
output: 2.96,
cacheRead: 0.15,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 32768,
contextWindow: 256000,
maxTokens: 80000,
} satisfies Model<"anthropic-messages">,
"meta/llama-3.1-70b": {
id: "meta/llama-3.1-70b",
@@ -1580,23 +1546,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"anthropic-messages">,
"mistral/devstral-small": {
id: "mistral/devstral-small",
name: "Devstral Small 1.1",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 64000,
} satisfies Model<"anthropic-messages">,
"mistral/devstral-small-2": {
id: "mistral/devstral-small-2",
name: "Devstral Small 2",
@@ -1801,23 +1750,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"anthropic-messages">,
"mistral/pixtral-large": {
id: "mistral/pixtral-large",
name: "Pixtral Large",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2": {
id: "moonshotai/kimi-k2",
name: "Kimi K2 Instruct",
@@ -2972,40 +2904,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"anthropic-messages">,
"xiaomi/mimo-v2-flash": {
id: "xiaomi/mimo-v2-flash",
name: "MiMo V2 Flash",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0.01,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 32000,
} satisfies Model<"anthropic-messages">,
"xiaomi/mimo-v2-pro": {
id: "xiaomi/mimo-v2-pro",
name: "MiMo V2 Pro",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text"],
cost: {
input: 1,
output: 3,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"xiaomi/mimo-v2.5": {
id: "xiaomi/mimo-v2.5",
name: "MiMo M2.5",
@@ -3270,9 +3168,9 @@ export const VERCEL_AI_GATEWAY_MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 3,
output: 10.25,
cacheRead: 0.5,
input: 2.1,
output: 6.6,
cacheRead: 0.21,
cacheWrite: 0,
},
contextWindow: 1000000,
-176
View File
@@ -1,176 +0,0 @@
/**
* OAuth credential management for AI providers.
*
* This module handles login, token refresh, and credential storage
* for OAuth-based providers:
* - Anthropic (Claude Pro/Max)
* - GitHub Copilot
*/
// Anthropic
export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts";
export * from "./device-code.ts";
// GitHub Copilot
export {
getGitHubCopilotBaseUrl,
githubCopilotOAuthProvider,
loginGitHubCopilot,
normalizeDomain,
refreshGitHubCopilotToken,
} from "./github-copilot.ts";
// OpenAI Codex (ChatGPT OAuth)
export {
loginOpenAICodex,
loginOpenAICodexDeviceCode,
OPENAI_CODEX_BROWSER_LOGIN_METHOD,
OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD,
openaiCodexOAuthProvider,
refreshOpenAICodexToken,
} from "./openai-codex.ts";
// Radius (pi-messages gateway)
export {
createRadiusOAuthProvider,
DEFAULT_RADIUS_GATEWAY,
type RadiusGatewayConfig,
type RadiusGatewayModel,
type RadiusOAuthCredentials,
type RadiusOAuthProviderOptions,
} from "./radius.ts";
export * from "./types.ts";
// ============================================================================
// Provider Registry
// ============================================================================
import { getProviderEnvValue } from "../provider-env.ts";
import { anthropicOAuthProvider } from "./anthropic.ts";
import { githubCopilotOAuthProvider } from "./github-copilot.ts";
import { openaiCodexOAuthProvider } from "./openai-codex.ts";
import { createRadiusOAuthProvider, DEFAULT_RADIUS_GATEWAY } from "./radius.ts";
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts";
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
anthropicOAuthProvider,
githubCopilotOAuthProvider,
openaiCodexOAuthProvider,
createRadiusOAuthProvider({
id: "radius",
name: "Radius",
gateway: getProviderEnvValue("PI_GATEWAY") || DEFAULT_RADIUS_GATEWAY,
}),
];
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>(
BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]),
);
/**
* Get an OAuth provider by ID
*/
export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined {
return oauthProviderRegistry.get(id);
}
/**
* Register a custom OAuth provider
*/
export function registerOAuthProvider(provider: OAuthProviderInterface): void {
oauthProviderRegistry.set(provider.id, provider);
}
/**
* Unregister an OAuth provider.
*
* If the provider is built-in, restores the built-in implementation.
* Custom providers are removed completely.
*/
export function unregisterOAuthProvider(id: string): void {
const builtInProvider = BUILT_IN_OAUTH_PROVIDERS.find((provider) => provider.id === id);
if (builtInProvider) {
oauthProviderRegistry.set(id, builtInProvider);
return;
}
oauthProviderRegistry.delete(id);
}
/**
* Reset OAuth providers to built-ins.
*/
export function resetOAuthProviders(): void {
oauthProviderRegistry.clear();
for (const provider of BUILT_IN_OAUTH_PROVIDERS) {
oauthProviderRegistry.set(provider.id, provider);
}
}
/**
* Get all registered OAuth providers
*/
export function getOAuthProviders(): OAuthProviderInterface[] {
return Array.from(oauthProviderRegistry.values());
}
/**
* @deprecated Use getOAuthProviders() which returns OAuthProviderInterface[]
*/
export function getOAuthProviderInfoList(): OAuthProviderInfo[] {
return getOAuthProviders().map((p) => ({
id: p.id,
name: p.name,
available: true,
}));
}
// ============================================================================
// High-level API (uses provider registry)
// ============================================================================
/**
* Refresh token for any OAuth provider.
* @deprecated Use getOAuthProvider(id).refreshToken() instead
*/
export async function refreshOAuthToken(
providerId: OAuthProviderId,
credentials: OAuthCredentials,
): Promise<OAuthCredentials> {
const provider = getOAuthProvider(providerId);
if (!provider) {
throw new Error(`Unknown OAuth provider: ${providerId}`);
}
return provider.refreshToken(credentials);
}
/**
* Get API key for a provider from OAuth credentials.
* Automatically refreshes expired tokens.
*
* @returns API key string and updated credentials, or null if no credentials
* @throws Error if refresh fails
*/
export async function getOAuthApiKey(
providerId: OAuthProviderId,
credentials: Record<string, OAuthCredentials>,
): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
const provider = getOAuthProvider(providerId);
if (!provider) {
throw new Error(`Unknown OAuth provider: ${providerId}`);
}
let creds = credentials[providerId];
if (!creds) {
return null;
}
// Refresh if expired
if (Date.now() >= creds.expires) {
try {
creds = await provider.refreshToken(creds);
} catch (_error) {
throw new Error(`Failed to refresh OAuth token for ${providerId}`);
}
}
const apiKey = provider.getApiKey(creds);
return { newCredentials: creds, apiKey };
}
-79
View File
@@ -1,79 +0,0 @@
import type { Api, Model } from "../../types.ts";
export type OAuthCredentials = {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
};
export type OAuthProviderId = string;
/** @deprecated Use OAuthProviderId instead */
export type OAuthProvider = OAuthProviderId;
export type OAuthPrompt = {
message: string;
placeholder?: string;
allowEmpty?: boolean;
};
export type OAuthAuthInfo = {
url: string;
instructions?: string;
};
export type OAuthDeviceCodeInfo = {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
};
export type OAuthSelectOption = {
id: string;
label: string;
};
export type OAuthSelectPrompt = {
message: string;
options: OAuthSelectOption[];
};
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}
export interface OAuthProviderInterface {
readonly id: OAuthProviderId;
readonly name: string;
/** Run the login flow, return credentials to persist */
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
/** Whether login uses a local callback server and supports manual code input. */
usesCallbackServer?: boolean;
/** Refresh expired credentials, return updated credentials to persist */
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
/** Convert credentials to API key string for the provider */
getApiKey(credentials: OAuthCredentials): string;
/** Optional: modify models for this provider (e.g., update baseUrl) */
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
}
/** @deprecated Use OAuthProviderInterface instead */
export interface OAuthProviderInfo {
id: OAuthProviderId;
name: string;
available: boolean;
}
+13 -10
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.ts";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
@@ -53,18 +53,16 @@ describe.sequential("Anthropic OAuth", () => {
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await loginAnthropic({
onAuth: (info) => {
authUrl = info.url;
const credentials = await anthropicOAuth.login({
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
onPrompt: async () => "",
onManualCodeInput: async () => {
prompt: async (prompt) => {
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const url = new URL(authUrl);
const state = url.searchParams.get("state");
const redirectUri = url.searchParams.get("redirect_uri");
if (!state || !redirectUri) {
throw new Error("Missing OAuth state or redirect_uri in auth URL");
}
if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
return `${redirectUri}?code=manual-code&state=${state}`;
},
});
@@ -91,7 +89,12 @@ describe.sequential("Anthropic OAuth", () => {
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await refreshAnthropicToken("refresh-token");
const credentials = await anthropicOAuth.refresh({
type: "oauth",
access: "old-access-token",
refresh: "refresh-token",
expires: 0,
});
expect(credentials.access).toBe("new-access-token");
expect(credentials.refresh).toBe("new-refresh-token");
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
import type { Api, Context, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
const model: Model<Api> = {
id: "model",
name: "model",
api: "openai-completions",
provider: "cloudflare-ai-gateway",
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const context: Context = { messages: [] };
describe("Cloudflare provider streams", () => {
it("materializes the model endpoint before dispatch", () => {
const captured: string[] = [];
const streams = cloudflareStreams({
stream: (requestModel) => {
captured.push(requestModel.baseUrl);
return new AssistantMessageEventStream();
},
streamSimple: (requestModel) => {
captured.push(requestModel.baseUrl);
return new AssistantMessageEventStream();
},
});
const env = {
CLOUDFLARE_ACCOUNT_ID: "account",
CLOUDFLARE_GATEWAY_ID: "gateway",
};
streams.stream(model, context, { env });
streams.streamSimple(model, context, { env });
expect(captured).toEqual([
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
]);
});
it("keeps placeholders when the provider env does not resolve them", () => {
let captured: string | undefined;
const streams = cloudflareStreams({
stream: (requestModel) => {
captured = requestModel.baseUrl;
return new AssistantMessageEventStream();
},
streamSimple: (requestModel) => {
captured = requestModel.baseUrl;
return new AssistantMessageEventStream();
},
});
streams.streamSimple(model, context, {});
expect(captured).toBe(model.baseUrl);
});
});
@@ -9,7 +9,7 @@
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Type } from "typebox";
import { AuthStorage } from "../../coding-agent/src/core/auth-storage.ts";
import { ModelRuntime } from "../../coding-agent/src/core/model-runtime.ts";
import {
closeOpenAICodexWebSocketSessions,
getOpenAICodexWebSocketDebugStats,
@@ -166,8 +166,9 @@ async function main(): Promise<void> {
const model = getModel("openai-codex", "gpt-5.5") as Model<"openai-codex-responses"> | undefined;
if (!model) throw new Error("Model openai-codex/gpt-5.5 not found");
const modelWithMaxTokens = { ...model, maxTokens: args.maxTokens };
const authStorage = AuthStorage.create();
const apiKey = (await authStorage.getApiKey("openai-codex")) ?? (await authStorage.getApiKey("openai"));
const modelRuntime = await ModelRuntime.create();
const apiKey =
(await modelRuntime.getAuth("openai-codex"))?.auth.apiKey ?? (await modelRuntime.getAuth("openai"))?.auth.apiKey;
if (!apiKey) {
throw new Error("No OpenAI Codex API key found in coding-agent auth storage.");
}
+47 -16
View File
@@ -1,10 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getModels } from "../src/compat.ts";
import {
githubCopilotOAuthProvider,
loginGitHubCopilot,
refreshGitHubCopilotToken,
} from "../src/utils/oauth/github-copilot.ts";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { createModels } from "../src/models.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
@@ -28,6 +26,33 @@ function getUrl(input: unknown): string {
throw new Error(`Unsupported fetch input: ${String(input)}`);
}
function loginGitHubCopilotForTest(options: {
onDeviceCode(info: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}): void;
onPrompt(prompt: { message: string; placeholder?: string; allowEmpty?: boolean }): Promise<string>;
onProgress?(message: string): void;
signal?: AbortSignal;
}) {
return githubCopilotOAuth.login({
signal: options.signal,
prompt: (prompt) => {
if (prompt.type !== "text") throw new Error(`Unexpected prompt: ${prompt.type}`);
return options.onPrompt({ message: prompt.message, placeholder: prompt.placeholder, allowEmpty: true });
},
notify: (event) => {
if (event.type === "device_code") {
const { type: _, ...info } = event;
options.onDeviceCode(info);
}
if (event.type === "progress") options.onProgress?.(event.message);
},
});
}
describe("GitHub Copilot OAuth device flow", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -76,13 +101,19 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const credentials = await refreshGitHubCopilotToken("ghu_refresh_token");
const credentials = await githubCopilotOAuth.refresh({
type: "oauth",
access: "old-access-token",
refresh: "ghu_refresh_token",
expires: 0,
});
expect(credentials.availableModelIds).toEqual(["gpt-4.1"]);
const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? [];
expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([
"gpt-4.1",
]);
const store = new InMemoryCredentialStore();
await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" }));
const models = createModels({ credentials: store });
models.setProvider(githubCopilotProvider());
expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual(["gpt-4.1"]);
});
it("reports device-code details through onDeviceCode", async () => {
@@ -127,7 +158,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
});
@@ -166,7 +197,7 @@ describe("GitHub Copilot OAuth device flow", () => {
const onDeviceCode = vi.fn();
await expect(
loginGitHubCopilot({
loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
}),
@@ -220,7 +251,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode,
onPrompt: async () => "",
});
@@ -308,7 +339,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
onProgress: () => {},
@@ -382,7 +413,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {},
onPrompt: async () => "",
});
+5 -3
View File
@@ -51,10 +51,10 @@ function testProvider(input: {
auth: {
apiKey: {
name: "Test key",
resolve: async ({ ctx }) => {
resolve: async ({ ctx, credential }) => {
if (!input.envVar) return { auth: {} };
const key = await ctx.env(input.envVar);
return key ? { auth: { apiKey: key }, source: input.envVar } : undefined;
const key = credential?.key ?? (await ctx.env(input.envVar));
return key ? { auth: { apiKey: key }, source: credential ? "stored" : input.envVar } : undefined;
},
},
},
@@ -93,6 +93,8 @@ describe("ImagesModels", () => {
const model = models.getModel("p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model, { apiKey: "explicit-key" }))?.auth.apiKey).toBe("explicit-key");
const result = await models.generateImages(model, context);
expect(result.stopReason).toBe("stop");
+237 -27
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
import { calculateCost, createModels, hasApi, type Provider } from "../src/models.ts";
import { calculateCost, createModels, createProvider, hasApi, type Provider } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions, Usage } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
@@ -56,7 +57,7 @@ function testProvider(input: {
models?: Model<Api>[];
auth?: ProviderAuth;
getModels?: () => readonly Model<Api>[];
refreshModels?: () => Promise<void>;
refreshModels?: Provider["refreshModels"];
calls?: ProviderCall[];
}): Provider {
const models = input.models ?? [testModel(input.id, "model-a")];
@@ -106,6 +107,22 @@ function testOAuth(overrides?: Partial<OAuthAuth>): OAuthAuth {
}
describe("Models runtime", () => {
it("enumerates credential metadata without exposing secrets", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("api-provider", async () => ({ type: "api_key", key: "secret" }));
await credentials.modify("oauth-provider", async () => ({
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
}));
expect(await credentials.list()).toEqual([
{ providerId: "api-provider", type: "api_key" },
{ providerId: "oauth-provider", type: "oauth" },
]);
});
it("applies request-wide pricing tiers above the configured input threshold", () => {
const model = testModel("openai", "gpt-5.6-sol");
model.cost = {
@@ -199,7 +216,7 @@ describe("Models runtime", () => {
expect(() => models.getProvider("broken")?.getModels()).toThrow("boom");
});
it("refresh() updates dynamic providers; single-provider refresh failures reject", async () => {
it("refresh() updates every configured dynamic provider and reports failures", async () => {
let list = [testModel("dyn", "before")];
let refreshes = 0;
const models = createModels();
@@ -216,17 +233,12 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "static", models: [testModel("static", "s1")] }));
expect(models.getModel("dyn", "before")).toBeDefined();
await models.refresh("dyn");
const first = await models.refresh();
expect(first.errors.size).toBe(0);
expect(refreshes).toBe(1);
expect(models.getModel("dyn", "after")).toBeDefined();
expect(models.getModel("dyn", "before")).toBeUndefined();
// static providers are no-ops; refresh-all is best-effort
await models.refresh("static");
await models.refresh();
expect(refreshes).toBe(2);
// single-provider refresh failures reject with ModelsError
models.setProvider(
testProvider({
id: "flaky",
@@ -235,9 +247,120 @@ describe("Models runtime", () => {
},
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
// refresh-all swallows the same failure
await expect(models.refresh()).resolves.toBeUndefined();
const second = await models.refresh();
expect(refreshes).toBe(2);
expect(second.errors.get("flaky")?.message).toBe("fetch failed");
});
it("persists dynamic catalogs and restores them without network access", async () => {
const credentials = new InMemoryCredentialStore();
const modelsStore = new InMemoryModelsStore();
await credentials.modify("dynamic", async () => ({ type: "api_key", key: "key" }));
const createDynamicProvider = (fetchModels: (() => Promise<readonly Model<Api>[]>) | undefined) =>
createProvider({
id: "dynamic",
auth: { apiKey: envKeyAuth(undefined) },
models: [],
fetchModels: fetchModels ? () => fetchModels() : undefined,
api: {
stream: () => new AssistantMessageEventStream(),
streamSimple: () => new AssistantMessageEventStream(),
},
});
const online = createModels({ credentials, modelsStore });
online.setProvider(createDynamicProvider(async () => [testModel("dynamic", "fetched")]));
expect((await online.refresh()).errors.size).toBe(0);
expect(online.getModel("dynamic", "fetched")).toBeDefined();
const offline = createModels({ credentials, modelsStore });
offline.setProvider(
createDynamicProvider(async () => {
throw new Error("must not fetch");
}),
);
expect((await offline.refresh({ allowNetwork: false })).errors.size).toBe(0);
expect(offline.getModel("dynamic", "fetched")).toBeDefined();
});
it("passes effective API-key credentials and skips unconfigured providers", async () => {
let effectiveCredential: unknown;
let unconfiguredRefreshes = 0;
const models = createModels();
models.setProvider(
testProvider({
id: "configured",
auth: { apiKey: envKeyAuth("ambient-key") },
refreshModels: async (context) => {
effectiveCredential = context.credential;
},
}),
);
models.setProvider(
testProvider({
id: "unconfigured",
auth: { apiKey: envKeyAuth(undefined) },
refreshModels: async () => {
unconfiguredRefreshes++;
},
}),
);
await models.refresh();
expect(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined });
expect(unconfiguredRefreshes).toBe(0);
});
it("refreshes expired OAuth before refreshing models", async () => {
const credentials = new InMemoryCredentialStore();
let modelRefreshCredential: unknown;
await credentials.modify("oauth-dynamic", async () => ({
type: "oauth",
access: "expired",
refresh: "refresh",
expires: 0,
}));
const models = createModels({ credentials });
models.setProvider(
testProvider({
id: "oauth-dynamic",
auth: {
oauth: testOAuth({
refresh: async () => ({
type: "oauth",
access: "fresh",
refresh: "rotated",
expires: Date.now() + 60_000,
}),
}),
},
refreshModels: async (context) => {
modelRefreshCredential = context.credential;
},
}),
);
expect((await models.refresh()).errors.size).toBe(0);
expect(modelRefreshCredential).toMatchObject({ type: "oauth", access: "fresh", refresh: "rotated" });
expect(await credentials.read("oauth-dynamic")).toMatchObject({ access: "fresh", refresh: "rotated" });
});
it("returns aborted state without reporting cancellation as a provider error", async () => {
const controller = new AbortController();
const models = createModels();
models.setProvider(
testProvider({
id: "dynamic",
refreshModels: async ({ signal }) => {
controller.abort();
if (signal?.aborted) return;
},
}),
);
const result = await models.refresh({ signal: controller.signal });
expect(result.aborted).toBe(true);
expect(result.errors.size).toBe(0);
});
it("resolves auth: stored credential owns the provider, ambient only when nothing stored", async () => {
@@ -246,8 +369,10 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key"), oauth: testOAuth() } }));
const model = testModel("p1", "model-a");
// nothing stored: ambient env resolves
// model and provider-id overloads resolve the same provider-scoped auth
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
expect((await models.getAuth(model, { apiKey: "explicit-key" }))?.auth.apiKey).toBe("explicit-key");
// stored oauth credential (persisted via the single write path): beats ambient env
await credentials.modify("p1", async () => ({
@@ -256,17 +381,69 @@ describe("Models runtime", () => {
refresh: "r",
expires: Date.now() + 100000,
}));
const resolution = await models.getAuth(model);
const resolution = await models.getAuth(model.provider);
expect(resolution?.auth.apiKey).toBe("oauth-token");
expect(resolution?.source).toBe("OAuth");
// stored api-key credential resolves through apiKey auth, beats env
await credentials.modify("p1", async () => ({ type: "api_key", key: "stored-key" }));
const apiKeyResolution = await models.getAuth(model);
const apiKeyResolution = await models.getAuth(model.provider);
expect(apiKeyResolution?.auth.apiKey).toBe("stored-key");
expect(apiKeyResolution?.source).toBe("stored");
});
it("checks provider auth without refreshing OAuth and filters available models", async () => {
const credentials = new InMemoryCredentialStore();
let refreshes = 0;
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "ambient", auth: { apiKey: envKeyAuth("env-key") } }));
models.setProvider(testProvider({ id: "missing", auth: { apiKey: envKeyAuth(undefined) } }));
models.setProvider(
testProvider({
id: "oauth",
auth: {
oauth: testOAuth({
refresh: async (credential) => {
refreshes++;
return credential;
},
}),
},
}),
);
await credentials.modify("oauth", async () => ({
type: "oauth",
access: "expired",
refresh: "refresh",
expires: 0,
}));
expect(await models.checkAuth("ambient")).toEqual({ source: "env", type: "api_key" });
expect(await models.checkAuth("missing")).toBeUndefined();
expect(await models.checkAuth("oauth")).toEqual({ source: "OAuth", type: "oauth" });
expect(refreshes).toBe(0);
expect((await models.getAvailable()).map((model) => model.provider)).toEqual(["ambient", "oauth"]);
expect((await models.getAvailable("ambient")).map((model) => model.provider)).toEqual(["ambient"]);
});
it("runs provider login and logout through the credential store", async () => {
const credentials = new InMemoryCredentialStore();
const apiKey = envKeyAuth(undefined);
apiKey.login = async () => ({ type: "api_key", key: "logged-in" });
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { apiKey } }));
const credential = await models.login("p1", "api_key", {
prompt: async () => "unused",
notify: () => {},
});
expect(credential).toEqual({ type: "api_key", key: "logged-in" });
expect(await credentials.read("p1")).toEqual(credential);
await models.logout("p1");
expect(await credentials.read("p1")).toBeUndefined();
});
it("a stored credential without a matching handler blocks ambient fallback", async () => {
const credentials = new InMemoryCredentialStore();
const models = createModels({ credentials });
@@ -274,7 +451,7 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } }));
await credentials.modify("p1", async () => ({ type: "oauth", access: "a", refresh: "r", expires: 0 }));
expect(await models.getAuth(testModel("p1", "model-a"))).toBeUndefined();
expect(await models.getAuth("p1")).toBeUndefined();
});
it("refreshes expired oauth credentials and persists the rotated credential", async () => {
@@ -291,7 +468,7 @@ describe("Models runtime", () => {
expires: 0,
}));
const resolution = await models.getAuth(testModel("p1", "model-a"));
const resolution = await models.getAuth("p1");
expect(resolution?.auth.apiKey).toBe("new-token");
expect(((await credentials.read("p1")) as { access: string }).access).toBe("new-token");
});
@@ -307,7 +484,7 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }));
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "oauth" });
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "oauth" });
// credential preserved for retry / re-login
expect(((await credentials.read("p1")) as { access: string }).access).toBe("old");
});
@@ -328,7 +505,7 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
const model = testModel("p1", "model-a");
const [a, b] = await Promise.all([models.getAuth(model), models.getAuth(model)]);
const [a, b] = await Promise.all([models.getAuth(model.provider), models.getAuth(model.provider)]);
expect(refreshes).toBe(1);
expect(a?.auth.apiKey).toBe("new-1");
expect(b?.auth.apiKey).toBe("new-1");
@@ -339,6 +516,7 @@ describe("Models runtime", () => {
const base = new InMemoryCredentialStore();
const credentials: CredentialStore = {
read: (pid) => base.read(pid),
list: () => base.list(),
modify: (pid, fn) => {
modifies++;
return base.modify(pid, fn);
@@ -354,7 +532,7 @@ describe("Models runtime", () => {
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } }));
expect((await models.getAuth(testModel("p1", "model-a")))?.auth.apiKey).toBe("valid");
expect((await models.getAuth("p1"))?.auth.apiKey).toBe("valid");
expect(modifies).toBe(0);
});
@@ -364,16 +542,18 @@ describe("Models runtime", () => {
read: async () => {
throw new Error("disk on fire");
},
list: async () => [],
modify: async () => undefined,
delete: async () => {},
};
const models = createModels({ credentials: readFailing });
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } }));
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
// modify failure during refresh
const modifyFailing: CredentialStore = {
read: async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }),
list: async () => [{ providerId: "p1", type: "oauth" }],
modify: async () => {
throw new Error("disk on fire");
},
@@ -381,7 +561,7 @@ describe("Models runtime", () => {
};
const oauthModels = createModels({ credentials: modifyFailing });
oauthModels.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } }));
await expect(oauthModels.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
await expect(oauthModels.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
});
it("wraps api-key auth failures in ModelsError", async () => {
@@ -393,7 +573,7 @@ describe("Models runtime", () => {
};
const models = createModels();
models.setProvider(testProvider({ id: "p1", auth: { apiKey: failing } }));
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
});
it("uses explicit request api key and env during provider auth resolution", async () => {
@@ -427,7 +607,7 @@ describe("Models runtime", () => {
resolve: async () => ({
auth: {
apiKey: "resolved-key",
headers: { "x-a": "auth", "x-b": "auth" },
headers: { Authorization: "Bearer resolved-key", "x-a": "auth", "x-b": "auth" },
baseUrl: "https://auth.test/v1",
},
}),
@@ -438,12 +618,12 @@ describe("Models runtime", () => {
const result = await models.completeSimple(model, context, {
apiKey: "explicit-key",
headers: { "x-b": "explicit" },
headers: { authorization: "Explicit token", "x-b": "explicit" },
});
expect(result.stopReason).toBe("stop");
expect(calls).toHaveLength(1);
expect(calls[0].options?.apiKey).toBe("explicit-key");
expect(calls[0].options?.headers).toEqual({ "x-a": "auth", "x-b": "explicit" });
expect(calls[0].options?.headers).toEqual({ authorization: "Explicit token", "x-a": "auth", "x-b": "explicit" });
expect(calls[0].model.baseUrl).toBe("https://auth.test/v1");
// without explicit options, resolved auth applies
@@ -452,6 +632,36 @@ describe("Models runtime", () => {
expect(calls[1].options?.apiKey).toBe("resolved-key");
});
it("adds model headers only for model auth and transforms assembled headers once", async () => {
const calls: ProviderCall[] = [];
const models = createModels();
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("key") }, calls }));
const model = testModel("p1", "model-a");
model.headers = { "x-model": "model", "x-shared": "model" };
expect((await models.getAuth("p1"))?.auth.headers).toBeUndefined();
expect((await models.getAuth(model))?.auth.headers).toEqual({ "x-model": "model", "x-shared": "model" });
let transforms = 0;
await models.completeSimple(model, context, {
headers: { "x-explicit": "explicit", "X-Shared": "explicit" },
transformHeaders: async (headers) => {
transforms++;
expect(headers).toEqual({ "x-model": "model", "x-explicit": "explicit", "X-Shared": "explicit" });
return { ...headers, "x-transformed": "yes" };
},
});
expect(transforms).toBe(1);
expect(calls[0].options?.headers).toEqual({
"x-model": "model",
"x-explicit": "explicit",
"X-Shared": "explicit",
"x-transformed": "yes",
});
expect(calls[0].options).not.toHaveProperty("transformHeaders");
});
it("produces an error stream for unknown providers instead of throwing", async () => {
const models = createModels();
const result = await models.completeSimple(testModel("ghost", "model-a"), context);
+11 -5
View File
@@ -1,17 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
import { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
import { anthropicOAuth } from "../src/utils/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/utils/oauth/github-copilot.ts";
import { openaiCodexOAuth } from "../src/utils/oauth/openai-codex.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
describe.sequential("OAuthAuth adapters", () => {
it("keeps the extension OAuth barrel free of built-in flow implementations", () => {
expect(extensionOAuthCompatibility).not.toHaveProperty("loginAnthropic");
expect(extensionOAuthCompatibility).not.toHaveProperty("anthropicOAuth");
});
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -104,7 +110,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
models.setProvider(anthropicProvider());
const model = models.getModels("anthropic")[0];
const result = await models.getAuth(model);
const result = await models.getAuth(model.provider);
expect(result?.auth.apiKey).toBe("oauth-access-token");
expect(result?.source).toBe("OAuth");
});
@@ -122,7 +128,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
models.setProvider(githubCopilotProvider());
const model = models.getModels("github-copilot")[0];
const result = await models.getAuth(model);
const result = await models.getAuth(model.provider);
expect(result?.auth.apiKey).toBe(access);
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com");
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts";
import { pollOAuthDeviceCodeFlow } from "../src/auth/oauth/device-code.ts";
describe("OAuth device-code polling", () => {
afterEach(() => {
+11 -21
View File
@@ -8,8 +8,8 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import { getOAuthApiKey } from "../src/utils/oauth/index.ts";
import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.ts";
import type { OAuthCredentials } from "../src/auth/types.ts";
import { builtinProviders } from "../src/providers/all.ts";
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
@@ -65,28 +65,18 @@ export async function resolveApiKey(provider: string): Promise<string | undefine
}
if (entry.type === "oauth") {
// Build OAuthCredentials record for getOAuthApiKey
const oauthCredentials: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(storage)) {
if (value.type === "oauth") {
const { type: _, ...creds } = value;
oauthCredentials[key] = creds;
}
}
let result: { newCredentials: OAuthCredentials; apiKey: string } | null = null;
const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
if (!oauth) return undefined;
let credential = entry;
try {
result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials);
} catch (e) {
console.log(JSON.stringify(e));
if (Date.now() >= credential.expires) credential = await oauth.refresh(credential);
} catch (error) {
console.log(JSON.stringify(error));
return undefined;
}
if (!result) return undefined;
// Save refreshed credentials back to auth.json
storage[provider] = { type: "oauth", ...result.newCredentials };
storage[provider] = credential;
saveAuthStorage(storage);
return result.apiKey;
return (await oauth.toAuth(credential)).apiKey;
}
return undefined;
+56 -28
View File
@@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
loginOpenAICodexDeviceCode,
openaiCodexOAuthProvider,
refreshOpenAICodexToken,
} from "../src/utils/oauth/openai-codex.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), {
@@ -45,6 +41,30 @@ function deviceAuthPendingResponse(): Response {
);
}
function loginOpenAICodexDeviceCodeForTest(options: {
onDeviceCode(info: {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}): void;
signal?: AbortSignal;
}) {
return openaiCodexOAuth.login({
signal: options.signal,
prompt: async (prompt) => {
if (prompt.type !== "select") throw new Error(`Unexpected prompt: ${prompt.type}`);
return "device_code";
},
notify: (event) => {
if (event.type === "device_code") {
const { type: _, ...info } = event;
options.onDeviceCode(info);
}
},
});
}
describe("OpenAI Codex OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -125,7 +145,7 @@ describe("OpenAI Codex OAuth", () => {
vi.stubGlobal("fetch", fetchMock);
const credentialsPromise = loginOpenAICodexDeviceCode({
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: (info) => deviceInfos.push(info),
});
@@ -159,7 +179,7 @@ describe("OpenAI Codex OAuth", () => {
const accessToken = createAccessToken("account-456");
const selectPrompts: Array<{
message: string;
options: Array<{ id: string; label: string }>;
options: readonly { id: string; label: string }[];
}> = [];
const deviceInfos: Array<{
userCode: string;
@@ -199,20 +219,22 @@ describe("OpenAI Codex OAuth", () => {
);
await expect(
openaiCodexOAuthProvider.login({
onAuth: () => {
throw new Error("Browser login should not start");
},
onDeviceCode: (info) => deviceInfos.push(info),
onPrompt: async () => {
throw new Error("Prompt should not be used");
},
onSelect: async (prompt) => {
openaiCodexOAuth.login({
prompt: async (prompt) => {
if (prompt.type !== "select") throw new Error("Text prompt should not be used");
selectPrompts.push(prompt);
return "device_code";
},
notify: (event) => {
if (event.type === "auth_url") throw new Error("Browser login should not start");
if (event.type === "device_code") {
const { type: _, ...info } = event;
deviceInfos.push(info);
}
},
}),
).resolves.toMatchObject({
type: "oauth",
access: accessToken,
refresh: "refresh-token",
accountId: "account-456",
@@ -220,6 +242,7 @@ describe("OpenAI Codex OAuth", () => {
expect(selectPrompts).toEqual([
{
type: "select",
message: "Select OpenAI Codex login method:",
options: [
{ id: "browser", label: "Browser login (default)" },
@@ -239,11 +262,11 @@ describe("OpenAI Codex OAuth", () => {
it("cancels when OpenAI Codex login method selection is cancelled", async () => {
await expect(
openaiCodexOAuthProvider.login({
onAuth: () => {},
onDeviceCode: () => {},
onPrompt: async () => "",
onSelect: async () => undefined,
openaiCodexOAuth.login({
prompt: async () => {
throw new Error("Login cancelled");
},
notify: () => {},
}),
).rejects.toThrow("Login cancelled");
});
@@ -273,7 +296,7 @@ describe("OpenAI Codex OAuth", () => {
}),
);
const credentialsPromise = loginOpenAICodexDeviceCode({
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
signal: controller.signal,
});
@@ -317,7 +340,7 @@ describe("OpenAI Codex OAuth", () => {
}),
);
const credentialsPromise = loginOpenAICodexDeviceCode({
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
});
const rejectionPromise = credentialsPromise.then(
@@ -380,7 +403,7 @@ describe("OpenAI Codex OAuth", () => {
}),
);
const credentialsPromise = loginOpenAICodexDeviceCode({
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
});
@@ -418,7 +441,7 @@ describe("OpenAI Codex OAuth", () => {
);
await expect(
loginOpenAICodexDeviceCode({
loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {},
}),
).rejects.toThrow(
@@ -443,9 +466,14 @@ describe("OpenAI Codex OAuth", () => {
}),
);
await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow(
/OpenAI Codex token refresh failed \(401\).*Could not validate your token/,
);
await expect(
openaiCodexOAuth.refresh({
type: "oauth",
access: "invalid-access-token",
refresh: "invalid-refresh-token",
expires: 0,
}),
).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/);
expect(consoleError).not.toHaveBeenCalled();
});
});
+101 -55
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import type { AuthContext } from "../src/auth/types.ts";
import type { AuthContext, AuthEvent } from "../src/auth/types.ts";
import { createModels, createProvider } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import { builtinModels, builtinProviders } from "../src/providers/all.ts";
import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
@@ -34,10 +35,11 @@ describe("builtin providers", () => {
const all = models.getModels();
expect(all.length).toBeGreaterThan(500);
// every provider lists at least one model and owns its models
// Static providers list models immediately; Radius is purely dynamic.
for (const provider of providers) {
const list = models.getModels(provider.id);
expect(list.length).toBeGreaterThan(0);
if (provider.id === "radius") expect(list).toEqual([]);
else expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.provider === provider.id)).toBe(true);
}
});
@@ -49,22 +51,41 @@ describe("builtin providers", () => {
models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5")!;
const result = await models.getAuth(model);
const result = await models.getAuth(model.provider);
expect(result?.auth.apiKey).toBe("oauth-token");
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
});
it("prompts for and stores a Bedrock API key", async () => {
const provider = amazonBedrockProvider();
const credential = await provider.auth.apiKey?.login?.({
prompt: async (prompt) => {
expect(prompt).toEqual({ type: "secret", message: "Enter Bedrock API key" });
return "bedrock-api-key";
},
notify: () => {},
});
it("runs provider-owned Bedrock bearer token and AWS profile login flows", async () => {
const auth = amazonBedrockProvider().auth.apiKey!;
const bearerAnswers = ["bearer-token", "bedrock-token"];
expect(
await auth.login?.({
prompt: async () => bearerAnswers.shift()!,
notify: () => {},
}),
).toEqual({ type: "api_key", key: "bedrock-token" });
expect(credential).toEqual({ type: "api_key", key: "bedrock-api-key" });
const profileAnswers = ["aws-profile", "work"];
const events: AuthEvent[] = [];
expect(
await auth.login?.({
prompt: async () => profileAnswers.shift()!,
notify: (event) => events.push(event),
}),
).toEqual({ type: "api_key", env: { AWS_PROFILE: "work" } });
expect(events).toEqual([
expect.objectContaining({
type: "info",
links: [expect.objectContaining({ label: "AWS credential provider chain" })],
}),
]);
expect(
await auth.resolve({
ctx: fakeAuthContext({}),
credential: { type: "api_key", env: { AWS_PROFILE: "work" } },
}),
).toMatchObject({ auth: {}, env: { AWS_PROFILE: "work" } });
});
it("reports bedrock as configured from ambient AWS credentials without an api key", async () => {
@@ -72,50 +93,27 @@ describe("builtin providers", () => {
models.setProvider(amazonBedrockProvider());
const model = models.getModels("amazon-bedrock")[0];
const result = await models.getAuth(model);
const result = await models.getAuth(model.provider);
expect(result?.auth).toEqual({});
expect(result?.source).toBe("AWS_PROFILE");
const unconfigured = createModels({ authContext: fakeAuthContext({}) });
unconfigured.setProvider(amazonBedrockProvider());
expect(await unconfigured.getAuth(model)).toBeUndefined();
expect(await unconfigured.getAuth(model.provider)).toBeUndefined();
});
it("requires Cloudflare Workers AI account config and returns scoped env", async () => {
const missingAccount = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key" }) });
missingAccount.setProvider(cloudflareWorkersAIProvider());
const model = missingAccount.getModels("cloudflare-workers-ai")[0];
expect(await missingAccount.getAuth(model)).toBeUndefined();
expect(await missingAccount.getAuth(model.provider)).toBeUndefined();
const configured = createModels({
authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }),
});
configured.setProvider(cloudflareWorkersAIProvider());
const result = await configured.getAuth(model);
expect(result?.auth).toEqual({
apiKey: "cf-key",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1",
});
expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" });
});
// Regression for #6021: a credential carrying only the API key (as stored
// by `/login`) must still resolve CLOUDFLARE_ACCOUNT_ID from ambient env.
it("falls back to ambient CLOUDFLARE_ACCOUNT_ID when the credential carries only the API key", async () => {
const provider = cloudflareWorkersAIProvider();
const model = builtinModels().getModels("cloudflare-workers-ai")[0];
const auth = provider.auth.apiKey;
if (!auth) throw new Error("expected api-key auth");
const result = await auth.resolve({
model,
ctx: fakeAuthContext({ CLOUDFLARE_ACCOUNT_ID: "account-id" }),
credential: { type: "api_key", key: "cf-key" },
});
expect(result?.auth).toEqual({
apiKey: "cf-key",
baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1",
});
const result = await configured.getAuth(model.provider);
expect(result?.auth).toEqual({ apiKey: "cf-key" });
expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" });
});
@@ -125,7 +123,7 @@ describe("builtin providers", () => {
});
missingGateway.setProvider(cloudflareAIGatewayProvider());
const model = missingGateway.getModels("cloudflare-ai-gateway")[0];
expect(await missingGateway.getAuth(model)).toBeUndefined();
expect(await missingGateway.getAuth(model.provider)).toBeUndefined();
const configured = createModels({
authContext: fakeAuthContext({
@@ -135,14 +133,13 @@ describe("builtin providers", () => {
}),
});
configured.setProvider(cloudflareAIGatewayProvider());
const result = await configured.getAuth(model);
const result = await configured.getAuth(model.provider);
expect(result?.auth).toEqual({
headers: {
"cf-aig-authorization": "Bearer cf-key",
Authorization: null,
"x-api-key": null,
},
baseUrl: "https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/anthropic",
});
expect(result?.env).toEqual({
CLOUDFLARE_ACCOUNT_ID: "account-id",
@@ -150,6 +147,47 @@ describe("builtin providers", () => {
});
});
it("runs provider-owned Vertex API key and ADC login flows", async () => {
const auth = googleVertexProvider().auth.apiKey!;
const keyAnswers = ["api-key", "vertex-key"];
expect(
await auth.login?.({
prompt: async () => keyAnswers.shift()!,
notify: () => {},
}),
).toEqual({ type: "api_key", key: "vertex-key" });
const adcAnswers = ["adc", "project-id", "us-central1"];
const events: AuthEvent[] = [];
expect(
await auth.login?.({
prompt: async () => adcAnswers.shift()!,
notify: (event) => events.push(event),
}),
).toEqual({
type: "api_key",
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
});
expect(events).toEqual([
expect.objectContaining({
type: "info",
links: [expect.objectContaining({ label: "Application Default Credentials" })],
}),
]);
expect(
await auth.resolve({
ctx: fakeAuthContext({}, ["~/.config/gcloud/application_default_credentials.json"]),
credential: {
type: "api_key",
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
},
}),
).toMatchObject({
auth: {},
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
});
});
it("resolves vertex via ADC file plus project and location", async () => {
const adc = "~/.config/gcloud/application_default_credentials.json";
const configured = createModels({
@@ -158,40 +196,38 @@ describe("builtin providers", () => {
configured.setProvider(googleVertexProvider());
const model = configured.getModels("google-vertex")[0];
const result = await configured.getAuth(model);
const result = await configured.getAuth(model.provider);
expect(result?.auth).toEqual({});
expect(result?.source).toContain("application default");
// ADC without project/location is not configured
const partial = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj" }, [adc]) });
partial.setProvider(googleVertexProvider());
expect(await partial.getAuth(model)).toBeUndefined();
expect(await partial.getAuth(model.provider)).toBeUndefined();
// explicit key wins over ADC
const keyed = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_API_KEY: "vertex-key" }) });
keyed.setProvider(googleVertexProvider());
expect((await keyed.getAuth(model))?.auth.apiKey).toBe("vertex-key");
expect((await keyed.getAuth(model.provider))?.auth.apiKey).toBe("vertex-key");
});
});
describe("envApiKeyAuth", () => {
it("prefers the stored credential key and falls back through env vars in order", async () => {
const auth = envApiKeyAuth("Test key", ["FIRST_KEY", "SECOND_KEY"]);
const model = { provider: "p1" } as Model<Api>;
const stored = await auth.resolve({
model,
ctx: fakeAuthContext({ FIRST_KEY: "env" }),
credential: { type: "api_key", key: "stored" },
});
expect(stored?.auth.apiKey).toBe("stored");
expect(stored?.source).toBe("stored credential");
const second = await auth.resolve({ model, ctx: fakeAuthContext({ SECOND_KEY: "second" }) });
const second = await auth.resolve({ ctx: fakeAuthContext({ SECOND_KEY: "second" }) });
expect(second?.auth.apiKey).toBe("second");
expect(second?.source).toBe("SECOND_KEY");
expect(await auth.resolve({ model, ctx: fakeAuthContext({}) })).toBeUndefined();
expect(await auth.resolve({ ctx: fakeAuthContext({}) })).toBeUndefined();
});
it("login prompts for a secret and returns an api-key credential", async () => {
@@ -311,7 +347,7 @@ describe("createProvider", () => {
id: "dynamic",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetchModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testModel("api-a", "listed")];
@@ -319,13 +355,23 @@ describe("createProvider", () => {
api: recordingStreams("a", []),
});
const store = new InMemoryModelsStore();
const refreshContext = {
credential: { type: "api_key" as const },
store: {
read: () => store.read("dynamic"),
write: (listed: readonly Model<Api>[]) => store.write("dynamic", listed),
delete: () => store.delete("dynamic"),
},
allowNetwork: true,
};
expect(provider.getModels()).toEqual([]);
await Promise.all([provider.refreshModels?.(), provider.refreshModels?.()]);
await Promise.all([provider.refreshModels?.(refreshContext), provider.refreshModels?.(refreshContext)]);
expect(fetches).toBe(1);
expect(provider.getModels().map((m) => m.id)).toEqual(["listed"]);
// a later refresh fetches again
await provider.refreshModels?.();
await provider.refreshModels?.(refreshContext);
expect(fetches).toBe(2);
});
});
+1 -1
View File
@@ -21,7 +21,7 @@ models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5");
if (!model) throw new Error("model not found");
const auth = await models.getAuth(model);
const auth = await models.getAuth(model.provider);
console.log(`model: ${model.provider}/${model.id}`);
console.log(`auth: ${auth ? `configured via ${auth.source}` : "not configured"}\n`);
if (!auth) process.exit(1);
+14
View File
@@ -4,6 +4,11 @@
### Breaking Changes
- Replaced the SDK's `CreateAgentSessionOptions.authStorage` and `modelRegistry` options with the async `modelRuntime` option. `AuthStorage` and its storage backends are no longer exported; use `ModelRuntime` (or a custom pi-ai `CredentialStore`), or `readStoredCredential()` for one-off reads of auth.json.
- Removed redundant `ModelRuntime.getAll()`, `find()`, `getSnapshot()`, and `getAuthOptions()` projections. Use the pi-ai `Models` methods `getModels()`, `getModel()`, `getProviders()`, and `checkAuth()` directly.
- Replaced SDK request-auth assembly through `ModelRegistry.getApiKeyAndHeaders()` with `ModelRuntime.getAuth()`. Passing a provider ID returns provider-scoped auth; passing a model also resolves built-in, `models.json`, and extension model headers.
- Changed extension-facing `ModelRegistry.refresh()` from synchronous `void` to `Promise<void>` because `models.json` loading is asynchronous. Extensions must await it before making synchronous registry reads.
- Moved canonical dynamic catalog refresh to async `ModelRuntime.refresh()`/pi-ai `Models.refresh()`. Legacy extension OAuth `modifyModels` remains supported as a synchronous compatibility projection after credential initialization.
- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)).
### New Features
@@ -14,10 +19,19 @@
### Added
- Added `ModelRuntime` as the canonical async SDK and internal model/auth facade while preserving the synchronous extension-facing `ModelRegistry` API. `ModelRuntime.create()` accepts any pi-ai `CredentialStore` through its `credentials` option.
- Added provider-owned `/login` discovery directly from registered pi-ai providers, including ambient auth status and informational links.
- Added file-backed dynamic catalogs in `models-store.json`, per-provider pi.dev catalog overlays, and Radius gateway support including offline migration from legacy credential-cached catalogs.
- Added cache-friendly dynamic tool loading for extension tools activated by tool results. Supported Anthropic and OpenAI Responses models load definitions where they become available, preserving the cached prompt prefix. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading) ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)).
- Added inherited native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)).
- Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`.
### Changed
- Changed `ModelRuntime` to compose built-in providers, immutable `models.json` configuration, and extension overlays through ad-hoc pi-ai provider methods.
- Changed `ModelRuntime` to own final request assembly: `getAuth(model)` includes configured model headers, stream methods resolve auth once, and `before_provider_headers` runs as the Models-only header transform before provider dispatch.
- Changed `/model` to render the current model snapshot immediately, refresh configured providers in the background, and update the open selector with partial results or timeout errors.
### Fixed
- Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)).
+3 -5
View File
@@ -455,14 +455,12 @@ See [docs/packages.md](docs/packages.md).
### SDK
```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
await session.prompt("What files are in the current directory?");
+6 -11
View File
@@ -253,6 +253,8 @@ pi.registerProvider("custom-api", {
});
```
The key is resolved for each request. An explicit request `Authorization` header takes precedence over the generated value.
## OAuth Support
Add OAuth/SSO authentication that integrates with `/login`:
@@ -312,15 +314,6 @@ pi.registerProvider("corporate-ai", {
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
// Optional: modify models based on user's subscription
modifyModels(models, credentials) {
const region = decodeRegionFromToken(credentials.access);
return models.map(m => ({
...m,
baseUrl: `https://${region}.ai.corp.com/v1`
}));
}
}
});
@@ -330,7 +323,7 @@ After registration, users can authenticate via `/login corporate-ai`.
### OAuthLoginCallbacks
The `callbacks` object provides three ways to authenticate:
The `callbacks` object provides UI-neutral interactions for the provider-owned flow:
```typescript
interface OAuthLoginCallbacks {
@@ -345,6 +338,9 @@ interface OAuthLoginCallbacks {
expiresInSeconds?: number;
}): void;
// Show transient progress
onProgress?(message: string): void;
// Prompt user for input (for manual token entry)
onPrompt(params: { message: string }): Promise<string>;
@@ -660,7 +656,6 @@ interface ProviderConfig {
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey(credentials: OAuthCredentials): string;
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
}
```
+1
View File
@@ -136,6 +136,7 @@ Set `api` at provider level (default for all models) or model level (override pe
| `baseUrl` | API endpoint URL |
| `api` | API type (see above) |
| `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. |
| `oauth` | Dynamic OAuth provider type. Currently supports `"radius"`; requires the gateway `baseUrl`. |
| `headers` | Custom headers (see value resolution below) |
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
| `models` | Array of model configurations |
+7 -1
View File
@@ -1,6 +1,6 @@
# Providers
Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. For each provider, pi knows all available models. The list is updated with every pi release.
Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. Built-in catalogs ship with pi; configured providers may refresh newer catalogs and cache them in `~/.pi/agent/models-store.json` for offline use.
## Table of Contents
@@ -18,6 +18,7 @@ Use `/login` in interactive mode, then select a provider:
- ChatGPT Plus/Pro (Codex)
- Claude Pro/Max
- GitHub Copilot
- Radius
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired.
@@ -35,6 +36,10 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h
- Press Enter for github.com, or enter your GitHub Enterprise Server domain
- If you get "model not supported", enable it in VS Code: Copilot Chat → model selector → select model → "Enable"
### Radius
Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.
## API Keys
### Environment Variables or Auth File
@@ -68,6 +73,7 @@ pi
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` |
| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
| Radius | `RADIUS_API_KEY` | `radius` |
| Hugging Face | `HF_TOKEN` | `huggingface` |
| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
| Together AI | `TOGETHER_API_KEY` | `together` |
+41 -51
View File
@@ -16,16 +16,12 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
## Quick Start
```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
// Set up credential storage and model registry
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
session.subscribe((event) => {
@@ -369,10 +365,9 @@ When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control
```typescript
import { getModel } from "@earendil-works/pi-ai";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
@@ -380,10 +375,10 @@ if (!opus) throw new Error("Model not found");
// Find any model by provider/id, including custom models from models.json
// (doesn't check if API key exists)
const customModel = modelRegistry.find("my-provider", "my-model");
const customModel = modelRuntime.getModel("my-provider", "my-model");
// Get only models that have valid API keys configured
const available = await modelRegistry.getAvailable();
// Get only models that have valid authentication configured
const available = await modelRuntime.getAvailable();
const { session } = await createAgentSession({
model: opus,
@@ -395,8 +390,7 @@ const { session } = await createAgentSession({
{ model: haiku, thinkingLevel: "off" },
],
authStorage,
modelRegistry,
modelRuntime,
});
```
@@ -415,14 +409,14 @@ import {
const cliModel = resolveCliModel({
cliModel: "anthropic/claude-opus-4-5:high",
modelRegistry,
modelRuntime,
});
if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning);
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
["anthropic/*:high", "gpt-5"],
modelRegistry,
modelRuntime,
);
for (const diagnostic of diagnostics) {
console.warn(diagnostic.message);
@@ -435,40 +429,41 @@ for (const diagnostic of diagnostics) {
### API Keys and OAuth
API key resolution priority (handled by AuthStorage):
Authentication resolution priority (handled by `ModelRuntime`):
1. Runtime overrides (via `setRuntimeApiKey`, not persisted)
2. Stored credentials in `auth.json` (API keys or OAuth tokens)
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.json`)
```typescript
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { createAgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
});
// Provider-owned auth methods and current status
for (const provider of modelRuntime.getProviders()) {
const status = await modelRuntime.checkAuth(provider.id);
console.log(provider.name, provider.auth, status);
}
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");
// Custom auth storage location
const customAuth = AuthStorage.create("/my/app/auth.json");
const customRegistry = ModelRegistry.create(customAuth, "/my/app/models.json");
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuth,
modelRegistry: customRegistry,
// Custom credential and model locations
const customRuntime = await ModelRuntime.create({
authPath: "/my/app/auth.json",
modelsPath: "/my/app/models.json",
});
// No custom models.json (built-in models only)
const simpleRegistry = ModelRegistry.inMemory(authStorage);
// Or inject any pi-ai CredentialStore
const credentials = new InMemoryCredentialStore();
const inMemoryRuntime = await ModelRuntime.create({ credentials });
const { session } = await createAgentSession({
modelRuntime: customRuntime,
});
```
> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
@@ -927,26 +922,22 @@ interface LoadExtensionsResult {
import { getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
defineTool,
ModelRegistry,
ModelRuntime,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Set up auth storage (custom location)
const authStorage = AuthStorage.create("/custom/agent/auth.json");
// Runtime API key override (not persisted)
const modelRuntime = await ModelRuntime.create({
authPath: "/custom/agent/auth.json",
modelsPath: "/custom/agent/models.json",
});
if (process.env.MY_KEY) {
authStorage.setRuntimeApiKey("anthropic", process.env.MY_KEY);
modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}
// Model registry (no custom models.json)
const modelRegistry = ModelRegistry.create(authStorage);
// Inline tool
const statusTool = defineTool({
name: "status",
@@ -982,8 +973,7 @@ const { session } = await createAgentSession({
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
modelRuntime,
tools: ["read", "bash", "status"],
customTools: [statusTool],
@@ -1149,8 +1139,8 @@ createAgentSessionRuntime
AgentSessionRuntime
// Auth and Models
AuthStorage
ModelRegistry
ModelRuntime // implements pi-ai Models and owns credential storage
ModelRegistry // synchronous extension compatibility facade
resolveCliModel
resolveModelScopeWithDiagnostics
@@ -46,7 +46,7 @@ import {
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// =============================================================================
// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts)
// OAuth implementation adapted for the legacy extension compatibility interface.
// =============================================================================
const decode = (s: string) => atob(s);
@@ -5,11 +5,9 @@
*/
import { getModel } from "@earendil-works/pi-ai/compat";
import { AuthStorage, createAgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { createAgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5");
@@ -18,13 +16,13 @@ if (opus) {
}
// Option 2: Find model via registry (includes custom models from models.json)
const customModel = modelRegistry.find("my-provider", "my-model");
const customModel = modelRuntime.getModel("my-provider", "my-model");
if (customModel) {
console.log(`Found custom model: ${customModel.provider}/${customModel.id}`);
}
// Option 3: Pick from available models (have valid API keys)
const available = await modelRegistry.getAvailable();
const available = await modelRuntime.getAvailable();
console.log(
"Available models:",
available.map((m) => `${m.provider}/${m.id}`),
@@ -34,8 +32,7 @@ if (available.length > 0) {
const { session } = await createAgentSession({
model: available[0],
thinkingLevel: "medium", // off, low, medium, high
authStorage,
modelRegistry,
modelRuntime,
});
try {
@@ -1,52 +1,34 @@
/**
* API Keys and OAuth
*
* Configure API key resolution via AuthStorage and ModelRegistry.
* Configure provider auth through ModelRuntime.
*/
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
// Default: AuthStorage uses ~/.pi/agent/auth.json
// ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const modelRuntime = await ModelRuntime.create();
const { session: defaultAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
console.log("Session with default auth storage and model registry");
console.log("Session with default model runtime");
defaultAuthSession.dispose();
// Custom auth storage location
const customAuthStorage = AuthStorage.create("/tmp/my-app/auth.json");
const customModelRegistry = ModelRegistry.create(customAuthStorage, "/tmp/my-app/models.json");
const customRuntime = await ModelRuntime.create({
authPath: "/tmp/my-app/auth.json",
modelsPath: "/tmp/my-app/models.json",
});
const { session: customAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuthStorage,
modelRegistry: customModelRegistry,
modelRuntime: customRuntime,
});
console.log("Session with custom auth storage location");
console.log("Session with custom auth and models locations");
customAuthSession.dispose();
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");
const { session: runtimeKeySession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
console.log("Session with runtime API key override");
runtimeKeySession.dispose();
// No models.json - only built-in models
const simpleRegistry = ModelRegistry.inMemory(authStorage);
const { session: builtInModelsSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry: simpleRegistry,
});
console.log("Session with only built-in models");
builtInModelsSession.dispose();
@@ -6,26 +6,22 @@
import { getModel } from "@earendil-works/pi-ai/compat";
import {
AuthStorage,
createAgentSession,
createExtensionRuntime,
ModelRegistry,
ModelRuntime,
type ResourceLoader,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Custom auth storage location
const authStorage = AuthStorage.create("/tmp/my-agent/auth.json");
// Runtime API key override (not persisted)
const modelRuntime = await ModelRuntime.create({
authPath: "/tmp/my-agent/auth.json",
modelsPath: "/tmp/my-agent/models.json",
});
if (process.env.MY_ANTHROPIC_KEY) {
authStorage.setRuntimeApiKey("anthropic", process.env.MY_ANTHROPIC_KEY);
modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_ANTHROPIC_KEY);
}
// Model registry with no custom models.json
const modelRegistry = ModelRegistry.inMemory(authStorage);
const model = getModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Model not found");
@@ -55,8 +51,7 @@ const { session } = await createAgentSession({
agentDir: "/tmp/my-agent",
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
modelRuntime,
resourceLoader,
tools: ["read", "bash"],
sessionManager: SessionManager.inMemory(cwd),
+14 -18
View File
@@ -34,46 +34,44 @@ npx tsx examples/sdk/01-minimal.ts
```typescript
import { getModel } from "@earendil-works/pi-ai";
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
ModelRegistry,
ModelRuntime,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Auth and models setup
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Minimal
const { session } = await createAgentSession({ authStorage, modelRegistry });
const { session } = await createAgentSession({ modelRuntime });
// Custom model
const model = getModel("anthropic", "claude-opus-4-5");
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
const { session } = await createAgentSession({ model, thinkingLevel: "high", modelRuntime });
// Modify prompt
const loader = new DefaultResourceLoader({
systemPromptOverride: (base) => `${base}\n\nBe concise.`,
});
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
const { session } = await createAgentSession({ resourceLoader: loader, modelRuntime });
// Read-only
const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], authStorage, modelRegistry });
const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], modelRuntime });
// In-memory
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
// Full control
const customAuth = AuthStorage.create("/my/app/auth.json");
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const customRegistry = ModelRegistry.create(customAuth);
const customRuntime = await ModelRuntime.create({
authPath: "/my/app/auth.json",
modelsPath: "/my/app/models.json",
});
customRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () => "You are helpful.",
@@ -86,8 +84,7 @@ await resourceLoader.reload();
const { session } = await createAgentSession({
model,
authStorage: customAuth,
modelRegistry: customRegistry,
modelRuntime: customRuntime,
resourceLoader,
tools: ["read", "bash", "my_tool"],
customTools: [myTool],
@@ -108,8 +105,7 @@ await session.prompt("Hello");
| Option | Default | Description |
|--------|---------|-------------|
| `authStorage` | `AuthStorage.create()` | Credential storage |
| `modelRegistry` | `ModelRegistry.create(authStorage)` | Model registry |
| `modelRuntime` | Runtime using `agentDir/auth.json` and `models.json` | Canonical model and authentication runtime |
| `cwd` | `process.cwd()` | Working directory |
| `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use |
+4 -4
View File
@@ -6,7 +6,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
import { fuzzyFilter } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts";
import type { ModelRegistry } from "../core/model-registry.ts";
import type { ModelRuntime } from "../core/model-runtime.ts";
/**
* Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")
@@ -26,13 +26,13 @@ function formatTokenCount(count: number): string {
/**
* List available models, optionally filtered by search pattern
*/
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
const loadError = modelRegistry.getError();
export async function listModels(modelRuntime: ModelRuntime, searchPattern?: string): Promise<void> {
const loadError = modelRuntime.getError();
if (loadError) {
console.error(chalk.yellow(`Warning: errors loading models.json:\n${loadError}`));
}
const models = modelRegistry.getAvailable();
const models = [...(await modelRuntime.getAvailable())];
if (models.length === 0) {
console.log(formatNoModelsAvailableMessage());
@@ -3,9 +3,8 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
import { ModelRuntime } from "./model-runtime.ts";
import {
DefaultResourceLoader,
type DefaultResourceLoaderOptions,
@@ -38,9 +37,8 @@ export interface AgentSessionRuntimeDiagnostic {
export interface CreateAgentSessionServicesOptions {
cwd: string;
agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager;
modelRegistry?: ModelRegistry;
modelRuntime?: ModelRuntime;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
@@ -74,9 +72,8 @@ export interface CreateAgentSessionFromServicesOptions {
export interface AgentSessionServices {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
modelRuntime: ModelRuntime;
settingsManager: SettingsManager;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
@@ -139,9 +136,13 @@ export async function createAgentSessionServices(
): Promise<AgentSessionServices> {
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const modelRuntime =
options.modelRuntime ??
(await ModelRuntime.create({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
}));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
cwd,
@@ -154,7 +155,7 @@ export async function createAgentSessionServices(
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
modelRuntime.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
@@ -164,14 +165,14 @@ export async function createAgentSessionServices(
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
await modelRuntime.refresh({ allowNetwork: false });
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return {
cwd,
agentDir,
authStorage,
modelRuntime,
settingsManager,
modelRegistry,
resourceLoader,
diagnostics,
};
@@ -190,9 +191,8 @@ export async function createAgentSessionFromServices(
return createAgentSession({
cwd: options.services.cwd,
agentDir: options.services.agentDir,
authStorage: options.services.authStorage,
modelRuntime: options.services.modelRuntime,
settingsManager: options.services.settingsManager,
modelRegistry: options.services.modelRegistry,
resourceLoader: options.services.resourceLoader,
sessionManager: options.sessionManager,
model: options.model,
+68 -35
View File
@@ -24,7 +24,15 @@ import type {
PrepareNextTurnContext,
ThinkingLevel,
} from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat";
import type {
AssistantMessage,
AuthResult,
ImageContent,
Message,
Model,
ProviderHeaders,
TextContent,
} from "@earendil-works/pi-ai/compat";
import {
clampThinkingLevel,
cleanupSessionResources,
@@ -83,7 +91,8 @@ import {
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import { ModelRegistry } from "./model-registry.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
@@ -159,6 +168,12 @@ export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
// Types
// ============================================================================
function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
return headers
? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
: undefined;
}
export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
@@ -170,8 +185,8 @@ export interface AgentSessionConfig {
resourceLoader: ResourceLoader;
/** SDK custom tools registered outside extensions */
customTools?: ToolDefinition[];
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Canonical model/auth runtime used by coding-agent internals. */
modelRuntime: ModelRuntime;
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[];
/** Optional allowlist of tool names. When provided, only these tool names are exposed. */
@@ -325,8 +340,7 @@ export class AgentSession {
private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void;
// Model registry for API key resolution
private _modelRegistry: ModelRegistry;
private _modelRuntime: ModelRuntime;
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map();
@@ -347,7 +361,7 @@ export class AgentSession {
this._resourceLoader = config.resourceLoader;
this._customTools = config.customTools ?? [];
this._cwd = config.cwd;
this._modelRegistry = config.modelRegistry;
this._modelRuntime = config.modelRuntime;
this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames;
this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
@@ -367,9 +381,8 @@ export class AgentSession {
});
}
/** Model registry for API key resolution and model discovery */
get modelRegistry(): ModelRegistry {
return this._modelRegistry;
get modelRuntime(): ModelRuntime {
return this._modelRuntime;
}
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
@@ -377,18 +390,25 @@ export class AgentSession {
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
if (!result.ok) {
if (result.error.startsWith("No API key found")) {
let result: AuthResult | undefined;
try {
result = await this._modelRuntime.getAuth(model);
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined;
if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {
throw new Error(formatNoApiKeyFoundMessage(model.provider));
}
throw new Error(result.error);
throw error;
}
if (result.apiKey) {
return { apiKey: result.apiKey, headers: result.headers, env: result.env };
if (result?.auth.apiKey) {
return {
apiKey: result.auth.apiKey,
headers: withoutDeletedHeaders(result.auth.headers),
env: result.env,
};
}
const isOAuth = this._modelRegistry.isUsingOAuth(model);
const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
if (isOAuth) {
throw new Error(
`Authentication failed for "${model.provider}". ` +
@@ -408,8 +428,14 @@ export class AgentSession {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {};
try {
const result = await this._modelRuntime.getAuth(model);
return result
? { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env }
: {};
} catch {
return {};
}
}
/**
@@ -1141,8 +1167,11 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
if (!this._modelRegistry.hasConfiguredAuth(this.model)) {
const isOAuth = this._modelRegistry.isUsingOAuth(this.model);
const hasConfiguredAuth =
this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
(await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
if (!hasConfiguredAuth) {
const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
if (isOAuth) {
throw new Error(
`Authentication failed for "${this.model.provider}". ` +
@@ -1535,7 +1564,7 @@ export class AgentSession {
* @throws Error if no auth is configured for the model
*/
async setModel(model: Model<any>): Promise<void> {
if (!this._modelRegistry.hasConfiguredAuth(model)) {
if (!(await this._modelRuntime.checkAuth(model.provider))) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
@@ -1565,7 +1594,13 @@ export class AgentSession {
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
const checks = await Promise.all(
this._scopedModels.map(async (scoped) => ({
scoped,
auth: await this._modelRuntime.checkAuth(scoped.model.provider),
})),
);
const scopedModels = checks.filter(({ auth }) => auth !== undefined).map(({ scoped }) => scoped);
if (scopedModels.length <= 1) return undefined;
const currentModel = this.model;
@@ -1594,7 +1629,7 @@ export class AgentSession {
}
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const availableModels = await this._modelRegistry.getAvailable();
const availableModels = await this._modelRuntime.getAvailable();
if (availableModels.length <= 1) return undefined;
const currentModel = this.model;
@@ -2004,12 +2039,10 @@ export class AgentSession {
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
return false;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey;
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
} else {
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
@@ -2267,7 +2300,7 @@ export class AgentSession {
return;
}
const refreshedModel = this._modelRegistry.find(currentModel.provider, currentModel.id);
const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
if (!refreshedModel || refreshedModel === currentModel) {
return;
}
@@ -2343,7 +2376,7 @@ export class AgentSession {
refreshTools: () => this._refreshToolRegistry(),
getCommands,
setModel: async (model) => {
if (!this.modelRegistry.hasConfiguredAuth(model)) return false;
if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;
await this.setModel(model);
return true;
},
@@ -2383,11 +2416,11 @@ export class AgentSession {
},
{
registerProvider: (name, config) => {
this._modelRegistry.registerProvider(name, config);
this._modelRuntime.registerProvider(name, config);
this._refreshCurrentModelFromRegistry();
},
unregisterProvider: (name) => {
this._modelRegistry.unregisterProvider(name);
this._modelRuntime.unregisterProvider(name);
this._refreshCurrentModelFromRegistry();
},
},
@@ -2523,7 +2556,7 @@ export class AgentSession {
extensionsResult.runtime,
this._cwd,
this.sessionManager,
this._modelRegistry,
new ModelRegistry(this._modelRuntime),
);
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
+50 -318
View File
@@ -1,19 +1,9 @@
/**
* Credential storage for API keys and OAuth tokens.
* Handles loading, saving, and refreshing credentials from auth.json.
*
* Uses file locking to prevent race conditions when multiple pi instances
* try to refresh tokens simultaneously.
* CredentialStore implementation backed by auth.json.
* Provider auth orchestration belongs to ModelRuntime and pi-ai Models.
*/
import {
findEnvKeys,
getEnvApiKey,
type OAuthCredentials,
type OAuthLoginCallbacks,
type OAuthProviderId,
} from "@earendil-works/pi-ai/compat";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import type { Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
@@ -21,29 +11,7 @@ import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
key: string;
env?: Record<string, string>;
};
export type OAuthCredential = {
type: "oauth";
} & OAuthCredentials;
export type AuthCredential = ApiKeyCredential | OAuthCredential;
export type AuthStorageData = Record<string, AuthCredential>;
export type AuthStatus = {
configured: boolean;
source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command";
label?: string;
};
export interface GetApiKeyOptions {
includeFallback?: boolean;
}
type AuthStorageData = Record<string, Credential>;
type LockResult<T> = {
result: T;
@@ -200,11 +168,8 @@ export class InMemoryAuthStorageBackend implements AuthStorageBackend {
/**
* Credential storage backed by a JSON file.
*/
export class AuthStorage {
export class AuthStorage implements CredentialStore {
private data: AuthStorageData = {};
private runtimeOverrides: Map<string, string> = new Map();
private loadError: Error | null = null;
private errors: Error[] = [];
private storage: AuthStorageBackend;
private constructor(storage: AuthStorageBackend) {
@@ -226,26 +191,6 @@ export class AuthStorage {
return AuthStorage.fromStorage(storage);
}
/**
* Set a runtime API key override (not persisted to disk).
* Used for CLI --api-key flag.
*/
setRuntimeApiKey(provider: string, apiKey: string): void {
this.runtimeOverrides.set(provider, apiKey);
}
/**
* Remove a runtime API key override.
*/
removeRuntimeApiKey(provider: string): void {
this.runtimeOverrides.delete(provider);
}
private recordError(error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push(normalizedError);
}
private parseStorageData(content: string | undefined): AuthStorageData {
if (!content) {
return {};
@@ -264,276 +209,63 @@ export class AuthStorage {
return { result: undefined };
});
this.data = this.parseStorageData(content);
this.loadError = null;
} catch (error) {
this.loadError = error as Error;
this.recordError(error);
} catch {
// Preserve the last valid in-memory snapshot.
}
}
private persistProviderChange(provider: string, credential: AuthCredential | undefined): AuthStorageData {
if (this.loadError) {
this.reload();
}
if (this.loadError) {
const error = new Error(
`Cannot update auth storage because it could not be loaded: ${this.loadError.message}`,
);
this.recordError(error);
throw error;
}
try {
let persistedData: AuthStorageData = {};
this.storage.withLock((current) => {
const currentData = this.parseStorageData(current);
const merged: AuthStorageData = { ...currentData };
if (credential) {
merged[provider] = credential;
} else {
delete merged[provider];
}
persistedData = merged;
return { result: undefined, next: JSON.stringify(merged, null, 2) };
});
this.loadError = null;
return persistedData;
} catch (error) {
this.recordError(error);
throw error;
}
async read(provider: string): Promise<Credential | undefined> {
const credential = this.data[provider];
if (credential?.type !== "api_key") return credential;
if (credential.key === undefined) return credential;
return { ...credential, key: resolveConfigValue(credential.key, credential.env) };
}
/**
* Get credential for a provider.
*/
get(provider: string): AuthCredential | undefined {
return this.data[provider] ?? undefined;
}
/**
* Get provider-scoped environment values for an API key credential.
*/
getProviderEnv(provider: string): Record<string, string> | undefined {
const cred = this.data[provider];
return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined;
}
/**
* Set credential for a provider.
*/
set(provider: string, credential: AuthCredential): void {
this.data = this.persistProviderChange(provider, credential);
}
/**
* Remove credential for a provider.
*/
remove(provider: string): void {
this.data = this.persistProviderChange(provider, undefined);
}
/**
* List all providers with credentials.
*/
list(): string[] {
return Object.keys(this.data);
}
/**
* Check if credentials exist for a provider in auth.json.
*/
has(provider: string): boolean {
return provider in this.data;
}
/**
* Check if any form of auth is configured for a provider.
* Unlike getApiKey(), this doesn't refresh OAuth tokens.
*/
hasAuth(provider: string): boolean {
if (this.runtimeOverrides.has(provider)) return true;
if (this.data[provider]) return true;
if (getEnvApiKey(provider)) return true;
return false;
}
/**
* Return auth status without exposing credential values or refreshing tokens.
*/
getAuthStatus(provider: string): AuthStatus {
if (this.data[provider]) {
return { configured: true, source: "stored" };
}
if (this.runtimeOverrides.has(provider)) {
return { configured: false, source: "runtime", label: "--api-key" };
}
const envKeys = findEnvKeys(provider);
if (envKeys?.[0]) {
return { configured: false, source: "environment", label: envKeys[0] };
}
return { configured: false };
}
/**
* Get all credentials (for passing to getOAuthApiKey).
*/
getAll(): AuthStorageData {
return { ...this.data };
}
drainErrors(): Error[] {
const drained = [...this.errors];
this.errors = [];
return drained;
}
/**
* Login to an OAuth provider.
*/
async login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise<void> {
const provider = getOAuthProvider(providerId);
if (!provider) {
throw new Error(`Unknown OAuth provider: ${providerId}`);
}
const credentials = await provider.login(callbacks);
this.set(providerId, { type: "oauth", ...credentials });
}
/**
* Logout from a provider.
*/
logout(provider: string): void {
this.remove(provider);
}
/**
* Refresh OAuth token with backend locking to prevent race conditions.
* Multiple pi instances may try to refresh simultaneously when tokens expire.
*/
private async refreshOAuthTokenWithLock(
providerId: OAuthProviderId,
): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const provider = getOAuthProvider(providerId);
if (!provider) {
return null;
}
const result = await this.storage.withLockAsync(async (current) => {
const currentData = this.parseStorageData(current);
this.data = currentData;
this.loadError = null;
const cred = currentData[providerId];
if (cred?.type !== "oauth") {
return { result: null };
async modify(
provider: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.storage.withLockAsync(async (content) => {
const currentData = this.parseStorageData(content);
const next = await fn(currentData[provider]);
if (next === undefined) {
this.data = currentData;
return { result: currentData[provider] };
}
if (Date.now() < cred.expires) {
return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } };
}
const oauthCreds: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(currentData)) {
if (value.type === "oauth") {
oauthCreds[key] = value;
}
}
const refreshed = await getOAuthApiKey(providerId, oauthCreds);
if (!refreshed) {
return { result: null };
}
const merged: AuthStorageData = {
...currentData,
[providerId]: { type: "oauth", ...refreshed.newCredentials },
};
const merged: AuthStorageData = { ...currentData, [provider]: next };
this.data = merged;
this.loadError = null;
return { result: refreshed, next: JSON.stringify(merged, null, 2) };
return { result: next, next: JSON.stringify(merged, null, 2) };
});
return result;
}
/**
* Get API key for a provider.
* Priority:
* 1. Runtime override (CLI --api-key)
* 2. API key from auth.json
* 3. OAuth token from auth.json (auto-refreshed with locking)
* 4. Environment variable
*/
async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(providerId);
if (runtimeKey) {
return runtimeKey;
}
const cred = this.data[providerId];
if (cred?.type === "api_key") {
return resolveConfigValue(cred.key, cred.env);
}
if (cred?.type === "oauth") {
const provider = getOAuthProvider(providerId);
if (!provider) {
// Unknown OAuth provider, can't get API key
return undefined;
}
// Check if token needs refresh
const needsRefresh = Date.now() >= cred.expires;
if (needsRefresh) {
// Use locked refresh to prevent race conditions
try {
const result = await this.refreshOAuthTokenWithLock(providerId);
if (result) {
return result.apiKey;
}
} catch (error) {
this.recordError(error);
// Refresh failed - re-read file to check if another instance succeeded
this.reload();
const updatedCred = this.data[providerId];
if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
// Another instance refreshed successfully, use those credentials
return provider.getApiKey(updatedCred);
}
// Refresh truly failed - return undefined so model discovery skips this provider
// User can /login to re-authenticate (credentials preserved for retry)
return undefined;
}
} else {
// Token not expired, use current access token
return provider.getApiKey(cred);
}
}
if (options.includeFallback === false) return undefined;
// Fall back to environment variable
const envKey = getEnvApiKey(providerId);
if (envKey) return envKey;
return undefined;
async delete(provider: string): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const currentData = this.parseStorageData(content);
delete currentData[provider];
this.data = currentData;
return { result: undefined, next: JSON.stringify(currentData, null, 2) };
});
}
/**
* Get all registered OAuth providers
*/
getOAuthProviders() {
return getOAuthProviders();
/** List credential metadata without resolving configured key values. */
async list(): Promise<readonly CredentialInfo[]> {
return Object.entries(this.data).map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
}
/**
* One-off synchronous read of a stored credential from an auth.json file,
* without instantiating a store or resolving configured key values.
*/
export function readStoredCredential(
providerId: string,
authPath: string = join(getAgentDir(), "auth.json"),
): Credential | undefined {
try {
const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData;
return data[providerId];
} catch {
return undefined;
}
}
@@ -29,9 +29,9 @@ export interface CacheWasteTotals {
missCount: number;
}
/** Minimal pricing lookup, satisfied by ModelRegistry. Cost is $/million tokens. */
/** Minimal pricing lookup, satisfied by ModelRuntime. Cost is $/million tokens. */
export interface ModelPriceSource {
find(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
getModel(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
}
/** The last request seen by the scan; everything in its prompt should be cached. */
@@ -79,7 +79,7 @@ function detectMiss(
const readPerToken =
usage.cacheRead > 0
? usage.cost.cacheRead / usage.cacheRead
: (models.find(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
: (models.getModel(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
return {
missedTokens,
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat";
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
import * as _bundledPiAiProviders from "@earendil-works/pi-ai/providers/all";
import type { KeyId } from "@earendil-works/pi-tui";
import * as _bundledPiTui from "@earendil-works/pi-tui";
import { createJiti } from "jiti/static";
@@ -58,12 +59,14 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
"@earendil-works/pi-ai": _bundledPiAiCompat,
"@earendil-works/pi-ai/compat": _bundledPiAiCompat,
"@earendil-works/pi-ai/oauth": _bundledPiAiOauth,
"@earendil-works/pi-ai/providers/all": _bundledPiAiProviders,
"@earendil-works/pi-coding-agent": _bundledPiCodingAgent,
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui,
"@mariozechner/pi-ai": _bundledPiAiCompat,
"@mariozechner/pi-ai/compat": _bundledPiAiCompat,
"@mariozechner/pi-ai/oauth": _bundledPiAiOauth,
"@mariozechner/pi-ai/providers/all": _bundledPiAiProviders,
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
};
@@ -102,20 +105,26 @@ function getAliases(): Record<string, string> {
// global API keep working at runtime until compat is removed.
const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat");
const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth");
const piAiProvidersEntry = resolveWorkspaceOrImport(
"ai/dist/providers/all.js",
"@earendil-works/pi-ai/providers/all",
);
_aliases = {
"@earendil-works/pi-coding-agent": piCodingAgentEntry,
"@earendil-works/pi-agent-core": piAgentCoreEntry,
"@earendil-works/pi-tui": piTuiEntry,
"@earendil-works/pi-ai": piAiCompatEntry,
"@earendil-works/pi-ai/providers/all": piAiProvidersEntry,
"@earendil-works/pi-ai/compat": piAiCompatEntry,
"@earendil-works/pi-ai/oauth": piAiOauthEntry,
"@earendil-works/pi-ai": piAiCompatEntry,
"@mariozechner/pi-coding-agent": piCodingAgentEntry,
"@mariozechner/pi-agent-core": piAgentCoreEntry,
"@mariozechner/pi-tui": piTuiEntry,
"@mariozechner/pi-ai": piAiCompatEntry,
"@mariozechner/pi-ai/providers/all": piAiProvidersEntry,
"@mariozechner/pi-ai/compat": piAiCompatEntry,
"@mariozechner/pi-ai/oauth": piAiOauthEntry,
"@mariozechner/pi-ai": piAiCompatEntry,
typebox: typeboxEntry,
"typebox/compile": typeboxCompileEntry,
"typebox/value": typeboxValueEntry,
@@ -603,6 +603,10 @@ export class ExtensionRunner {
});
}
getModelRegistry(): ModelRegistry {
return this.modelRegistry;
}
getRegisteredCommands(): ResolvedCommand[] {
this.commandDiagnostics = [];
return this.resolveRegisteredCommands();
@@ -1424,13 +1424,15 @@ export interface ProviderConfig {
oauth?: {
/** Display name for the provider in login UI. */
name: string;
/** @deprecated Retained for source compatibility; canonical auth flows ignore it. */
usesCallbackServer?: boolean;
/** Run the login flow, return credentials to persist. */
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
/** Refresh expired credentials, return updated credentials to persist. */
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
/** Convert credentials to API key string for the provider. */
getApiKey(credentials: OAuthCredentials): string;
/** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */
/** Legacy synchronous credential-dependent model projection. */
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
}
@@ -0,0 +1,286 @@
/** Immutable, credential-blind models.json snapshot. */
import { readFile } from "node:fs/promises";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
const PercentileCutoffsSchema = Type.Object({
p50: Type.Optional(Type.Number()),
p75: Type.Optional(Type.Number()),
p90: Type.Optional(Type.Number()),
p99: Type.Optional(Type.Number()),
});
const OpenRouterRoutingSchema = Type.Object({
allow_fallbacks: Type.Optional(Type.Boolean()),
require_parameters: Type.Optional(Type.Boolean()),
data_collection: Type.Optional(Type.Union([Type.Literal("deny"), Type.Literal("allow")])),
zdr: Type.Optional(Type.Boolean()),
enforce_distillable_text: Type.Optional(Type.Boolean()),
order: Type.Optional(Type.Array(Type.String())),
only: Type.Optional(Type.Array(Type.String())),
ignore: Type.Optional(Type.Array(Type.String())),
quantizations: Type.Optional(Type.Array(Type.String())),
sort: Type.Optional(
Type.Union([
Type.String(),
Type.Object({
by: Type.Optional(Type.String()),
partition: Type.Optional(Type.Union([Type.String(), Type.Null()])),
}),
]),
),
max_price: Type.Optional(
Type.Object({
prompt: Type.Optional(Type.Union([Type.Number(), Type.String()])),
completion: Type.Optional(Type.Union([Type.Number(), Type.String()])),
image: Type.Optional(Type.Union([Type.Number(), Type.String()])),
audio: Type.Optional(Type.Union([Type.Number(), Type.String()])),
request: Type.Optional(Type.Union([Type.Number(), Type.String()])),
}),
),
preferred_min_throughput: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])),
preferred_max_latency: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])),
});
const VercelGatewayRoutingSchema = Type.Object({
only: Type.Optional(Type.Array(Type.String())),
order: Type.Optional(Type.Array(Type.String())),
});
const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);
const ThinkingLevelMapSchema = Type.Object({
off: Type.Optional(ThinkingLevelMapValueSchema),
minimal: Type.Optional(ThinkingLevelMapValueSchema),
low: Type.Optional(ThinkingLevelMapValueSchema),
medium: Type.Optional(ThinkingLevelMapValueSchema),
high: Type.Optional(ThinkingLevelMapValueSchema),
xhigh: Type.Optional(ThinkingLevelMapValueSchema),
max: Type.Optional(ThinkingLevelMapValueSchema),
});
const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
const ChatTemplateKwargVariableSchema = Type.Object({
$var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]),
omitWhenOff: Type.Optional(Type.Boolean()),
});
const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]);
const OpenAICompletionsCompatSchema = Type.Object({
supportsStore: Type.Optional(Type.Boolean()),
supportsDeveloperRole: Type.Optional(Type.Boolean()),
supportsReasoningEffort: Type.Optional(Type.Boolean()),
supportsUsageInStreaming: Type.Optional(Type.Boolean()),
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
requiresToolResultName: Type.Optional(Type.Boolean()),
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
requiresThinkingAsText: Type.Optional(Type.Boolean()),
requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()),
thinkingFormat: Type.Optional(
Type.Union([
Type.Literal("openai"),
Type.Literal("openrouter"),
Type.Literal("together"),
Type.Literal("deepseek"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("chat-template"),
Type.Literal("qwen-chat-template"),
Type.Literal("string-thinking"),
Type.Literal("ant-ling"),
]),
),
chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)),
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
supportsStrictMode: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
sessionAffinityFormat: Type.Optional(
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
});
const OpenAIResponsesCompatSchema = Type.Object({
supportsDeveloperRole: Type.Optional(Type.Boolean()),
sessionAffinityFormat: Type.Optional(
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
supportsToolSearch: Type.Optional(Type.Boolean()),
});
const AnthropicMessagesCompatSchema = Type.Object({
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
});
const ProviderCompatSchema = Type.Union([
OpenAICompletionsCompatSchema,
OpenAIResponsesCompatSchema,
AnthropicMessagesCompatSchema,
]);
const ModelCostRatesSchema = {
input: Type.Number(),
output: Type.Number(),
cacheRead: Type.Number(),
cacheWrite: Type.Number(),
};
const ModelCostTierSchema = Type.Object({
inputTokensAbove: Type.Number(),
...ModelCostRatesSchema,
});
const ModelCostSchema = Type.Object({
...ModelCostRatesSchema,
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
});
const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
cost: Type.Optional(ModelCostSchema),
contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
});
const ModelOverrideSchema = Type.Object({
name: Type.Optional(Type.String({ minLength: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
cost: Type.Optional(
Type.Object({
input: Type.Optional(Type.Number()),
output: Type.Optional(Type.Number()),
cacheRead: Type.Optional(Type.Number()),
cacheWrite: Type.Optional(Type.Number()),
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
}),
),
contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
});
const ProviderConfigSchema = Type.Object({
name: Type.Optional(Type.String({ minLength: 1 })),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
apiKey: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
oauth: Type.Optional(Type.Literal("radius")),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
authHeader: Type.Optional(Type.Boolean()),
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
});
const ModelsConfigSchema = Type.Object({
providers: Type.Record(Type.String(), ProviderConfigSchema),
});
const validateModelsConfig = Compile(ModelsConfigSchema);
export type ModelsJsonModel = Static<typeof ModelDefinitionSchema>;
export type ModelsJsonModelOverride = Static<typeof ModelOverrideSchema>;
export type ModelsJsonProvider = Static<typeof ProviderConfigSchema>;
type ModelsJson = Static<typeof ModelsConfigSchema>;
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
const requiredProperty = requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
for (const child of Object.values(value)) deepFreeze(child);
return Object.freeze(value);
}
/** One immutable load of models.json. */
export class ModelConfig {
private readonly providers: ReadonlyMap<string, ModelsJsonProvider>;
private readonly error: string | undefined;
private constructor(providers: ReadonlyMap<string, ModelsJsonProvider>, error?: string) {
this.providers = providers;
this.error = error;
}
static async load(modelsJsonPath: string | undefined): Promise<ModelConfig> {
if (!modelsJsonPath) return new ModelConfig(new Map());
const path = normalizePath(modelsJsonPath);
let content: string;
try {
content = await readFile(path, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new ModelConfig(new Map());
return new ModelConfig(
new Map(),
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${path}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(stripJsonComments(content));
} catch (error) {
return new ModelConfig(
new Map(),
`Failed to parse models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${path}`,
);
}
if (!validateModelsConfig.Check(parsed)) {
const errors =
validateModelsConfig
.Errors(parsed)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown schema error";
return new ModelConfig(new Map(), `Invalid models.json schema:\n${errors}\n\nFile: ${path}`);
}
const config = parsed as ModelsJson;
const providers = new Map<string, ModelsJsonProvider>();
for (const [providerId, provider] of Object.entries(config.providers)) {
providers.set(providerId, deepFreeze(structuredClone(provider)));
}
return new ModelConfig(providers);
}
getProvider(providerId: string): ModelsJsonProvider | undefined {
return this.providers.get(providerId);
}
getProviderIds(): readonly string[] {
return [...this.providers.keys()];
}
getError(): string | undefined {
return this.error;
}
}
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ModelRegistry } from "./model-registry.ts";
import type { ModelRuntime } from "./model-runtime.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
@@ -269,9 +269,9 @@ export interface ResolveModelScopeResult {
export async function resolveModelScopeWithDiagnostics(
patterns: string[],
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
): Promise<ResolveModelScopeResult> {
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = [];
@@ -331,8 +331,8 @@ export async function resolveModelScopeWithDiagnostics(
return { scopedModels, diagnostics };
}
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
export async function resolveModelScope(patterns: string[], modelRuntime: ModelRuntime): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRuntime);
for (const diagnostic of diagnostics) {
console.warn(chalk.yellow(`Warning: ${diagnostic.message}`));
}
@@ -365,9 +365,9 @@ export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
cliThinking?: ThinkingLevel;
modelRegistry: ModelRegistry;
modelRuntime: ModelRuntime;
}): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRegistry } = options;
const { cliProvider, cliModel, cliThinking, modelRuntime } = options;
if (!cliModel) {
return { model: undefined, warning: undefined, error: undefined };
@@ -375,7 +375,7 @@ export function resolveCliModel(options: {
// Important: use *all* models here, not just models with pre-configured auth.
// This allows "--api-key" to be used for first-time setup.
const availableModels = modelRegistry.getAll();
const availableModels = [...modelRuntime.getModels()];
if (availableModels.length === 0) {
return {
model: undefined,
@@ -455,8 +455,8 @@ export function resolveCliModel(options: {
const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
);
if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m));
if (rawExactMatches.length > 0 && !modelRuntime.hasConfiguredAuth(model.provider)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
if (authenticatedRawMatches.length === 1) {
return {
model: authenticatedRawMatches[0],
@@ -556,7 +556,7 @@ export async function findInitialModel(options: {
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: ThinkingLevel;
modelRegistry: ModelRegistry;
modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> {
const {
cliProvider,
@@ -566,7 +566,7 @@ export async function findInitialModel(options: {
defaultProvider,
defaultModelId,
defaultThinkingLevel,
modelRegistry,
modelRuntime,
} = options;
let model: Model<Api> | undefined;
@@ -577,7 +577,7 @@ export async function findInitialModel(options: {
const resolved = resolveCliModel({
cliProvider,
cliModel,
modelRegistry,
modelRuntime,
});
if (resolved.error) {
console.error(chalk.red(resolved.error));
@@ -599,8 +599,8 @@ export async function findInitialModel(options: {
// 3. Try saved default from settings if auth is configured.
if (defaultProvider && defaultModelId) {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found && modelRegistry.hasConfiguredAuth(found)) {
const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
@@ -610,7 +610,7 @@ export async function findInitialModel(options: {
}
// 4. Try first available model with valid API key
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -638,12 +638,12 @@ export async function restoreModelFromSession(
savedModelId: string,
currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
const restoredModel = modelRuntime.getModel(savedProvider, savedModelId);
// Check if restored model exists and still has auth configured
const hasConfiguredAuth = restoredModel ? modelRegistry.hasConfiguredAuth(restoredModel) : false;
const hasConfiguredAuth = restoredModel ? modelRuntime.hasConfiguredAuth(restoredModel.provider) : false;
if (restoredModel && hasConfiguredAuth) {
if (shouldPrintMessages) {
@@ -671,7 +671,7 @@ export async function restoreModelFromSession(
}
// Try to find any available model
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -0,0 +1,562 @@
import { dirname, join } from "node:path";
import {
type Api,
type ApiStreamOptions,
type AssistantMessage,
type AssistantMessageEventStream,
type AuthCheck,
type AuthInteraction,
type AuthResult,
type AuthType,
type Context,
type Credential,
type CredentialInfo,
type CredentialStore,
createModels,
lazyStream,
type Model,
type Models,
type ModelsApiStreamOptions,
ModelsError,
type ModelsRefreshOptions,
type ModelsRefreshResult,
type ModelsSimpleStreamOptions,
type ModelsStore,
type ModelsStreamTransforms,
type MutableModels,
type Provider,
type ProviderHeaders,
type SimpleStreamOptions,
type StreamOptions,
} from "@earendil-works/pi-ai";
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
import { getAgentDir } from "../config.ts";
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
import { ModelConfig } from "./model-config.ts";
import { FileModelsStore, InMemoryCodingAgentModelsStore } from "./models-store.ts";
import {
type AuthStatus,
type CompatibilityRequestConfig,
composeModelProvider,
configuredRequestAuthStatus,
type ProviderConfigInput,
resolveCompatibilityRequestConfig,
resolveConfiguredModelHeaders,
validateExtensionProvider,
} from "./provider-composer.ts";
import { withRemoteCatalog } from "./remote-catalog-provider.ts";
import { RuntimeCredentials } from "./runtime-credentials.ts";
interface ModelRuntimeSnapshot {
all: readonly Model<Api>[];
available: readonly Model<Api>[];
configuredProviders: ReadonlySet<string>;
storedProviders: ReadonlySet<string>;
auth: ReadonlyMap<string, AuthCheck | undefined>;
}
export interface CreateModelRuntimeOptions {
/** Credential storage. Defaults to the file at authPath. */
credentials?: CredentialStore;
authPath?: string;
modelsPath?: string | null;
modelsStore?: ModelsStore;
modelsStorePath?: string;
allowModelNetwork?: boolean;
modelRefreshTimeoutMs?: number;
catalogBaseUrl?: string;
}
export interface ModelRuntimeAuthOverrides {
apiKey?: string;
env?: Record<string, string>;
}
function mergeHeaders(
base: ProviderHeaders | undefined,
override: ProviderHeaders | undefined,
): ProviderHeaders | undefined {
if (!base && !override) return undefined;
const merged = { ...base };
for (const [name, value] of Object.entries(override ?? {})) {
const lowerName = name.toLowerCase();
for (const existingName of Object.keys(merged)) {
if (existingName.toLowerCase() === lowerName) delete merged[existingName];
}
merged[name] = value;
}
return merged;
}
/** Configured pi-ai Models collection used by coding-agent and SDK consumers. */
export class ModelRuntime implements Models {
private readonly models: MutableModels;
private readonly credentials: RuntimeCredentials;
private readonly defaultBuiltins: ReadonlyMap<string, Provider>;
private readonly builtins = new Map<string, Provider>();
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
private readonly compositionErrors = new Map<string, string>();
private readonly modelsPath: string | undefined;
private readonly allowModelNetwork: boolean;
private config: ModelConfig;
private snapshot: ModelRuntimeSnapshot = {
all: [],
available: [],
configuredProviders: new Set(),
storedProviders: new Set(),
auth: new Map(),
};
private availabilityRefresh: Promise<void> | undefined;
private availabilityError: string | undefined;
private constructor(
credentials: RuntimeCredentials,
config: ModelConfig,
modelsPath: string | undefined,
modelsStore: ModelsStore,
providers: readonly Provider[],
allowModelNetwork: boolean,
) {
this.credentials = credentials;
this.config = config;
this.modelsPath = modelsPath;
this.allowModelNetwork = allowModelNetwork;
this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
this.models = createModels({ credentials, modelsStore });
this.rebuildProviders();
}
static async create(options: CreateModelRuntimeOptions = {}): Promise<ModelRuntime> {
const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));
const modelsPath =
options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
const config = await ModelConfig.load(modelsPath);
const modelsStore =
options.modelsStore ??
(modelsPath
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
: new InMemoryCodingAgentModelsStore());
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
);
const runtime = new ModelRuntime(
credentials,
config,
modelsPath,
modelsStore,
providers,
options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined,
);
runtime.configureRadiusProviders();
runtime.rebuildProviders();
const controller = new AbortController();
const timeout = runtime.allowModelNetwork
? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000)
: undefined;
try {
await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal });
} finally {
if (timeout) clearTimeout(timeout);
}
return runtime;
}
private configureRadiusProviders(): void {
this.builtins.clear();
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
for (const providerId of this.config.getProviderIds()) {
const config = this.config.getProvider(providerId);
if (config?.oauth !== "radius" || !config.baseUrl) continue;
this.builtins.set(
providerId,
builtinProviderCatalog.radiusProvider({
id: providerId,
name: config.name ?? providerId,
gateway: config.baseUrl.replace(/\/v1\/?$/u, ""),
}),
);
}
}
private providerIds(): Set<string> {
return new Set([...this.builtins.keys(), ...this.config.getProviderIds(), ...this.extensionProviders.keys()]);
}
private recomposeProvider(providerId: string): void {
const base = this.builtins.get(providerId);
const extension = this.extensionProviders.get(providerId);
if (!base && !this.config.getProvider(providerId) && !extension) {
this.models.deleteProvider(providerId);
this.compositionErrors.delete(providerId);
return;
}
if (base && !this.config.getProvider(providerId) && !extension) {
// No overlays: use the builtin untouched so its auth/login/stream behavior is exact.
this.models.setProvider(base);
this.compositionErrors.delete(providerId);
return;
}
try {
this.models.setProvider(composeModelProvider(providerId, base, this.config, extension));
this.compositionErrors.delete(providerId);
} catch (error) {
this.compositionErrors.set(providerId, error instanceof Error ? error.message : String(error));
if (base) this.models.setProvider(base);
else this.models.deleteProvider(providerId);
}
}
private rebuildProviders(): void {
this.models.clearProviders();
this.compositionErrors.clear();
for (const providerId of this.providerIds()) this.recomposeProvider(providerId);
this.updateModelSnapshot();
}
private updateModelSnapshot(): void {
const all = [...this.models.getModels()];
this.snapshot = {
...this.snapshot,
all,
available: all.filter((model) => this.snapshot.configuredProviders.has(model.provider)),
};
}
private async runAvailabilityRefresh(): Promise<void> {
const providers = this.models.getProviders();
const [available, checks, credentials] = await Promise.all([
this.models.getAvailable(),
Promise.all(
providers.map(
async (provider): Promise<[string, AuthCheck | undefined]> => [
provider.id,
await this.models.checkAuth(provider.id),
],
),
),
this.credentials.list(),
]);
const auth = new Map(checks);
const configuredProviders = new Set(
checks
.filter((entry): entry is [string, AuthCheck] => entry[1] !== undefined)
.map(([providerId]) => providerId),
);
this.snapshot = {
all: [...this.models.getModels()],
available: [...available],
configuredProviders,
storedProviders: new Set(credentials.map((entry) => entry.providerId)),
auth,
};
this.availabilityError = undefined;
}
private queueAvailabilityRefresh(after: Promise<void> | undefined): Promise<void> {
const refresh = (after ?? Promise.resolve()).catch(() => {}).then(() => this.runAvailabilityRefresh());
const recorded = refresh.catch((error) => {
this.availabilityError = error instanceof Error ? error.message : String(error);
throw error;
});
const tracked = recorded.finally(() => {
if (this.availabilityRefresh === tracked) this.availabilityRefresh = undefined;
});
this.availabilityRefresh = tracked;
return tracked;
}
/** Coalesce concurrent readers onto the pending refresh. */
private refreshAvailability(): Promise<void> {
return this.availabilityRefresh ?? this.queueAvailabilityRefresh(undefined);
}
/** Mutations must not observe an in-flight refresh started before them. */
private forceRefreshAvailability(): Promise<void> {
return this.queueAvailabilityRefresh(this.availabilityRefresh);
}
getProviders(): readonly Provider[] {
return this.models.getProviders();
}
getProvider(providerId: string): Provider | undefined {
return this.models.getProvider(providerId);
}
getModels(providerId?: string): readonly Model<Api>[] {
return this.models.getModels(providerId);
}
getModel(providerId: string, modelId: string): Model<Api> | undefined {
return this.models.getModel(providerId, modelId);
}
async checkAuth(providerId: string): Promise<AuthCheck | undefined> {
return this.models.checkAuth(providerId);
}
async getAvailable(providerId?: string): Promise<readonly Model<Api>[]> {
if (providerId) {
if (this.availabilityRefresh) {
await this.availabilityRefresh;
return this.snapshot.available.filter((model) => model.provider === providerId);
}
try {
return await this.models.getAvailable(providerId);
} catch (error) {
this.availabilityError = error instanceof Error ? error.message : String(error);
throw error;
}
}
await this.refreshAvailability();
return this.snapshot.available;
}
getAvailableSnapshot(): readonly Model<Api>[] {
return this.snapshot.available;
}
getError(): string | undefined {
const errors: string[] = [];
const configError = this.config.getError();
if (configError) errors.push(configError);
for (const [providerId, error] of this.compositionErrors) {
errors.push(`Provider "${providerId}": ${error}`);
}
if (this.availabilityError) errors.push(`Availability refresh: ${this.availabilityError}`);
return errors.length > 0 ? errors.join("\n\n") : undefined;
}
getRegisteredProviderConfig(providerId: string): ProviderConfigInput | undefined {
return this.extensionProviders.get(providerId);
}
getRegisteredProviderIds(): readonly string[] {
return [...this.extensionProviders.keys()];
}
/** @internal Compatibility fallback for ModelRegistry when provider auth is unconfigured. */
getCompatibilityRequestConfig(model: Model<Api>): CompatibilityRequestConfig {
return resolveCompatibilityRequestConfig(
model,
this.config.getProvider(model.provider),
this.extensionProviders.get(model.provider),
);
}
isUsingOAuth(providerId: string): boolean {
return this.snapshot.auth.get(providerId)?.type === "oauth";
}
hasConfiguredAuth(providerId: string): boolean {
return this.snapshot.configuredProviders.has(providerId);
}
getAuth(providerId: string, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
overrides: ModelRuntimeAuthOverrides = {},
): Promise<AuthResult | undefined> {
if (typeof providerOrModel === "string") return this.models.getAuth(providerOrModel, overrides);
const resolution = await this.models.getAuth(providerOrModel, overrides);
if (!resolution) return undefined;
const configuredHeaders = resolveConfiguredModelHeaders(
providerOrModel,
this.config.getProvider(providerOrModel.provider),
this.extensionProviders.get(providerOrModel.provider),
{ ...(resolution.env ?? {}), ...(overrides.env ?? {}) },
);
return {
...resolution,
auth: {
...resolution.auth,
headers: mergeHeaders(resolution.auth.headers, configuredHeaders),
},
};
}
async setRuntimeApiKey(providerId: string, apiKey: string): Promise<void> {
this.credentials.setRuntimeApiKey(providerId, apiKey);
const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" });
const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
const storedProviders = new Set(this.snapshot.storedProviders).add(providerId);
this.snapshot = {
...this.snapshot,
auth,
configuredProviders,
storedProviders,
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
};
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
async removeRuntimeApiKey(providerId: string): Promise<void> {
this.credentials.removeRuntimeApiKey(providerId);
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
listCredentials(): Promise<readonly CredentialInfo[]> {
return this.credentials.list();
}
getProviderAuthStatus(providerId: string): AuthStatus {
if (this.credentials.hasRuntimeApiKey(providerId)) return { configured: true, source: "runtime" };
if (this.snapshot.storedProviders.has(providerId)) return { configured: true, source: "stored" };
const configured = configuredRequestAuthStatus(
this.config.getProvider(providerId),
this.extensionProviders.get(providerId),
);
if (configured) return configured;
const check = this.snapshot.auth.get(providerId);
return check ? { configured: true, source: "environment", label: check.source } : { configured: false };
}
private async prepareRequest(
model: Model<Api>,
options: (StreamOptions & ModelsStreamTransforms) | undefined,
): Promise<{ provider: Provider; model: Model<Api>; options: StreamOptions }> {
const provider = this.models.getProvider(model.provider);
if (!provider) throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
const resolution = await this.getAuth(model, { apiKey: options?.apiKey, env: options?.env });
if (!resolution) throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
const { transformHeaders, ...providerOptions } = options ?? {};
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
if (transformHeaders) headers = await transformHeaders(headers ?? {});
const env =
resolution.env || providerOptions.env
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
: undefined;
return {
provider,
model: resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model,
options: {
...providerOptions,
apiKey: providerOptions.apiKey ?? resolution.auth.apiKey,
headers,
env,
},
};
}
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
const prepared = await this.prepareRequest(
model,
options as (StreamOptions & ModelsStreamTransforms) | undefined,
);
return prepared.provider.stream(
prepared.model as Model<TApi>,
context,
prepared.options as ApiStreamOptions<TApi>,
);
});
}
complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage> {
return this.stream(model, context, options).result();
}
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
return lazyStream(model, async () => {
const prepared = await this.prepareRequest(model, options);
return prepared.provider.streamSimple(prepared.model, context, prepared.options as SimpleStreamOptions);
});
}
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage> {
return this.streamSimple(model, context, options).result();
}
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
const credential = await this.models.login(providerId, type, interaction);
await this.refresh({ allowNetwork: this.allowModelNetwork });
return credential;
}
async logout(providerId: string): Promise<void> {
await this.models.logout(providerId);
// Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh.
this.recomposeProvider(providerId);
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
async reloadConfig(): Promise<void> {
this.config = await ModelConfig.load(this.modelsPath);
this.configureRadiusProviders();
this.rebuildProviders();
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
// Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
// The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
const result = ((await this.models.refresh(options)) as ModelsRefreshResult | undefined) ?? {
aborted: options.signal?.aborted ?? false,
errors: new Map(),
};
this.updateModelSnapshot();
try {
await this.forceRefreshAvailability();
} catch {
// Availability errors are recorded by forceRefreshAvailability; refreshed models remain usable.
}
return result;
}
registerProvider(providerId: string, config: ProviderConfigInput): void {
// Validate the incoming registration on its own, like the legacy registry:
// a broken re-registration must throw without touching the stored config.
validateExtensionProvider(providerId, this.builtins.get(providerId), this.config.getProvider(providerId), config);
// Re-registration merges defined values over the previous registration and
// preserves undefined ones, matching the legacy ModelRegistry contract.
const previous = this.extensionProviders.get(providerId);
const effective: ProviderConfigInput = { ...previous };
for (const [key, value] of Object.entries(config)) {
if (value !== undefined) (effective as Record<string, unknown>)[key] = value;
}
this.extensionProviders.set(providerId, effective);
this.recomposeProvider(providerId);
this.updateModelSnapshot();
if (
this.snapshot.storedProviders.has(providerId) ||
configuredRequestAuthStatus(this.config.getProvider(providerId), effective)?.configured
) {
const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
const auth = new Map(this.snapshot.auth);
// Provisional entry until the async refresh lands; never clobber a real check result.
if (!auth.get(providerId)) {
auth.set(providerId, {
type: effective.oauth && !effective.apiKey ? "oauth" : "api_key",
source: "configured provider",
});
}
this.snapshot = {
...this.snapshot,
auth,
configuredProviders,
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
};
}
void this.refresh({ allowNetwork: false });
}
unregisterProvider(providerId: string): void {
this.extensionProviders.delete(providerId);
this.recomposeProvider(providerId);
this.updateModelSnapshot();
void this.refresh({ allowNetwork: false });
}
}
@@ -0,0 +1,57 @@
import { join } from "node:path";
import type { Api, Model, ModelsStore } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
type StoredModels = Record<string, Model<Api>[]>;
export class InMemoryCodingAgentModelsStore implements ModelsStore {
private readonly models = new Map<string, readonly Model<Api>[]>();
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
return this.models.get(providerId);
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
this.models.set(providerId, models);
}
async delete(providerId: string): Promise<void> {
this.models.delete(providerId);
}
}
/** Locked JSON-backed storage for dynamically refreshed provider catalogs. */
export class FileModelsStore implements ModelsStore {
private readonly storage: AuthStorageBackend;
constructor(path: string = join(getAgentDir(), "models-store.json")) {
this.storage = new FileAuthStorageBackend(path);
}
private parse(content: string | undefined): StoredModels {
return content ? (JSON.parse(content) as StoredModels) : {};
}
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
return this.storage.withLock((content) => ({
result: this.parse(content)[providerId]?.map((model) => structuredClone(model)),
}));
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const current = this.parse(content);
current[providerId] = models.map((model) => structuredClone(model));
return { result: undefined, next: JSON.stringify(current, null, 2) };
});
}
async delete(providerId: string): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const current = this.parse(content);
delete current[providerId];
return { result: undefined, next: JSON.stringify(current, null, 2) };
});
}
}
@@ -0,0 +1,528 @@
import {
type Api,
type ApiKeyAuth,
type AssistantMessageEventStream,
type AuthContext,
type AuthInteraction,
type AuthResult,
type Context,
type Credential,
lazyStream,
type Model,
type ModelAuth,
type OAuthAuth,
type OAuthCredentials,
type OAuthLoginCallbacks,
type Provider,
type ProviderHeaders,
type SimpleStreamOptions,
type StreamOptions,
} from "@earendil-works/pi-ai";
import { getApiProvider } from "@earendil-works/pi-ai/compat";
import type { ModelConfig, ModelsJsonModel, ModelsJsonModelOverride, ModelsJsonProvider } from "./model-config.ts";
import {
clearConfigValueCache,
getConfigValueEnvVarNames,
isCommandConfigValue,
isConfigValueConfigured,
resolveConfigValueOrThrow,
resolveHeadersOrThrow,
} from "./resolve-config-value.ts";
export interface ExtensionOAuthConfig {
name: string;
/** @deprecated Retained for extension source compatibility; ignored by canonical auth flows. */
usesCallbackServer?: boolean;
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey(credentials: OAuthCredentials): string;
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
}
/** Input type for the extension registerProvider API. */
export interface ProviderConfigInput {
name?: string;
baseUrl?: string;
apiKey?: string;
api?: Api;
streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
headers?: Record<string, string>;
authHeader?: boolean;
oauth?: ExtensionOAuthConfig;
models?: Array<{
id: string;
name: string;
api?: Api;
baseUrl?: string;
reasoning: boolean;
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
input: ("text" | "image")[];
cost: Model<Api>["cost"];
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
compat?: Model<Api>["compat"];
}>;
}
export type AuthStatus = {
configured: boolean;
source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command";
label?: string;
};
export const clearApiKeyCache = clearConfigValueCache;
function mergeCompat(
base: Model<Api>["compat"],
override: Model<Api>["compat"] | ModelsJsonModelOverride["compat"],
): Model<Api>["compat"] {
if (!override) return base;
const merged = { ...base, ...override } as NonNullable<Model<Api>["compat"]>;
const baseNested = base as Record<string, unknown> | undefined;
const overrideNested = override as Record<string, unknown>;
const mergedNested = merged as Record<string, unknown>;
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"] as const) {
const baseValue = baseNested?.[key];
const overrideValue = overrideNested[key];
if (
(typeof baseValue === "object" && baseValue !== null) ||
(typeof overrideValue === "object" && overrideValue !== null)
) {
mergedNested[key] = { ...(baseValue as object | undefined), ...(overrideValue as object | undefined) };
}
}
return merged;
}
function applyModelOverride(model: Model<Api>, override: ModelsJsonModelOverride): Model<Api> {
return {
...model,
name: override.name ?? model.name,
reasoning: override.reasoning ?? model.reasoning,
thinkingLevelMap: override.thinkingLevelMap
? { ...model.thinkingLevelMap, ...override.thinkingLevelMap }
: model.thinkingLevelMap,
input: (override.input as ("text" | "image")[] | undefined) ?? model.input,
cost: override.cost
? {
input: override.cost.input ?? model.cost.input,
output: override.cost.output ?? model.cost.output,
cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
tiers: override.cost.tiers ?? model.cost.tiers,
}
: model.cost,
contextWindow: override.contextWindow ?? model.contextWindow,
maxTokens: override.maxTokens ?? model.maxTokens,
compat: mergeCompat(model.compat, override.compat),
};
}
function modelFromJson(
providerId: string,
definition: ModelsJsonModel,
providerConfig: ModelsJsonProvider,
defaults: Model<Api> | undefined,
): Model<Api> {
const api = definition.api ?? providerConfig.api ?? defaults?.api;
if (!api) {
throw new Error(
`Provider ${providerId}, model ${definition.id}: no "api" specified. Set at provider or model level.`,
);
}
const baseUrl = definition.baseUrl ?? providerConfig.baseUrl ?? defaults?.baseUrl;
if (!baseUrl) throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
if (definition.contextWindow !== undefined && definition.contextWindow <= 0) {
throw new Error(`Provider ${providerId}, model ${definition.id}: invalid contextWindow`);
}
if (definition.maxTokens !== undefined && definition.maxTokens <= 0) {
throw new Error(`Provider ${providerId}, model ${definition.id}: invalid maxTokens`);
}
return {
id: definition.id,
name: definition.name ?? definition.id,
api: api as Api,
provider: providerId,
baseUrl,
reasoning: definition.reasoning ?? false,
thinkingLevelMap: definition.thinkingLevelMap,
input: (definition.input ?? ["text"]) as ("text" | "image")[],
cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: definition.contextWindow ?? 128000,
maxTokens: definition.maxTokens ?? 16384,
headers: undefined,
compat: mergeCompat(providerConfig.compat, definition.compat),
};
}
function applyModelsJson(
providerId: string,
baseModels: readonly Model<Api>[],
config: ModelsJsonProvider | undefined,
): Model<Api>[] {
if (!config) return [...baseModels];
if (config.oauth && !config.baseUrl) {
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
}
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
if (
!config.models?.length &&
!config.baseUrl &&
!config.headers &&
!config.compat &&
!hasOverrides &&
!config.apiKey &&
!config.oauth &&
config.authHeader === undefined
) {
throw new Error(
`Provider ${providerId}: must specify "baseUrl", "headers", "compat", "modelOverrides", or "models".`,
);
}
const models: Model<Api>[] = baseModels.map((model) => ({
...model,
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
compat: mergeCompat(model.compat, config.compat),
}));
for (const definition of config.models ?? []) {
const existingIndex = models.findIndex((model) => model.id === definition.id);
const defaults = existingIndex >= 0 ? models[existingIndex] : models[0];
const model = modelFromJson(providerId, definition, config, defaults);
if (existingIndex >= 0) models[existingIndex] = model;
else models.push(model);
}
return models;
}
function applyExtension(
providerId: string,
models: readonly Model<Api>[],
config: ProviderConfigInput | undefined,
): Model<Api>[] {
if (!config) return [...models];
if (!config.models) {
return config.baseUrl ? models.map((model) => ({ ...model, baseUrl: config.baseUrl! })) : [...models];
}
return config.models.map((definition) => {
const defaults = models.find((model) => model.id === definition.id) ?? models[0];
const api = definition.api ?? config.api ?? defaults?.api;
if (!api) {
throw new Error(
`Provider ${providerId}, model ${definition.id}: no "api" specified. Set at provider or model level.`,
);
}
const baseUrl = definition.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
if (!baseUrl) throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
return {
...definition,
api,
provider: providerId,
baseUrl,
headers: undefined,
};
});
}
function adaptOAuth(config: ExtensionOAuthConfig): OAuthAuth {
return {
name: config.name,
login: async (callbacks) => {
const credential = await config.login({
onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }),
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
onPrompt: (prompt) => callbacks.prompt({ type: "text", ...prompt }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
onManualCodeInput: () => callbacks.prompt({ type: "manual_code", message: "Paste the authorization code" }),
onSelect: (prompt) => callbacks.prompt({ type: "select", ...prompt }),
signal: callbacks.signal,
});
return { ...credential, type: "oauth" };
},
refresh: async (credential) => ({ ...(await config.refreshToken(credential)), type: "oauth" }),
toAuth: async (credential) => ({ apiKey: config.getApiKey(credential) }),
};
}
function withConfiguredAuth(
auth: ModelAuth,
headers: Record<string, string> | undefined,
authHeader: boolean,
): ModelAuth {
let mergedHeaders: ProviderHeaders | undefined =
auth.headers || headers ? { ...auth.headers, ...headers } : undefined;
if (authHeader) {
if (!auth.apiKey) throw new Error("authHeader requires a resolved API key");
mergedHeaders = { ...mergedHeaders, Authorization: `Bearer ${auth.apiKey}` };
}
return { ...auth, headers: mergedHeaders };
}
function configuredApiKey(
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): string | undefined {
return extension?.apiKey ?? config?.apiKey;
}
function configuredHeaders(
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): Record<string, string> | undefined {
if (!config?.headers && !extension?.headers) return undefined;
return { ...config?.headers, ...extension?.headers };
}
async function configContextEnv(
values: readonly string[],
ctx: AuthContext,
explicit?: Record<string, string>,
): Promise<Record<string, string> | undefined> {
const env = { ...explicit };
for (const name of new Set(values.flatMap(getConfigValueEnvVarNames))) {
if (env[name] !== undefined) continue;
const value = await ctx.env(name);
if (value !== undefined) env[name] = value;
}
return Object.keys(env).length > 0 ? env : undefined;
}
function composeApiKeyAuth(
providerId: string,
base: Provider | undefined,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): ApiKeyAuth | undefined {
const inherited = base?.auth.apiKey;
const rawKey = configuredApiKey(config, extension);
const oauth = extension?.oauth ?? base?.auth.oauth;
// OAuth-only providers get no fabricated API-key login method.
if (!inherited && rawKey === undefined && oauth) return undefined;
const rawHeaders = configuredHeaders(config, extension);
const authHeader = extension?.authHeader ?? config?.authHeader ?? false;
return {
name: inherited?.name ?? "API key",
login:
inherited?.login ??
(async (interaction: AuthInteraction) => ({
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter API key" }),
})),
check: async (input) => {
if (input.credential) {
if (inherited?.check) return inherited.check(input);
if (input.credential.key) return { type: "api_key", source: "stored credential" };
const resolved = await inherited?.resolve(input);
return resolved ? { type: "api_key", source: resolved.source } : undefined;
}
if (rawKey !== undefined) {
if (isCommandConfigValue(rawKey)) return { type: "api_key", source: "configured API key" };
const envNames = getConfigValueEnvVarNames(rawKey);
for (const name of envNames) {
if ((await input.ctx.env(name)) === undefined) return undefined;
}
return { type: "api_key", source: "configured API key" };
}
if (inherited?.check) return inherited.check(input);
const resolved = await inherited?.resolve(input);
return resolved ? { type: "api_key", source: resolved.source } : undefined;
},
resolve: async (input) => {
let result: AuthResult | undefined;
if (input.credential) {
result = inherited
? await inherited.resolve(input)
: input.credential.key
? { auth: { apiKey: input.credential.key }, env: input.credential.env, source: "stored credential" }
: undefined;
} else if (rawKey !== undefined) {
const env = await configContextEnv([rawKey], input.ctx);
const key = resolveConfigValueOrThrow(rawKey, `API key for provider "${providerId}"`, env);
result = inherited
? await inherited.resolve({ ...input, credential: { type: "api_key", key } })
: { auth: { apiKey: key }, source: "configured API key" };
} else {
result = await inherited?.resolve(input);
}
if (!result) return undefined;
const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) };
const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv);
const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv);
return { ...result, auth: withConfiguredAuth(result.auth, headers, authHeader) };
},
};
}
function composeOAuthAuth(
providerId: string,
base: Provider | undefined,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): OAuthAuth | undefined {
const oauth = extension?.oauth ? adaptOAuth(extension.oauth) : base?.auth.oauth;
if (!oauth) return undefined;
const rawHeaders = configuredHeaders(config, extension);
const authHeader = extension?.authHeader ?? config?.authHeader ?? false;
return {
...oauth,
toAuth: async (credential) => {
const auth = await oauth.toAuth(credential);
const env = credential.env;
const headers = resolveHeadersOrThrow(
rawHeaders,
`provider "${providerId}"`,
typeof env === "object" && env !== null ? (env as Record<string, string>) : undefined,
);
return withConfiguredAuth(auth, headers, authHeader);
},
};
}
function rawModelHeaders(
model: Model<Api>,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): Record<string, string> | undefined {
const definition = config?.models?.find((entry) => entry.id === model.id);
const extensionModel = extension?.models?.find((entry) => entry.id === model.id);
const headers = {
...config?.modelOverrides?.[model.id]?.headers,
...definition?.headers,
...extensionModel?.headers,
};
return Object.keys(headers).length > 0 ? headers : undefined;
}
export function validateExtensionProvider(
providerId: string,
base: Provider | undefined,
modelsConfig: ModelsJsonProvider | undefined,
extension: ProviderConfigInput,
): void {
if (extension.streamSimple && !extension.api) {
throw new Error(`Provider ${providerId}: "api" is required when registering streamSimple.`);
}
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], modelsConfig), extension);
}
/** Compose built-in, models.json, and extension layers without reading credentials. */
export function composeModelProvider(
providerId: string,
base: Provider | undefined,
modelConfig: ModelConfig,
extension: ProviderConfigInput | undefined,
): Provider {
const config = modelConfig.getProvider(providerId);
let extensionOAuthCredential: OAuthCredentials | undefined;
// models.json modelOverrides are the topmost user-config layer: they apply once,
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
const getModels = () => {
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension);
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
}
return models.map((model) => {
const override = config?.modelOverrides?.[model.id];
return override ? applyModelOverride(model, override) : model;
});
};
// Validate eagerly so registration/reload reports structural errors immediately.
getModels();
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
const oauth = composeOAuthAuth(providerId, base, config, extension);
if (!apiKey && !oauth) throw new Error(`Provider ${providerId}: no authentication method configured.`);
const supportsBaseApi = (model: Model<Api>) => base?.getModels().some((entry) => entry.api === model.api) ?? false;
const streamWith = (
model: Model<Api>,
context: Context,
options: StreamOptions | undefined,
simple: boolean,
): AssistantMessageEventStream =>
lazyStream(model, async () => {
if (extension?.streamSimple && model.api === extension.api) {
return extension.streamSimple(model, context, options as SimpleStreamOptions);
}
if (base && supportsBaseApi(model)) {
return simple
? base.streamSimple(model, context, options as SimpleStreamOptions)
: base.stream(model, context, options);
}
const api = getApiProvider(model.api);
if (!api) throw new Error(`No API provider registered for api: ${model.api}`);
return simple
? api.streamSimple(model, context, options as SimpleStreamOptions)
: api.stream(model, context, options);
});
return {
id: providerId,
name: extension?.name ?? config?.name ?? base?.name ?? extension?.oauth?.name ?? providerId,
baseUrl: extension?.baseUrl ?? config?.baseUrl ?? base?.baseUrl,
headers: base?.headers,
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
getModels,
refreshModels:
base?.refreshModels || extension?.oauth?.modifyModels
? async (context) => {
await base?.refreshModels?.(context);
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
}
: undefined,
filterModels: base?.filterModels
? (models, credential: Credential | undefined) => base.filterModels!(models, credential)
: undefined,
stream: (model, context, options) => streamWith(model, context, options, false),
streamSimple: (model, context, options) => streamWith(model, context, options, true),
};
}
export function resolveConfiguredModelHeaders(
model: Model<Api>,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
env?: Record<string, string>,
): Record<string, string> | undefined {
return resolveHeadersOrThrow(
rawModelHeaders(model, config, extension),
`model "${model.provider}/${model.id}"`,
env,
);
}
export interface CompatibilityRequestConfig {
headers?: ProviderHeaders;
authHeader: boolean;
}
export function resolveCompatibilityRequestConfig(
model: Model<Api>,
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): CompatibilityRequestConfig {
const configured = resolveHeadersOrThrow(
{ ...configuredHeaders(config, extension), ...rawModelHeaders(model, config, extension) },
`model "${model.provider}/${model.id}"`,
);
return {
headers: model.headers || configured ? { ...model.headers, ...configured } : undefined,
authHeader: extension?.authHeader ?? config?.authHeader ?? false,
};
}
export function configuredRequestAuthStatus(
config: ModelsJsonProvider | undefined,
extension: ProviderConfigInput | undefined,
): AuthStatus | undefined {
const value = configuredApiKey(config, extension);
if (value === undefined) return undefined;
if (isCommandConfigValue(value)) return { configured: true, source: "models_json_command" };
const names = getConfigValueEnvVarNames(value);
if (names.length > 0) {
return isConfigValueConfigured(value)
? { configured: true, source: "environment", label: names.join(", ") }
: { configured: false };
}
return { configured: true, source: extension?.apiKey !== undefined ? "fallback" : "models_json_key" };
}
@@ -1,36 +0,0 @@
export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
"ant-ling": "Ant Ling",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
"cloudflare-ai-gateway": "Cloudflare AI Gateway",
"cloudflare-workers-ai": "Cloudflare Workers AI",
deepseek: "DeepSeek",
fireworks: "Fireworks",
google: "Google Gemini",
"google-vertex": "Google Vertex AI",
groq: "Groq",
huggingface: "Hugging Face",
"kimi-coding": "Kimi For Coding",
mistral: "Mistral",
minimax: "MiniMax",
"minimax-cn": "MiniMax (China)",
moonshotai: "Moonshot AI",
"moonshotai-cn": "Moonshot AI (China)",
nvidia: "NVIDIA NIM",
opencode: "OpenCode Zen",
"opencode-go": "OpenCode Go",
openai: "OpenAI",
openrouter: "OpenRouter",
radius: "Radius",
together: "Together AI",
"vercel-ai-gateway": "Vercel AI Gateway",
xai: "xAI",
zai: "ZAI Coding Plan (Global)",
"zai-coding-cn": "ZAI Coding Plan (China)",
xiaomi: "Xiaomi MiMo",
"xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)",
"xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)",
"xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)",
};
-33
View File
@@ -1,34 +1 @@
/**
* Radius (pi-messages gateway) provider wiring.
*
* The main Radius provider is a built-in OAuth provider in pi-ai; models are
* dynamic, cached on the stored OAuth credential (`gatewayConfig`) and
* injected via the OAuth provider's `modifyModels` hook, so startup, /reload,
* and registry refreshes work without network access. The catalog refreshes
* on login and on every token refresh.
*
* Additional gateways (e.g. a local dev gateway) can be declared in
* models.json with `"oauth": "radius"`; each entry is an independent Radius
* instance with its own credentials and catalog.
*/
import { createRadiusOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
export const RADIUS_PROVIDER_ID = "radius";
/**
* Register a Radius-style OAuth provider for a custom gateway declared in
* models.json (`"oauth": "radius"`). Runs on every models.json load so the
* registration survives `resetOAuthProviders()` during registry refreshes.
*/
export function registerCustomRadiusOAuthProvider(id: string, name: string | undefined, gateway: string): void {
registerOAuthProvider(
createRadiusOAuthProvider({
id,
name: name ?? id,
// Tolerate an API base URL: the gateway root is what the OAuth and
// config discovery endpoints hang off.
gateway: gateway.replace(/\/v1\/?$/u, ""),
}),
);
}
@@ -0,0 +1,61 @@
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
const merged = [...baseline];
for (const model of dynamic) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
}
function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
? value.models
: undefined;
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry): entry is Model<Api> => typeof entry === "object" && entry !== null && "id" in entry)
.map((model) => ({ ...model, provider: providerId }));
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
return {
...provider,
getModels: () => mergeModels(provider.getModels(), dynamicModels),
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const stored = await context.store.read();
if (stored) dynamicModels = stored.filter((model) => model.provider === provider.id);
if (!context.allowNetwork || context.signal?.aborted) return;
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: context.signal,
});
if (!response.ok) {
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const refreshed = parseCatalog(provider.id, await response.json());
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write(refreshed);
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
},
};
}
@@ -0,0 +1,48 @@
import type { Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
/** Async credential store overlay for non-persistent runtime API keys. */
export class RuntimeCredentials implements CredentialStore {
private readonly store: CredentialStore;
private readonly overrides = new Map<string, string>();
constructor(store: CredentialStore) {
this.store = store;
}
setRuntimeApiKey(providerId: string, apiKey: string): void {
this.overrides.set(providerId, apiKey);
}
removeRuntimeApiKey(providerId: string): void {
this.overrides.delete(providerId);
}
hasRuntimeApiKey(providerId: string): boolean {
return this.overrides.has(providerId);
}
async read(providerId: string): Promise<Credential | undefined> {
const override = this.overrides.get(providerId);
return override ? { type: "api_key", key: override } : this.store.read(providerId);
}
async list(): Promise<readonly CredentialInfo[]> {
const entries = new Map((await this.store.list()).map((entry) => [entry.providerId, entry]));
for (const providerId of this.overrides.keys()) {
entries.set(providerId, { providerId, type: "api_key" });
}
return [...entries.values()];
}
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.store.modify(providerId, fn);
}
async delete(providerId: string): Promise<void> {
this.overrides.delete(providerId);
await this.store.delete(providerId);
}
}
+21 -35
View File
@@ -1,16 +1,15 @@
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
@@ -37,10 +36,8 @@ export interface CreateAgentSessionOptions {
/** Global config directory. Default: ~/.pi/agent */
agentDir?: string;
/** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */
authStorage?: AuthStorage;
/** Model registry. Default: ModelRegistry.create(authStorage, agentDir/models.json) */
modelRegistry?: ModelRegistry;
/** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */
modelRuntime?: ModelRuntime;
/** Model to use. Default: from settings, else first available */
model?: Model<any>;
@@ -169,11 +166,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const authStorage = options.authStorage ?? AuthStorage.create(authPath);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath);
const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
@@ -194,8 +189,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// If session has data, try to restore model from it
if (!model && hasExistingSession && existingSession.model) {
const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) {
const restoredModel = modelRuntime.getModel(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
model = restoredModel;
}
if (!model) {
@@ -211,7 +206,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRegistry,
modelRuntime,
});
model = result.model;
if (!model) {
@@ -300,11 +295,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const auth = await modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) {
throw new Error(auth.error);
}
const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined;
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
@@ -313,28 +303,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
let headers = mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
auth.headers,
options?.headers,
);
// Let extensions inject/adjust per-request headers (e.g. tracing, session correlation)
// after static assembly, before the provider HTTP call.
const headerRunner = extensionRunnerRef.current;
if (headerRunner?.hasHandlers("before_provider_headers")) {
headers = await headerRunner.emitBeforeProviderHeaders(headers ?? {});
}
return streamSimple(model, context, {
return modelRuntime.streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
env,
timeoutMs,
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
headers,
transformHeaders: async (requestHeaders) => {
const headers = mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
requestHeaders,
);
return headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers ?? {})
: (headers ?? {});
},
});
},
onPayload: async (payload, _model) => {
@@ -390,7 +376,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
scopedModels: options.scopedModels,
resourceLoader,
customTools: options.customTools,
modelRegistry,
modelRuntime,
initialActiveToolNames,
allowedToolNames,
excludedToolNames,
+6 -11
View File
@@ -23,17 +23,7 @@ export {
parseSkillBlock,
type SessionStats,
} from "./core/agent-session.ts";
// Auth and model registry
export {
type ApiKeyCredential,
type AuthCredential,
type AuthStatus,
AuthStorage,
type AuthStorageBackend,
FileAuthStorageBackend,
InMemoryAuthStorageBackend,
type OAuthCredential,
} from "./core/auth-storage.ts";
export { readStoredCredential } from "./core/auth-storage.ts";
// Compaction
export {
type BranchPreparation,
@@ -178,6 +168,11 @@ export {
resolveModelScopeWithDiagnostics,
type ScopedModel,
} from "./core/model-resolver.ts";
export {
type CreateModelRuntimeOptions,
ModelRuntime,
type ModelRuntimeAuthOverrides,
} from "./core/model-runtime.ts";
export type {
PackageManager,
PathMetadata,
+12 -14
View File
@@ -23,12 +23,11 @@ import {
createAgentSessionServices,
} from "./core/agent-session-services.ts";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { InlineExtension } from "./core/extensions/types.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import type { ModelRuntime } from "./core/model-runtime.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
@@ -358,7 +357,7 @@ function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
hasExistingSession: boolean,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
settingsManager: SettingsManager,
): {
options: CreateAgentSessionOptions;
@@ -377,7 +376,7 @@ function buildSessionOptions(
cliProvider: parsed.provider,
cliModel: parsed.model,
cliThinking: parsed.thinking,
modelRegistry,
modelRuntime,
});
if (resolved.warning) {
diagnostics.push({ type: "warning", message: resolved.warning });
@@ -400,7 +399,7 @@ function buildSessionOptions(
// Check if saved default is in scoped models - use it if so, otherwise first scoped model
const savedProvider = settingsManager.getDefaultProvider();
const savedModelId = settingsManager.getDefaultModel();
const savedModel = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined;
const savedModel = savedProvider && savedModelId ? modelRuntime.getModel(savedProvider, savedModelId) : undefined;
const savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;
if (savedInScope) {
@@ -433,7 +432,7 @@ function buildSessionOptions(
}));
}
// API key from CLI - set in authStorage
// API key from CLI - set as a non-persistent runtime override
// (handled by caller before createAgentSession)
// Tools
@@ -611,7 +610,6 @@ export async function main(args: string[], options?: MainOptions) {
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
const authStorage = AuthStorage.create();
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
@@ -634,7 +632,6 @@ export async function main(args: string[], options?: MainOptions) {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderReloadOptions: shouldResolveProjectTrust
@@ -676,7 +673,7 @@ export async function main(args: string[], options?: MainOptions) {
extensionFactories: options?.extensionFactories,
},
});
const { settingsManager, modelRegistry, resourceLoader } = services;
const { settingsManager, modelRuntime, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics,
...services.diagnostics,
@@ -689,7 +686,7 @@ export async function main(args: string[], options?: MainOptions) {
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
const scopedModels =
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : [];
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRuntime) : [];
const {
options: sessionOptions,
cliThinkingFromModel,
@@ -698,7 +695,7 @@ export async function main(args: string[], options?: MainOptions) {
parsed,
scopedModels,
sessionManager.buildSessionContext().messages.length > 0,
modelRegistry,
modelRuntime,
settingsManager,
);
diagnostics.push(...sessionOptionDiagnostics);
@@ -710,7 +707,8 @@ export async function main(args: string[], options?: MainOptions) {
message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
});
} else {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
await services.modelRuntime.getAvailable();
}
}
@@ -745,7 +743,7 @@ export async function main(args: string[], options?: MainOptions) {
});
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
const { settingsManager, modelRuntime, resourceLoader } = services;
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
@@ -759,7 +757,7 @@ export async function main(args: string[], options?: MainOptions) {
if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern);
await listModels(modelRuntime, searchPattern);
process.exit(0);
}
@@ -138,7 +138,7 @@ export class FooterComponent implements Component {
statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
}
// Show cost with "(sub)" indicator if using OAuth subscription
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
const usingSubscription = state.model ? this.session.modelRuntime.isUsingOAuth(state.model.provider) : false;
if (totalCost || usingSubscription) {
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statsParts.push(costStr);
@@ -1,4 +1,4 @@
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { openBrowser } from "../../../utils/open-browser.ts";
import { theme } from "../theme/theme.ts";
@@ -38,8 +38,7 @@ export class LoginDialogComponent extends Container implements Focusable {
this.tui = tui;
this.onComplete = onComplete;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerNameOverride || providerInfo?.name || providerId;
const providerName = providerNameOverride || providerId;
const title = titleOverride ?? `Login to ${providerName}`;
// Top border
@@ -176,9 +175,7 @@ export class LoginDialogComponent extends Container implements Focusable {
});
}
/**
* Show informational text before another login step.
*/
/** Show informational text before another login step. */
showDetails(lines: string[]): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
@@ -188,13 +185,19 @@ export class LoginDialogComponent extends Container implements Focusable {
this.tui.requestRender();
}
/**
* Show informational text without prompting for input.
*/
showInfo(lines: string[]): void {
this.showDetails(lines);
/** Show provider-owned information and links without starting an auth callback flow. */
showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0));
this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0));
for (const link of links) {
const text = link.label ? `${link.label}: ${link.url}` : link.url;
const hyperlink = `\x1b]8;;${link.url}\x07${text}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
}
if (showCloseHint) {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0));
}
this.tui.requestRender();
}
@@ -9,7 +9,7 @@ import {
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { ModelRuntime } from "../../../core/model-runtime.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
@@ -52,7 +52,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
private selectedIndex: number = 0;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage?: string;
@@ -61,12 +61,15 @@ export class ModelSelectorComponent extends Container implements Focusable {
private scope: ModelScope = "all";
private scopeText?: Text;
private scopeHintText?: Text;
private readonly refreshAbortController = new AbortController();
private refreshTimeout?: ReturnType<typeof setTimeout>;
private closed = false;
constructor(
tui: TUI,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
@@ -77,7 +80,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.modelRegistry = modelRegistry;
this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
@@ -123,50 +126,23 @@ export class ModelSelectorComponent extends Container implements Focusable {
// Add bottom border
this.addChild(new DynamicBorder());
// Load models and do initial render
this.loadModels().then(() => {
if (initialSearchInput) {
this.filterModels(initialSearchInput);
} else {
this.updateList();
}
// Request re-render after models are loaded
this.tui.requestRender();
});
// Render the current snapshot immediately, then refresh in the background.
this.loadModelsFromSnapshot();
if (initialSearchInput) this.filterModels(initialSearchInput);
else this.updateList();
this.tui.requestRender();
void this.refreshModels();
}
private async loadModels(): Promise<void> {
let models: ModelItem[];
// Refresh to pick up any changes to models.json
this.modelRegistry.refresh();
// Check for models.json errors
const loadError = this.modelRegistry.getError();
if (loadError) {
this.errorMessage = loadError;
}
// Load available models (built-in models still work even if models.json failed)
try {
const availableModels = await this.modelRegistry.getAvailable();
models = availableModels.map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
}));
} catch (error) {
this.allModels = [];
this.scopedModelItems = [];
this.activeModels = [];
this.filteredModels = [];
this.errorMessage = error instanceof Error ? error.message : String(error);
return;
}
private loadModelsFromSnapshot(): void {
const models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
}));
this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => {
const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id);
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
return refreshed ? { ...scoped, model: refreshed } : scoped;
});
this.scopedModelItems = this.scopedModels.map((scoped) => ({
@@ -181,6 +157,37 @@ export class ModelSelectorComponent extends Container implements Focusable {
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
}
private async refreshModels(): Promise<void> {
const timeoutMs = 15_000;
let timedOut = false;
this.refreshTimeout = setTimeout(() => {
timedOut = true;
this.refreshAbortController.abort();
}, timeoutMs);
try {
const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
if (this.closed) return;
if (result.aborted && timedOut) {
this.errorMessage = "Model refresh timed out; showing cached models.";
} else if (result.errors.size > 0) {
this.errorMessage = `Model refresh failed for: ${[...result.errors.keys()].join(", ")}`;
} else {
this.errorMessage = this.modelRuntime.getError();
}
this.loadModelsFromSnapshot();
this.filterModels(this.searchInput.getValue());
this.tui.requestRender();
} finally {
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
}
}
private close(): void {
this.closed = true;
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
this.refreshAbortController.abort();
}
private sortModels(models: ModelItem[]): ModelItem[] {
const sorted = [...models];
// Sort: current model first, then by provider
@@ -316,6 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
this.close();
this.onCancelCallback();
}
// Pass everything else to search input
@@ -326,6 +334,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
private handleSelect(model: Model<any>): void {
this.close();
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
@@ -1,3 +1,4 @@
import type { ApiKeyAuth, AuthCheck, OAuthAuth } from "@earendil-works/pi-ai";
import {
Container,
type Focusable,
@@ -7,7 +8,6 @@ import {
Spacer,
TruncatedText,
} from "@earendil-works/pi-tui";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
@@ -15,6 +15,8 @@ export type AuthSelectorProvider = {
id: string;
name: string;
authType: "oauth" | "api_key";
method?: ApiKeyAuth | OAuthAuth;
status?: AuthCheck;
};
export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string {
@@ -42,26 +44,20 @@ export class OAuthSelectorComponent extends Container implements Focusable {
private filteredProviders: AuthSelectorProvider[];
private selectedIndex: number = 0;
private mode: "login" | "logout";
private authStorage: AuthStorage;
private getAuthStatus: (providerId: string) => AuthStatus;
private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void;
private onCancelCallback: () => void;
private showAuthTypeLabels: boolean;
constructor(
mode: "login" | "logout",
authStorage: AuthStorage,
providers: AuthSelectorProvider[],
onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void,
onCancel: () => void,
getAuthStatus?: (providerId: string) => AuthStatus,
initialSearchInput?: string,
) {
super();
this.mode = mode;
this.authStorage = authStorage;
this.getAuthStatus = getAuthStatus ?? ((providerId) => this.authStorage.getAuthStatus(providerId));
this.allProviders = providers;
this.filteredProviders = providers;
this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1;
@@ -105,7 +101,11 @@ export class OAuthSelectorComponent extends Container implements Focusable {
private filterProviders(query: string): void {
this.filteredProviders = query
? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType}`)
? fuzzyFilter(
this.allProviders,
query,
(provider) => `${provider.name} ${provider.id} ${provider.authType} ${provider.method?.name ?? ""}`,
)
: this.allProviders;
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1)));
this.updateList();
@@ -162,29 +162,22 @@ export class OAuthSelectorComponent extends Container implements Focusable {
}
private formatStatusIndicator(provider: AuthSelectorProvider): string {
const credential = this.authStorage.get(provider.id);
if (credential?.type === provider.authType) return theme.fg("success", " ✓ configured");
if (credential) {
const label = credential.type === "oauth" ? "subscription configured" : "API key configured";
if (!provider.status) return theme.fg("muted", " • unconfigured");
if (provider.status.type !== provider.authType) {
const label = provider.status.type === "oauth" ? "subscription configured" : "API key configured";
return theme.fg("muted", " • ") + theme.fg("warning", label);
}
if (provider.authType !== "api_key") return theme.fg("muted", " • unconfigured");
const status = this.getAuthStatus(provider.id);
switch (status.source) {
case "environment":
return theme.fg("success", ` ✓ env: ${status.label ?? "API key"}`);
case "runtime":
return theme.fg("success", " ✓ runtime API key");
case "fallback":
return theme.fg("success", " ✓ custom API key");
case "models_json_key":
return theme.fg("success", " ✓ key in models.json");
case "models_json_command":
return theme.fg("success", " ✓ command in models.json");
default:
return theme.fg("muted", " • unconfigured");
if (
!provider.status.source ||
provider.status.source === "OAuth" ||
provider.status.source === "stored credential"
) {
return theme.fg("success", " ✓ configured");
}
const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source)
? `env: ${provider.status.source}`
: provider.status.source;
return theme.fg("success", `${source}`);
}
handleInput(keyData: string): void {
@@ -8,15 +8,8 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
type AssistantMessage,
getProviders,
type ImageContent,
type Message,
type Model,
type OAuthProviderId,
type OAuthSelectPrompt,
} from "@earendil-works/pi-ai/compat";
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat";
import type {
AutocompleteItem,
AutocompleteProvider,
@@ -85,7 +78,6 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.t
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";
@@ -212,7 +204,7 @@ function isDeadTerminalError(error: unknown): boolean {
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
@@ -248,22 +240,6 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof
return providerId in defaultModelPerProvider;
}
const BUILT_IN_MODEL_PROVIDERS = new Set<string>(getProviders());
export function isApiKeyLoginProvider(
providerId: string,
oauthProviderIds: ReadonlySet<string>,
builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS,
): boolean {
if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) {
return true;
}
if (builtInProviderIds.has(providerId)) {
return false;
}
return !oauthProviderIds.has(providerId);
}
type LoginProviderCompletionOption = {
id: string;
name: string;
@@ -569,12 +545,12 @@ export class InteractiveMode {
const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) {
modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
modelCommand.getArgumentCompletions = async (prefix: string): Promise<AutocompleteItem[] | null> => {
// Get available models (scoped or from registry)
const models =
this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model)
: this.session.modelRegistry.getAvailable();
: await this.session.modelRuntime.getAvailable();
if (models.length === 0) return null;
@@ -877,7 +853,7 @@ export class InteractiveMode {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
}
const modelsJsonError = this.session.modelRegistry.getError();
const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
@@ -1777,7 +1753,7 @@ export class InteractiveMode {
hasUI: true,
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
modelRegistry: extensionRunner.getModelRegistry(),
model: this.session.model,
isIdle: () => this.session.isIdle,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
@@ -3291,7 +3267,7 @@ export class InteractiveMode {
// Cache-miss notices are not persisted; re-derive them from the full entry
// list and re-inject them after the assistant messages that paid for them.
const cacheMisses = this.settingsManager.getShowCacheMissNotices()
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRegistry)
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRuntime)
: new Map<AssistantMessage, CacheMiss>();
if (options.updateFooter) {
@@ -3395,7 +3371,7 @@ export class InteractiveMode {
if (!this.settingsManager.getShowCacheMissNotices()) return;
// Entries don't contain `message` yet: message_end fires before persistence.
const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRegistry);
const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRuntime);
if (miss) this.addCacheMissNotice(miss);
}
@@ -4328,9 +4304,9 @@ export class InteractiveMode {
return this.session.scopedModels.map((scoped) => scoped.model);
}
this.session.modelRegistry.refresh();
try {
return await this.session.modelRegistry.getAvailable();
await this.session.modelRuntime.refresh();
return [...(await this.session.modelRuntime.getAvailable())];
} catch {
return [];
}
@@ -4356,15 +4332,13 @@ export class InteractiveMode {
return;
}
const storedCredential = this.session.modelRegistry.authStorage.get("anthropic");
if (storedCredential?.type === "oauth") {
this.anthropicSubscriptionWarningShown = true;
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
return;
}
try {
const apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);
if ((await this.session.modelRuntime.checkAuth("anthropic"))?.type === "oauth") {
this.anthropicSubscriptionWarningShown = true;
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
return;
}
const apiKey = (await this.session.modelRuntime.getAuth(model.provider))?.auth.apiKey;
if (!isAnthropicSubscriptionAuthKey(apiKey)) {
return;
}
@@ -4432,7 +4406,7 @@ export class InteractiveMode {
this.ui,
this.session.model,
this.settingsManager,
this.session.modelRegistry,
this.session.modelRuntime,
this.session.scopedModels,
async (model) => {
try {
@@ -4460,8 +4434,8 @@ export class InteractiveMode {
private async showModelsSelector(): Promise<void> {
// Get all available models
this.session.modelRegistry.refresh();
const allModels = this.session.modelRegistry.getAvailable();
await this.session.modelRuntime.refresh();
const allModels = [...(await this.session.modelRuntime.getAvailable())];
if (allModels.length === 0) {
this.showStatus("No models available");
@@ -4482,7 +4456,7 @@ export class InteractiveMode {
// Fall back to settings
const patterns = this.settingsManager.getEnabledModels();
if (patterns !== undefined && patterns.length > 0) {
const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
const scopedModels = await resolveModelScope(patterns, this.session.modelRuntime);
currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
}
}
@@ -4491,7 +4465,7 @@ export class InteractiveMode {
const updateSessionModels = async (enabledIds: string[] | null) => {
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRuntime);
this.session.setScopedModels(
newScopedModels.map((sm) => ({
model: sm.model,
@@ -4805,48 +4779,46 @@ export class InteractiveMode {
}
private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] {
const authStorage = this.session.modelRegistry.authStorage;
const oauthProviders = authStorage.getOAuthProviders();
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
const options: AuthSelectorProvider[] = oauthProviders.map((provider) => ({
id: provider.id,
name: provider.name,
authType: "oauth",
}));
const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider));
for (const providerId of modelProviders) {
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) {
continue;
const options: AuthSelectorProvider[] = [];
for (const provider of this.session.modelRuntime.getProviders()) {
const authStatus = this.session.modelRuntime.getProviderAuthStatus(provider.id);
const status = authStatus.configured
? {
type: this.session.modelRuntime.isUsingOAuth(provider.id) ? ("oauth" as const) : ("api_key" as const),
source: authStatus.label ?? authStatus.source,
}
: undefined;
if ((!authType || authType === "oauth") && provider.auth.oauth) {
options.push({
id: provider.id,
name: provider.name,
authType: "oauth",
method: provider.auth.oauth,
status,
});
}
if ((!authType || authType === "api_key") && provider.auth.apiKey) {
options.push({
id: provider.id,
name: provider.name,
authType: "api_key",
method: provider.auth.apiKey,
status,
});
}
options.push({
id: providerId,
name: this.session.modelRegistry.getProviderDisplayName(providerId),
authType: "api_key",
});
}
const filteredOptions = authType ? options.filter((option) => option.authType === authType) : options;
return filteredOptions.sort((a, b) => a.name.localeCompare(b.name));
return options.sort((a, b) => a.name.localeCompare(b.name));
}
private getLogoutProviderOptions(): AuthSelectorProvider[] {
const authStorage = this.session.modelRegistry.authStorage;
const options: AuthSelectorProvider[] = [];
for (const providerId of authStorage.list()) {
const credential = authStorage.get(providerId);
if (!credential) {
continue;
}
options.push({
private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> {
return (await this.session.modelRuntime.listCredentials())
.map(({ providerId, type }) => ({
id: providerId,
name: this.session.modelRegistry.getProviderDisplayName(providerId),
authType: credential.type,
});
}
return options.sort((a, b) => a.name.localeCompare(b.name));
name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId,
authType: type,
status: { type, source: "stored credential" },
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] {
@@ -4863,6 +4835,7 @@ export class InteractiveMode {
}
private async handleLoginCommand(providerRef?: string): Promise<void> {
await this.session.modelRuntime.getAvailable();
if (!providerRef) {
this.showLoginAuthTypeSelector();
return;
@@ -4888,8 +4861,10 @@ export class InteractiveMode {
private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> {
if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name);
} else {
} else if (providerOption.method?.login) {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
} else {
this.showAmbientAuthDialog(providerOption);
}
}
@@ -4964,7 +4939,6 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
"login",
this.session.modelRegistry.authStorage,
providerOptions,
async (providerId, selectedAuthType) => {
done();
@@ -4986,7 +4960,6 @@ export class InteractiveMode {
this.ui.requestRender();
}
},
(providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId),
initialSearchInput,
);
return { component: selector, focus: selector };
@@ -4999,7 +4972,7 @@ export class InteractiveMode {
return;
}
const providerOptions = this.getLogoutProviderOptions();
const providerOptions = await this.getLogoutProviderOptions();
if (providerOptions.length === 0) {
this.showStatus(
"No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.",
@@ -5010,7 +4983,6 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
mode,
this.session.modelRegistry.authStorage,
providerOptions,
async (providerId: string) => {
done();
@@ -5021,8 +4993,7 @@ export class InteractiveMode {
}
try {
this.session.modelRegistry.authStorage.logout(providerOption.id);
this.session.modelRegistry.refresh();
await this.session.modelRuntime.logout(providerOption.id);
await this.updateAvailableProviderCount();
const message =
providerOption.authType === "oauth"
@@ -5048,14 +5019,14 @@ export class InteractiveMode {
authType: "oauth" | "api_key",
previousModel: Model<any> | undefined,
): Promise<void> {
this.session.modelRegistry.refresh();
await this.session.modelRuntime.getAvailable();
const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`;
let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRegistry.getAvailable();
const availableModels = await this.session.modelRuntime.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
@@ -5095,6 +5066,29 @@ export class InteractiveMode {
}
}
private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void {
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
this.ui.requestRender();
};
const dialog = new LoginDialogComponent(
this.ui,
providerOption.id,
() => restoreEditor(),
providerOption.name,
`${providerOption.name} setup`,
);
dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside pi.`, [], true);
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
}
private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> {
const previousModel = this.session.model;
@@ -5128,13 +5122,7 @@ export class InteractiveMode {
};
try {
const apiKey = (await dialog.showPrompt("Enter API key:")).trim();
if (!apiKey) {
throw new Error("API key cannot be empty.");
}
this.session.modelRegistry.authStorage.set(providerId, { type: "api_key", key: apiKey });
await this.loginProvider(dialog, providerId, "api_key");
restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel);
} catch (error: unknown) {
@@ -5146,8 +5134,11 @@ export class InteractiveMode {
}
}
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> {
return new Promise((resolve) => {
private showAuthSelect(
dialog: LoginDialogComponent,
prompt: Extract<AuthPrompt, { type: "select" }>,
): Promise<string> {
return new Promise((resolve, reject) => {
const restoreDialog = () => {
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
@@ -5160,11 +5151,13 @@ export class InteractiveMode {
labels,
(optionLabel) => {
restoreDialog();
resolve(prompt.options.find((option) => option.label === optionLabel)?.id);
const id = prompt.options.find((option) => option.label === optionLabel)?.id;
if (id) resolve(id);
else reject(new Error("Login cancelled"));
},
() => {
restoreDialog();
resolve(undefined);
reject(new Error("Login cancelled"));
},
);
this.editorContainer.clear();
@@ -5174,40 +5167,63 @@ export class InteractiveMode {
});
}
private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise<string> {
let response: Promise<string>;
if (prompt.type === "select") {
response = this.showAuthSelect(dialog, prompt);
} else if (prompt.type === "manual_code") {
response = dialog.showManualInput(prompt.message);
} else {
response = dialog.showPrompt(prompt.message, prompt.placeholder);
}
if (!prompt.signal) return response;
if (prompt.signal.aborted) throw new Error("Login cancelled");
const signal = prompt.signal;
let onAbort: (() => void) | undefined;
const aborted = new Promise<string>((_resolve, reject) => {
onAbort = () => reject(new Error("Login cancelled"));
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([response, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
private notifyAuthDialog(dialog: LoginDialogComponent, event: AuthEvent): void {
if (event.type === "auth_url") {
dialog.showAuth(event.url, event.instructions);
} else if (event.type === "device_code") {
dialog.showDeviceCode(event);
dialog.showWaiting("Waiting for authentication...");
} else if (event.type === "info") {
dialog.showInfo(event.message, event.links);
} else {
dialog.showProgress(event.message);
}
}
private async loginProvider(
dialog: LoginDialogComponent,
providerId: string,
method: "api_key" | "oauth",
): Promise<void> {
await this.session.modelRuntime.login(providerId, method, {
signal: dialog.signal,
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
notify: (event) => this.notifyAuthDialog(dialog, event),
});
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage
.getOAuthProviders()
.find((provider) => provider.id === providerId);
const previousModel = this.session.model;
// Providers that use callback servers (can paste redirect URL)
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
// Create login dialog component
const dialog = new LoginDialogComponent(
this.ui,
providerId,
(_success, _message) => {
// Completion handled below
},
providerName,
);
// Show dialog in editor container
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName);
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
// Promise for manual code input (racing with callback server)
let manualCodeResolve: ((code: string) => void) | undefined;
let manualCodeReject: ((err: Error) => void) | undefined;
const manualCodePromise = new Promise<string>((resolve, reject) => {
manualCodeResolve = resolve;
manualCodeReject = reject;
});
// Restore editor helper
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
@@ -5216,51 +5232,7 @@ export class InteractiveMode {
};
try {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProviderId, {
onAuth: (info: { url: string; instructions?: string }) => {
dialog.showAuth(info.url, info.instructions);
if (usesCallbackServer) {
// Show input for manual paste, racing with callback
dialog
.showManualInput("Paste redirect URL below, or complete login in browser:")
.then((value) => {
if (value && manualCodeResolve) {
manualCodeResolve(value);
manualCodeResolve = undefined;
}
})
.catch(() => {
if (manualCodeReject) {
manualCodeReject(new Error("Login cancelled"));
manualCodeReject = undefined;
}
});
}
// For Anthropic: onPrompt is called immediately after
},
onDeviceCode: (info) => {
dialog.showDeviceCode(info);
dialog.showWaiting("Waiting for authentication...");
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
return dialog.showPrompt(prompt.message, prompt.placeholder);
},
onProgress: (message: string) => {
dialog.showProgress(message);
},
onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt),
onManualCodeInput: () => manualCodePromise,
signal: dialog.signal,
});
// Success
await this.loginProvider(dialog, providerId, "oauth");
restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel);
} catch (error: unknown) {
@@ -5361,7 +5333,7 @@ export class InteractiveMode {
showDiagnosticsWhenQuiet: true,
});
const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload();
const modelsJsonError = this.session.modelRegistry.getError();
const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
@@ -5606,7 +5578,7 @@ export class InteractiveMode {
const stats = this.session.getSessionStats();
const sessionName = this.sessionManager.getSessionName();
const entries = this.sessionManager.getEntries();
const cacheWaste = computeCacheWaste(entries, this.session.modelRegistry);
const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime);
// Cost/token totals per provider/model actually used (e.g. OpenRouter `auto`
// resolves to a concrete responseModel), sorted by cost descending.
@@ -465,7 +465,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// =================================================================
case "set_model": {
const models = await session.modelRegistry.getAvailable();
const models = await session.modelRuntime.getAvailable();
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
if (!model) {
return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
@@ -483,7 +483,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
}
case "get_available_models": {
const models = await session.modelRegistry.getAvailable();
const models = await session.modelRuntime.getAvailable();
return success(id, "get_available_models", { models });
}
@@ -7,9 +7,9 @@ import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
describe("AgentSession auto-compaction queue resume", () => {
@@ -18,7 +18,7 @@ describe("AgentSession auto-compaction queue resume", () => {
let settingsManager: SettingsManager;
let tempDir: string;
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
vi.useFakeTimers();
@@ -35,15 +35,15 @@ describe("AgentSession auto-compaction queue resume", () => {
sessionManager = SessionManager.inMemory();
settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const modelRegistry = await createModelRegistry(authStorage, tempDir);
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
});
@@ -48,7 +48,7 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: API_KEY! }));
const servicesOptions = {
agentDir: tempDir,
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* E2E tests for AgentSession compaction behavior.
*
@@ -15,7 +16,6 @@ import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createCodingTools } from "../src/index.ts";
@@ -27,7 +27,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
let sessionManager: SessionManager;
let events: AgentSessionEvent[];
beforeEach(() => {
beforeEach(async () => {
// Create temp directory for session files
tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
@@ -45,7 +45,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}
});
function createSession(inMemory = false) {
async function createSession(inMemory = false) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
@@ -61,14 +61,14 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
// Use minimal keepRecentTokens so small test conversations have something to summarize
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -81,7 +81,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}
it("should trigger manual compaction via compact()", async () => {
createSession();
await createSession();
// Send a few prompts to build up history
await session.prompt("What is 2+2? Reply with just the number.");
@@ -107,7 +107,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should maintain valid session state after compaction", async () => {
createSession();
await createSession();
// Build up history
await session.prompt("What is the capital of France? One word answer.");
@@ -132,7 +132,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 180000);
it("should persist compaction to session file", async () => {
createSession();
await createSession();
await session.prompt("Say hello");
await session.agent.waitForIdle();
@@ -160,7 +160,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should work with --no-session mode (in-memory only)", async () => {
createSession(true); // in-memory mode
await createSession(true); // in-memory mode
// Send prompts
await session.prompt("What is 2+2? Reply with just the number.");
@@ -182,7 +182,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should emit compaction events during manual compaction", async () => {
createSession();
await createSession();
// Build some history
await session.prompt("Say hello");
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* Tests for AgentSession concurrent prompt guard.
*/
@@ -18,7 +19,6 @@ import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts";
@@ -62,7 +62,7 @@ describe("AgentSession concurrent prompt guard", () => {
let session: AgentSession;
let tempDir: string;
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
@@ -78,7 +78,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
});
function createSession() {
async function createSession() {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
let abortSignal: AbortSignal | undefined;
@@ -111,16 +111,16 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
const modelRegistry = await createModelRegistry(authStorage, tempDir);
// Set a runtime API key so validation passes
authStorage.setRuntimeApiKey("anthropic", "test-key");
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -128,7 +128,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
it("should throw when prompt() called while streaming", async () => {
createSession();
await createSession();
// Start first prompt (don't await, it will block until abort)
const firstPrompt = session.prompt("First message");
@@ -150,7 +150,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow steer() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -166,7 +166,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow followUp() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -236,8 +236,8 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const extensionsResult = await createTestExtensionsResult([
(pi) => {
@@ -255,7 +255,7 @@ describe("AgentSession concurrent prompt guard", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader({ extensionsResult }),
});
session.subscribe((event) => {
@@ -314,15 +314,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -420,15 +420,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool },
});
@@ -567,15 +567,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool },
});

Some files were not shown because too many files have changed in this diff Show More