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 openrouter-images.ts # image-generation provider factory
faux.ts # test provider factory faux.ts # test provider factory
all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*() 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: `src/index.ts` must stay core-only. It must not import:
@@ -407,7 +407,7 @@ export interface ApiKeyAuth {
name: string; // "Anthropic API key" name: string; // "Anthropic API key"
/** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */ /** 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 * Resolve auth from the stored credential and/or ambient sources, merging
@@ -424,7 +424,7 @@ export interface ApiKeyAuth {
export interface OAuthAuth { export interface OAuthAuth {
name: string; // "Anthropic (Claude Pro/Max)" 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. */ /** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */
refresh(credential: OAuthCredential): Promise<OAuthCredential>; 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 ### Credentials
@@ -564,7 +564,7 @@ FileCredentialStore ports AuthStorage's lock backend: read = memory snaps
└─ withRuntimeOverrides --api-key └─ withRuntimeOverrides --api-key
└─ createModels({ credentials: store }) └─ 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) status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection)
getOAuthProviders presence of provider.auth.oauth across registered providers 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: One interface serves api-key and OAuth login:
```ts ```ts
export interface AuthLoginCallbacks { export interface AuthInteraction {
/** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */ /** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */
signal?: AbortSignal; signal?: AbortSignal;
@@ -612,7 +612,7 @@ export function anthropicProvider(): Provider {
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]), apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]),
oauth: lazyOAuth({ oauth: lazyOAuth({
name: "Anthropic (Claude Pro/Max)", 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, 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. 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 ## 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 ### 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] `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] 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. - [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 ### 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] 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] 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] 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 ### 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. - 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. - `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. - 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`. - 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. - 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. - 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. - Keep API-key/env-token Copilot behavior unchanged.
- Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout. - Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout.
7. [ ] Extension OAuth adapter. 7. [x] Extension OAuth adapter.
- Adapt old extension `OAuthProviderInterface` configs to pi-ai `OAuthAuth`. - Keep only the legacy callback/credential declarations required by coding-agent `ProviderConfig.oauth`.
- `login` maps old callbacks/events to `prompt()/notify()`. - `login` maps legacy callbacks/events to `AuthInteraction.prompt()`/`notify()`.
- `refreshToken` maps to `refresh`. - `refreshToken` maps to `refresh`; `getApiKey` maps to `toAuth`.
- `getApiKey` maps to `toAuth`. - Preserve the type-only pi-ai `oauth` barrel and extension-loader aliases.
- `modifyModels` becomes a provider `getModels()` wrapper plus `toAuth().baseUrl` where applicable.
- Preserve existing extension runtime compatibility through the `/compat` alias until Phase 10.
8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`. 8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`.
- It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers. - 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. - `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`. - [ ] 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. - [ ] 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 ### Deferred / follow-ups
+16
View File
@@ -4,17 +4,33 @@
### Breaking Changes ### 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)). - 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
- 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 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 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 Codex Responses, including `"required"` to force a tool call.
- Added `toolChoice` support to OpenAI Responses, including required and named tool selection. - 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
- 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 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 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. - 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) - [Dynamic Providers](#dynamic-providers)
- [Auth](#auth) - [Auth](#auth)
- [How Auth Resolves](#how-auth-resolves) - [How Auth Resolves](#how-auth-resolves)
- [Transforming Request Headers](#transforming-request-headers)
- [Credential Store](#credential-store) - [Credential Store](#credential-store)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Tools](#tools) - [Tools](#tools)
@@ -334,18 +335,45 @@ await models.complete(model, context);
await models.complete(model, context, { apiKey: 'sk-explicit' }); 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 ```typescript
const auth = await models.getAuth(model); const providerAuth = await models.getAuth(model.provider);
if (auth) { const modelAuth = await models.getAuth(model);
console.log(`configured via ${auth.source}`); // e.g. "ANTHROPIC_API_KEY", "OAuth", "stored credential"
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 { } else {
console.log('not configured'); 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 ### Credential Store
@@ -359,7 +387,7 @@ const models = createModels({ credentials: myFileBackedStore });
// const models = builtinModels({ 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: 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` | | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` | | 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 ## 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): 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 ```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({ const llamacpp = createProvider({
id: 'llamacpp', id: 'llamacpp',
auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } }, auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } },
models: [], models: [],
refreshModels: async () => fetchModelsFromServer('http://localhost:8080'), fetchModels: async ({ signal }) => fetchModelsFromServer('http://localhost:8080', signal),
api: openAICompletionsApi(), api: openAICompletionsApi(),
}); });
models.setProvider(llamacpp); 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. 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) - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
- **GitHub Copilot** (Copilot subscription) - **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 ```typescript
import { createModels } from '@earendil-works/pi-ai'; 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 const models = createModels({ credentials: myStore }); // persistent CredentialStore
models.setProvider(anthropicProvider()); models.setProvider(anthropicProvider());
// Login: drive the flow with prompt()/notify() callbacks, persist the credential // Login: Models drives the flow and persists the credential
const provider = models.getProvider('anthropic')!; await models.login('anthropic', 'oauth', {
const credential = await provider.auth.oauth!.login({
prompt: async (p) => { prompt: async (p) => {
// p.type: 'text' | 'secret' | 'select' | 'manual_code' // p.type: 'text' | 'secret' | 'select' | 'manual_code'
// manual_code prompts race a local callback server; p.signal aborts them when the server wins // manual_code prompts race a local callback server; p.signal aborts them when the server wins
return await askUser(p.message); return await askUser(p.message);
}, },
notify: (event) => { 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 === 'auth_url') console.log(`Open: ${event.url}`);
if (event.type === 'device_code') console.log(`Code: ${event.userCode} at ${event.verificationUri}`); if (event.type === 'device_code') console.log(`Code: ${event.userCode} at ${event.verificationUri}`);
if (event.type === 'progress') console.log(event.message); if (event.type === 'progress') console.log(event.message);
}, },
}); });
await myStore.modify('anthropic', async () => credential);
// From here on, requests resolve and refresh the token automatically // From here on, requests resolve and refresh the token automatically
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!; const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
await models.complete(model, context); await models.complete(model, context);
// Logout // Logout
await myStore.delete('anthropic'); await models.logout('anthropic');
``` ```
### Vertex AI ### 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. - **API key**: Set `GOOGLE_CLOUD_API_KEY` or pass `apiKey` in the call options.
- **Local development (ADC)**: Run `gcloud auth application-default login` - **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 ### 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: 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*` | | `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) | | `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()` | | `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 | | `streamAnthropic(model, ctx, opts)` | `stream` from `@earendil-works/pi-ai/api/anthropic-messages`, or a provider in a collection |
| `registerFauxProvider()` | `fauxProvider()` + `models.setProvider()` | | `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 { function hasResult(
(async () => { source: AsyncIterable<AssistantMessageEvent>,
for await (const event of source) { ): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
target.push(event); return typeof (source as { result?: unknown }).result === "function";
} }
target.end();
})(); 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(); const outer = new AssistantMessageEventStream();
setup() setup()
.then((inner) => { .then((inner) => forwardStream(outer, inner))
forwardStream(outer, inner);
})
.catch((error) => { .catch((error) => {
const message = createSetupErrorMessage(model, error); const message = createSetupErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message }); 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. * Default in-memory credential store. Apps inject persistent stores.
@@ -27,6 +27,10 @@ export class InMemoryCredentialStore implements CredentialStore {
return this.credentials.get(providerId); return this.credentials.get(providerId);
} }
async list(): Promise<readonly CredentialInfo[]> {
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
modify( modify(
providerId: string, providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>, 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 { export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {
return { return {
name, name,
login: async (callbacks) => { login: async (interaction) => {
const key = await callbacks.prompt({ type: "secret", message: `Enter ${name}` }); const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });
return { type: "api_key", key }; return { type: "api_key", key };
}, },
resolve: async ({ ctx, credential }) => { resolve: async ({ ctx, credential }) => {
@@ -39,7 +39,7 @@ export function lazyOAuth(input: { name: string; load: () => Promise<OAuthAuth>
}; };
return { return {
name: input.name, name: input.name,
login: async (callbacks) => (await loaded()).login(callbacks), login: async (interaction) => (await loaded()).login(interaction),
refresh: async (credential) => (await loaded()).refresh(credential), refresh: async (credential) => (await loaded()).refresh(credential),
toAuth: async (credential) => (await loaded()).toAuth(credential), toAuth: async (credential) => (await loaded()).toAuth(credential),
}; };
@@ -6,11 +6,10 @@
*/ */
import type { Server } from "node:http"; import type { Server } from "node:http";
import type { OAuthAuth } from "../../auth/types.ts"; import { getProviderEnvValue } from "../../utils/provider-env.ts";
import { getProviderEnvValue } from "../provider-env.ts"; import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts"; import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
type CallbackServerInfo = { type CallbackServerInfo = {
server: Server; server: Server;
@@ -193,7 +192,7 @@ async function exchangeAuthorizationCode(
state: string, state: string,
verifier: string, verifier: string,
redirectUri: string, redirectUri: string,
): Promise<OAuthCredentials> { ): Promise<OAuthCredential> {
let responseBody: string; let responseBody: string;
try { try {
responseBody = await postJson(TOKEN_URL, { responseBody = await postJson(TOKEN_URL, {
@@ -220,27 +219,21 @@ async function exchangeAuthorizationCode(
} }
return { return {
type: "oauth",
refresh: tokenData.refresh_token, refresh: tokenData.refresh_token,
access: tokenData.access_token, access: tokenData.access_token,
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
}; };
} }
/** async function loginAnthropic(interaction: AuthInteraction): Promise<OAuthCredential> {
* 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> {
const { verifier, challenge } = await generatePKCE(); const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier); const server = await startCallbackServer(verifier);
const manualAbort = new AbortController();
let code: string | undefined; let code: string | undefined;
let state: string | undefined; let state: string | undefined;
let redirectUriForExchange = REDIRECT_URI; let manualInput: string | undefined;
let manualError: Error | undefined;
try { try {
const authParams = new URLSearchParams({ const authParams = new URLSearchParams({
@@ -253,93 +246,58 @@ export async function loginAnthropic(options: {
code_challenge_method: "S256", code_challenge_method: "S256",
state: verifier, state: verifier,
}); });
interaction.notify({
options.onAuth({ type: "auth_url",
url: `${AUTHORIZE_URL}?${authParams.toString()}`, url: `${AUTHORIZE_URL}?${authParams.toString()}`,
instructions: instructions:
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
}); });
if (options.onManualCodeInput) { const manualPromise = interaction
let manualInput: string | undefined; .prompt({
let manualError: Error | undefined; type: "manual_code",
const manualPromise = options message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
.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:",
placeholder: REDIRECT_URI, 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) { const result = await server.waitForCode();
throw new Error("OAuth state mismatch"); 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; code = parsed.code;
state = parsed.state ?? verifier; state = parsed.state ?? verifier;
} }
if (!code) { 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) { if (!code) throw new Error("Missing authorization code");
throw new Error("Missing OAuth state"); 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);
options.onProgress?.("Exchanging authorization code for tokens...");
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange);
} finally { } finally {
manualAbort.abort();
server.server.close(); server.server.close();
} }
} }
@@ -347,7 +305,7 @@ export async function loginAnthropic(options: {
/** /**
* Refresh Anthropic OAuth token * Refresh Anthropic OAuth token
*/ */
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> { async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredential> {
let responseBody: string; let responseBody: string;
try { try {
responseBody = await postJson(TOKEN_URL, { responseBody = await postJson(TOKEN_URL, {
@@ -374,6 +332,7 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
} }
return { return {
type: "oauth",
refresh: data.refresh_token, refresh: data.refresh_token,
access: data.access_token, access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, 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 = { export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)", name: "Anthropic (Claude Pro/Max)",
login: loginAnthropic,
async login(callbacks) { refresh: (credential) => refreshAnthropicToken(credential.refresh),
// 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" };
},
async toAuth(credential) { async toAuth(credential) {
return { apiKey: credential.access }; 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 * GitHub Copilot OAuth flow
*/ */
import type { OAuthAuth, OAuthCredential } from "../../auth/types.ts";
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.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 { 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 decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
@@ -44,7 +37,7 @@ type DeviceTokenErrorResponse = {
interval?: number; interval?: number;
}; };
export function normalizeDomain(input: string): string | null { function normalizeDomain(input: string): string | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return null; if (!trimmed) return null;
try { try {
@@ -81,7 +74,7 @@ function getBaseUrlFromToken(token: string): string | null {
return `https://${apiHost}`; 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 we have a token, extract the base URL from proxy-ep
if (token) { if (token) {
const urlFromToken = getBaseUrlFromToken(token); const urlFromToken = getBaseUrlFromToken(token);
@@ -251,7 +244,7 @@ async function pollForGitHubAccessToken(
async function refreshGitHubCopilotAccessToken( async function refreshGitHubCopilotAccessToken(
refreshToken: string, refreshToken: string,
enterpriseDomain?: string, enterpriseDomain?: string,
): Promise<OAuthCredentials> { ): Promise<OAuthCredential> {
const domain = enterpriseDomain || "github.com"; const domain = enterpriseDomain || "github.com";
const urls = getUrls(domain); const urls = getUrls(domain);
@@ -275,6 +268,7 @@ async function refreshGitHubCopilotAccessToken(
} }
return { return {
type: "oauth",
refresh: refreshToken, refresh: refreshToken,
access: token, access: token,
expires: expiresAt * 1000 - 5 * 60 * 1000, expires: expiresAt * 1000 - 5 * 60 * 1000,
@@ -285,10 +279,7 @@ async function refreshGitHubCopilotAccessToken(
/** /**
* Refresh GitHub Copilot token * Refresh GitHub Copilot token
*/ */
export async function refreshGitHubCopilotToken( async function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string): Promise<OAuthCredential> {
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain); const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain);
return { return {
...credentials, ...credentials,
@@ -326,68 +317,41 @@ async function enableGitHubCopilotModel(token: string, modelId: string, enterpri
* Enable all known GitHub Copilot models that may require policy acceptance. * Enable all known GitHub Copilot models that may require policy acceptance.
* Called after successful login to ensure all models are available. * Called after successful login to ensure all models are available.
*/ */
async function enableAllGitHubCopilotModels( async function enableAllGitHubCopilotModels(token: string, enterpriseDomain?: string): Promise<void> {
token: string,
enterpriseDomain?: string,
onProgress?: (model: string, success: boolean) => void,
): Promise<void> {
const models = Object.values(GITHUB_COPILOT_MODELS); const models = Object.values(GITHUB_COPILOT_MODELS);
await Promise.all( await Promise.all(
models.map(async (model) => { models.map(async (model) => {
const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain); await enableGitHubCopilotModel(token, model.id, enterpriseDomain);
onProgress?.(model.id, success);
}), }),
); );
} }
/** async function loginGitHubCopilot(interaction: AuthInteraction): Promise<OAuthCredential> {
* Login with GitHub Copilot OAuth (device code flow) const input = await interaction.prompt({
* type: "text",
* @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({
message: "GitHub Enterprise URL/domain (blank for github.com)", message: "GitHub Enterprise URL/domain (blank for github.com)",
placeholder: "company.ghe.com", placeholder: "company.ghe.com",
allowEmpty: true,
}); });
if (interaction.signal?.aborted) throw new Error("Login cancelled");
if (options.signal?.aborted) {
throw new Error("Login cancelled");
}
const trimmed = input.trim(); const trimmed = input.trim();
const enterpriseDomain = normalizeDomain(input); const enterpriseDomain = normalizeDomain(input);
if (trimmed && !enterpriseDomain) { if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain");
throw new Error("Invalid GitHub Enterprise URL/domain");
}
const domain = enterpriseDomain || "github.com"; const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain); const device = await startDeviceFlow(domain);
options.onDeviceCode({ interaction.notify({
type: "device_code",
userCode: device.user_code, userCode: device.user_code,
verificationUri: device.verification_uri, verificationUri: device.verification_uri,
intervalSeconds: device.interval, intervalSeconds: device.interval,
expiresInSeconds: device.expires_in, 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); const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined);
interaction.notify({ type: "progress", message: "Enabling models..." });
// Enable all models after successful login
options.onProgress?.("Enabling models...");
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); 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 { return {
...credentials, ...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined), availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined),
@@ -402,26 +366,10 @@ function copilotEnterpriseDomain(credential: OAuthCredential): string | undefine
export const githubCopilotOAuth: OAuthAuth = { export const githubCopilotOAuth: OAuthAuth = {
name: "GitHub Copilot", name: "GitHub Copilot",
login: loginGitHubCopilot,
refresh: (credential) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential)),
async login(callbacks) { /** Derive the credential-specific proxy endpoint for each request. */
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. */
async toAuth(credential) { async toAuth(credential) {
return { return {
apiKey: credential.access, 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 * 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> => export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> =>
((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; ((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 "../../utils/provider-env.ts";
import { getProviderEnvValue } from "../provider-env.ts"; import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts"; import { generatePKCE } from "./pkce.ts";
import type {
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderInterface,
} from "./types.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_BASE_URL = "https://auth.openai.com"; 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_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
const SCOPE = "openid profile email offline_access"; const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth"; 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; return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
} }
function credentialsFromToken(token: OAuthToken): OAuthCredentials { function credentialsFromToken(token: OAuthToken): OAuthCredential {
const accountId = getAccountId(token.access); const accountId = getAccountId(token.access);
if (!accountId) { if (!accountId) {
throw new Error("Failed to extract accountId from token"); throw new Error("Failed to extract accountId from token");
} }
return { return {
type: "oauth",
access: token.access, access: token.access,
refresh: token.refresh, refresh: token.refresh,
expires: token.expires, expires: token.expires,
@@ -425,132 +419,83 @@ async function exchangeAuthorizationCodeForCredentials(
verifier: string, verifier: string,
redirectUri: string, redirectUri: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<OAuthCredentials> { ): Promise<OAuthCredential> {
return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal));
} }
/** async function loginOpenAICodexDeviceCode(interaction: AuthInteraction): Promise<OAuthCredential> {
* Login with OpenAI Codex OAuth using the Codex device-code flow. const device = await startOpenAICodexDeviceAuth(interaction.signal);
*/ interaction.notify({
export async function loginOpenAICodexDeviceCode(options: { type: "device_code",
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
const device = await startOpenAICodexDeviceAuth(options.signal);
options.onDeviceCode({
userCode: device.userCode, userCode: device.userCode,
verificationUri: DEVICE_VERIFICATION_URI, verificationUri: DEVICE_VERIFICATION_URI,
intervalSeconds: device.intervalSeconds, intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
}); });
const code = await pollOpenAICodexDeviceAuth(device, options.signal); const code = await pollOpenAICodexDeviceAuth(device, interaction.signal);
return exchangeAuthorizationCodeForCredentials( return exchangeAuthorizationCodeForCredentials(
code.authorizationCode, code.authorizationCode,
code.codeVerifier, code.codeVerifier,
DEVICE_REDIRECT_URI, DEVICE_REDIRECT_URI,
options.signal, interaction.signal,
); );
} }
/** async function loginOpenAICodex(interaction: AuthInteraction): Promise<OAuthCredential> {
* Login with OpenAI Codex OAuth const { verifier, state, url } = await createAuthorizationFlow();
*
* @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);
const server = await startLocalOAuthServer(state); const server = await startLocalOAuthServer(state);
const manualAbort = new AbortController();
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
let code: string | undefined; 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 { try {
if (options.onManualCodeInput) { const manualPromise = interaction
// Race between browser callback and manual input .prompt({
let manualCode: string | undefined; type: "manual_code",
let manualError: Error | undefined; message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
const manualPromise = options placeholder: REDIRECT_URI,
.onManualCodeInput() signal: manualAbort.signal,
.then((input) => { })
manualCode = input; .then((input) => {
server.cancelWait(); manualCode = input;
}) server.cancelWait();
.catch((err) => { })
manualError = err instanceof Error ? err : new Error(String(err)); .catch((error) => {
server.cancelWait(); manualError = error instanceof Error ? error : new Error(String(error));
}); 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 parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== state) { const result = await server.waitForCode();
throw new Error("State mismatch"); 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; code = parsed.code;
} }
if (!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 { } finally {
manualAbort.abort();
server.close(); server.close();
} }
} }
@@ -558,15 +503,15 @@ export async function loginOpenAICodex(options: {
/** /**
* Refresh OpenAI Codex OAuth token * 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)); return credentialsFromToken(await refreshAccessToken(refreshToken));
} }
export const openaiCodexOAuth: OAuthAuth = { export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)", name: "OpenAI (ChatGPT Plus/Pro)",
async login(callbacks) { async login(interaction) {
const method = await callbacks.prompt({ const method = await interaction.prompt({
type: "select", type: "select",
message: "Select OpenAI Codex login method:", message: "Select OpenAI Codex login method:",
options: [ options: [
@@ -576,89 +521,18 @@ export const openaiCodexOAuth: OAuthAuth = {
}); });
if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
const credentials = await loginOpenAICodexDeviceCode({ return loginOpenAICodexDeviceCode(interaction);
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
signal: callbacks.signal,
});
return { ...credentials, type: "oauth" };
} }
if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${method}`); throw new Error(`Unknown OpenAI Codex login method: ${method}`);
} }
// The manual_code prompt races the local callback server; abort it once return loginOpenAICodex(interaction);
// 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();
}
}, },
async refresh(credential) { refresh: (credential) => refreshOpenAICodexToken(credential.refresh),
return { ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" };
},
async toAuth(credential) { async toAuth(credential) {
return { apiKey: credential.access }; 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 * Radius is a pi-messages gateway. OAuth endpoints are discovered from the
* gateway (`/v1/oauth`); the model catalog comes from `/v1/config` and is * gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
* cached on the stored credential (`gatewayConfig`) so models are available
* at startup and refreshed whenever the token refreshes.
* *
* NOTE: This module uses node:http for the OAuth callback server. * NOTE: This module uses node:http for the OAuth callback server.
* It is only intended for CLI use, not browser environments. * 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 { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.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_HOST = "127.0.0.1";
const CALLBACK_PORT = 1456; const CALLBACK_PORT = 1456;
@@ -34,27 +30,6 @@ const TOKEN_EXPIRY_SKEW_MS = 60_000;
const LOGIN_METHOD_BROWSER = "browser"; const LOGIN_METHOD_BROWSER = "browser";
const LOGIN_METHOD_DEVICE_CODE = "device-code"; 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 = { type RadiusOAuthConfig = {
issuer: string; issuer: string;
authorizationEndpoint: string; authorizationEndpoint: string;
@@ -76,80 +51,6 @@ type DeviceAuthorizationResponse = {
interval?: number; 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> { async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {
const response = await fetch(new URL("/v1/oauth", gateway), { const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" }, headers: { accept: "application/json" },
@@ -202,7 +103,7 @@ async function requestOAuthToken(
oauth: RadiusOAuthConfig, oauth: RadiusOAuthConfig,
body: URLSearchParams, body: URLSearchParams,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<OAuthCredentials> { ): Promise<OAuthCredential> {
let response: Response; let response: Response;
try { try {
response = await fetch(oauth.tokenEndpoint, { response = await fetch(oauth.tokenEndpoint, {
@@ -230,6 +131,7 @@ async function requestOAuthToken(
}; };
return { return {
type: "oauth",
access: data.access_token, access: data.access_token,
refresh: data.refresh_token, refresh: data.refresh_token,
expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS, 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 { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID(); const state = crypto.randomUUID();
const authorizeUrl = new URL(oauth.authorizationEndpoint); const authorizeUrl = new URL(oauth.authorizationEndpoint);
@@ -333,9 +235,10 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
state, state,
}).toString(); }).toString();
const callbackServer = await startOAuthCallbackServer(state, callbacks.signal); const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
callbacks.onProgress?.(`Listening for OAuth callback on ${REDIRECT_URI}`); interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
callbacks.onAuth({ interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(), url: authorizeUrl.toString(),
instructions: "Continue in your browser.", instructions: "Continue in your browser.",
}); });
@@ -343,7 +246,7 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
try { try {
const code = await callbackServer.waitForCode(); const code = await callbackServer.waitForCode();
if (!code) { if (!code) {
if (callbacks.signal?.aborted) { if (interaction.signal?.aborted) {
throw new Error("Login cancelled"); throw new Error("Login cancelled");
} }
throw new Error("OAuth callback did not complete."); throw new Error("OAuth callback did not complete.");
@@ -357,7 +260,7 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, callbacks: OAuthLoginC
code, code,
code_verifier: verifier, code_verifier: verifier,
}), }),
callbacks.signal, interaction.signal,
); );
} finally { } finally {
callbackServer.close(); callbackServer.close();
@@ -402,22 +305,20 @@ async function requestDeviceAuthorization(
}; };
} }
async function loginWithDeviceCode( async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
oauth: RadiusOAuthConfig, const device = await requestDeviceAuthorization(oauth, interaction.signal);
callbacks: OAuthLoginCallbacks, interaction.notify({
): Promise<OAuthCredentials> { type: "device_code",
const device = await requestDeviceAuthorization(oauth, callbacks.signal);
callbacks.onDeviceCode({
userCode: device.user_code, userCode: device.user_code,
verificationUri: device.verification_uri || oauth.verificationEndpoint, verificationUri: device.verification_uri || oauth.verificationEndpoint,
intervalSeconds: device.interval, intervalSeconds: device.interval,
expiresInSeconds: device.expires_in, expiresInSeconds: device.expires_in,
}); });
return pollOAuthDeviceCodeFlow<OAuthCredentials>({ return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.interval, intervalSeconds: device.interval,
expiresInSeconds: device.expires_in, expiresInSeconds: device.expires_in,
signal: callbacks.signal, signal: interaction.signal,
poll: async () => { poll: async () => {
try { try {
const credentials = await requestOAuthToken( const credentials = await requestOAuthToken(
@@ -427,7 +328,7 @@ async function loginWithDeviceCode(
client_id: oauth.clientId, client_id: oauth.clientId,
device_code: device.device_code, device_code: device.device_code,
}), }),
callbacks.signal, interaction.signal,
); );
return { status: "complete", value: credentials }; return { status: "complete", value: credentials };
} catch (error) { } catch (error) {
@@ -451,43 +352,21 @@ async function loginWithDeviceCode(
}); });
} }
async function attachGatewayConfig( export interface RadiusOAuthOptions {
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;
name: string; name: string;
gateway: string; gateway: string;
} }
export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions): OAuthProviderInterface { export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
const gateway = normalizeRadiusGatewayUrl(options.gateway); const gateway = normalizeRadiusGatewayUrl(options.gateway);
return { return {
id: options.id,
name: options.name, name: options.name,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> { async login(interaction): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway); const oauth = await loadRadiusOAuthConfig(gateway);
const loginMethod = await callbacks.onSelect({ const loginMethod = await interaction.prompt({
type: "select",
message: `Sign in to ${options.name}:`, message: `Sign in to ${options.name}:`,
options: [ options: [
{ id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" }, { 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) { if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
credentials = await loginWithDeviceCode(oauth, callbacks); credential = await loginWithDeviceCode(oauth, interaction);
} else if (loginMethod === LOGIN_METHOD_BROWSER) { } else if (loginMethod === LOGIN_METHOD_BROWSER) {
credentials = await loginWithBrowser(oauth, callbacks); credential = await loginWithBrowser(oauth, interaction);
} else { } else {
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); 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 oauth = await loadRadiusOAuthConfig(gateway);
const refreshed = await requestOAuthToken( const refreshed = await requestOAuthToken(
oauth, oauth,
new URLSearchParams({ new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: oauth.clientId, client_id: oauth.clientId,
refresh_token: credentials.refresh, refresh_token: credential.refresh,
}), }),
signal,
); );
return attachGatewayConfig(gateway, refreshed, credentials); return refreshed;
}, },
getApiKey(credentials: OAuthCredentials): string { async toAuth(credential) {
return credentials.access; return { apiKey: credential.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];
}, },
}; };
} }
+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 { import type {
ApiKeyAuth, ApiKeyAuth,
ApiKeyCredential, 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. * Auth resolution shared by the `Models` and `ImagesModels` collections.
* A stored credential owns the provider: ambient/env is consulted only when * 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( export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth }, provider: { id: string; auth: ProviderAuth },
model: AuthModel,
credentials: CredentialStore, credentials: CredentialStore,
authContext: AuthContext, authContext: AuthContext,
overrides?: AuthResolutionOverrides, overrides?: AuthResolutionOverrides,
@@ -47,7 +43,7 @@ export async function resolveProviderAuth(
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext; const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
if (overrides?.apiKey !== undefined && provider.auth.apiKey) { 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", type: "api_key",
key: overrides.apiKey, key: overrides.apiKey,
env: overrides.env, env: overrides.env,
@@ -61,13 +57,15 @@ export async function resolveProviderAuth(
} }
if (stored.type === "api_key" && provider.auth.apiKey) { if (stored.type === "api_key" && provider.auth.apiKey) {
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; 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; return undefined;
} }
// Ambient (env vars, AWS profiles, ADC files). // 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 { function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
@@ -122,13 +120,13 @@ async function resolveStoredOAuth(
async function resolveApiKey( async function resolveApiKey(
authContext: AuthContext, authContext: AuthContext,
apiKey: ApiKeyAuth, apiKey: ApiKeyAuth,
model: AuthModel, providerId: string,
credential: ApiKeyCredential | undefined, credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> { ): Promise<AuthResult | undefined> {
try { try {
return await apiKey.resolve({ model, ctx: authContext, credential }); return await apiKey.resolve({ ctx: authContext, credential });
} catch (error) { } 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 { ProviderEnv, ProviderHeaders } from "../types.ts";
import type { OAuthCredentials } from "../utils/oauth/types.ts";
/** /**
* Request auth for a single model request. If a value cannot be expressed as * Request auth for a single model request. If a value cannot be expressed as
@@ -21,7 +20,15 @@ export interface ApiKeyCredential {
env?: ProviderEnv; 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 { export interface OAuthCredential extends OAuthCredentials {
type: "oauth"; type: "oauth";
} }
@@ -29,6 +36,12 @@ export interface OAuthCredential extends OAuthCredentials {
/** One type-tagged credential per provider — the shape of today's auth.json. */ /** One type-tagged credential per provider — the shape of today's auth.json. */
export type Credential = ApiKeyCredential | OAuthCredential; 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 * App-owned credential storage, keyed by `Provider.id`, one credential per
* provider. `modify` is the only write path, so every mutation is a * 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>; 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 * Serialized write — the only write path. `fn` sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it; * because correct writes (refresh, login-during-refresh) depend on it;
@@ -84,6 +103,13 @@ export interface AuthResult {
source?: string; 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 * 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 * 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 } | { type: "manual_code"; message: string; placeholder?: string }
); );
export interface AuthInfoLink {
url: string;
label?: string;
}
export type AuthEvent = export type AuthEvent =
| { type: "info"; message: string; links?: readonly AuthInfoLink[] }
| { type: "auth_url"; url: string; instructions?: string } | { type: "auth_url"; url: string; instructions?: string }
| { | {
type: "device_code"; type: "device_code";
@@ -115,7 +147,7 @@ export type AuthEvent =
* id). Rejects on cancel/abort. `signal` aborts the whole login flow; * id). Rejects on cancel/abort. `signal` aborts the whole login flow;
* per-prompt cancellation uses `AuthPrompt.signal`. * per-prompt cancellation uses `AuthPrompt.signal`.
*/ */
export interface AuthLoginCallbacks { export interface AuthInteraction {
signal?: AbortSignal; signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>; prompt(prompt: AuthPrompt): Promise<string>;
@@ -131,19 +163,22 @@ export interface ApiKeyAuth {
name: string; name: string;
/** Interactive setup (prompt for key/provider env). Absent = ambient-only. */ /** 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 * Resolve auth from the stored credential and/or ambient sources, merging
* per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`). * per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`).
* undefined = not configured. Receives the chat or image-generation model * undefined = not configured. Resolution is provider-scoped; model-specific
* the request is for (both carry `provider` and `baseUrl`). * endpoint preparation happens after auth has been resolved.
*/ */
resolve(input: { resolve(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthResult | undefined>;
model: Model<Api> | ImagesModel<ImagesApi>;
ctx: AuthContext;
credential?: ApiKeyCredential;
}): Promise<AuthResult | undefined>;
} }
/** /**
@@ -155,13 +190,13 @@ export interface OAuthAuth {
/** Display name, e.g. "Anthropic (Claude Pro/Max)". */ /** Display name, e.g. "Anthropic (Claude Pro/Max)". */
name: string; name: string;
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>; login(interaction: AuthInteraction): Promise<OAuthCredential>;
/** /**
* Exchange the refresh token. Network call; throws on failure * Exchange the refresh token. Network call; throws on failure
* (invalid_grant etc.). `Models` runs this under the store lock. * (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. * Side-effect-free derivation of request auth from a valid credential.
+63 -92
View File
@@ -1,71 +1,74 @@
#!/usr/bin/env node #!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { existsSync, readFileSync, writeFileSync } from "fs"; import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts"; import { builtinProviders } from "./providers/all.ts";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
const AUTH_FILE = "auth.json"; 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> { function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve)); 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 {}; if (!existsSync(AUTH_FILE)) return {};
try { try {
return JSON.parse(readFileSync(AUTH_FILE, "utf-8")); return JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as Record<string, OAuthCredential>;
} catch { } catch {
return {}; 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"); writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
} }
async function login(providerId: OAuthProviderId): Promise<void> { async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {
const provider = getOAuthProvider(providerId); if (authPrompt.type === "select") {
if (!provider) { console.log(`\n${authPrompt.message}`);
console.error(`Unknown provider: ${providerId}`); for (let index = 0; index < authPrompt.options.length; index++) {
process.exit(1); 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 rl = createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
try { try {
const credentials = await provider.login({ const credential = await provider.auth.oauth.login({
onAuth: (info) => { prompt: (authPrompt) => answerPrompt(rl, authPrompt),
console.log(`\nOpen this URL in your browser:\n${info.url}`); notify: (event) => {
if (info.instructions) console.log(info.instructions); switch (event.type) {
console.log(); case "auth_url":
}, console.log(`\nOpen this URL in your browser:\n${event.url}`);
onDeviceCode: (info) => { if (event.instructions) console.log(event.instructions);
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`); break;
console.log(`Enter code: ${info.userCode}`); case "device_code":
console.log(); console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
}, console.log(`Enter code: ${event.userCode}`);
onPrompt: async (p) => { break;
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); case "info":
}, case "progress":
onSelect: async (p) => { console.log(event.message);
console.log(`\n${p.message}`); break;
for (let i = 0; i < p.options.length; i++) {
console.log(` ${i + 1}. ${p.options[i].label}`);
} }
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(); const auth = loadAuth();
auth[providerId] = { type: "oauth", ...credentials }; auth[providerId] = credential;
saveAuth(auth); saveAuth(auth);
console.log(`\nCredentials saved to ${AUTH_FILE}`); console.log(`\nCredentials saved to ${AUTH_FILE}`);
} finally { } finally {
rl.close(); rl.close();
@@ -75,73 +78,41 @@ async function login(providerId: OAuthProviderId): Promise<void> {
async function main(): Promise<void> { async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const command = args[0]; const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") { if (!command || command === "help" || command === "--help" || command === "-h") {
const providerList = PROVIDERS.map((p) => ` ${p.id.padEnd(20)} ${p.name}`).join("\n"); const providerList = PROVIDERS.map((provider) => ` ${provider.id.padEnd(20)} ${provider.name}`).join("\n");
console.log(`Usage: npx @earendil-works/pi-ai <command> [provider] 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}`,
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
`);
return; return;
} }
if (command === "list") { if (command === "list") {
console.log("Available OAuth providers:\n"); for (const provider of PROVIDERS) console.log(`${provider.id.padEnd(20)} ${provider.name}`);
for (const p of PROVIDERS) {
console.log(` ${p.id.padEnd(20)} ${p.name}`);
}
return; return;
} }
if (command === "login") { if (command === "login") {
let provider = args[1] as OAuthProviderId | undefined; let providerId = args[1];
if (!providerId) {
if (!provider) {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("Select a provider:\n"); try {
for (let i = 0; i < PROVIDERS.length; i++) { for (let index = 0; index < PROVIDERS.length; index++) {
console.log(` ${i + 1}. ${PROVIDERS[i].name}`); 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 (!providerId || !PROVIDERS.some((provider) => provider.id === providerId)) {
if (!PROVIDERS.some((p) => p.id === provider)) { throw new Error(`Unknown provider: ${providerId ?? ""}`);
console.error(`Unknown provider: ${provider}`);
console.error(`Use 'npx @earendil-works/pi-ai list' to see available providers`);
process.exit(1);
} }
await login(providerId);
console.log(`Logging in to ${provider}...`);
await login(provider);
return; return;
} }
throw new Error(`Unknown command: ${command}`);
console.error(`Unknown command: ${command}`);
console.error(`Use 'npx @earendil-works/pi-ai --help' for usage`);
process.exit(1);
} }
main().catch((err) => { main().catch((error: unknown) => {
console.error("Error:", err.message); console.error("Error:", error instanceof Error ? error.message : String(error));
process.exit(1); 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 { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
import { piMessagesApi } from "./api/pi-messages.lazy.ts"; import { piMessagesApi } from "./api/pi-messages.lazy.ts";
import { getEnvApiKey } from "./env-api-keys.ts"; import { getEnvApiKey } from "./env-api-keys.ts";
import type { ModelsApiStreamOptions } from "./models.ts";
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts"; import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts";
export type { BuiltinProvider } 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; return { ...options, apiKey } as TOptions;
} }
function shouldUseBuiltinModels(model: Model<Api>): boolean { function hasResolvedCloudflareAuth(options: StreamOptions | undefined): boolean {
const builtin = compatModels.getModel(model.provider, model.id); return hasExplicitApiKey(options?.apiKey) || typeof options?.headers?.["cf-aig-authorization"] === "string";
return builtin?.api === model.api && getApiProvider(model.api) === builtinApiProviderInstances.get(model.api); }
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) { function resolveApiProvider(api: Api) {
@@ -246,8 +252,12 @@ export function stream<TApi extends Api>(
context: Context, context: Context,
options?: ProviderStreamOptions, options?: ProviderStreamOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
if (shouldUseBuiltinModels(model)) { const builtinProvider = getBuiltinProviderForModel(model);
return compatModels.stream(model, context, options as ApiStreamOptions<TApi> | undefined); 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); const provider = resolveApiProvider(model.api);
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions); return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
@@ -267,8 +277,12 @@ export function streamSimple<TApi extends Api>(
context: Context, context: Context,
options?: SimpleStreamOptions, options?: SimpleStreamOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
if (shouldUseBuiltinModels(model)) { const builtinProvider = getBuiltinProviderForModel(model);
return compatModels.streamSimple(model, context, options); 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); const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, withEnvApiKey(model, options)); 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", groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY", cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY", xai: "XAI_API_KEY",
radius: "PI_GATEWAY_API_KEY", radius: "RADIUS_API_KEY",
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY", zai: "ZAI_API_KEY",
+14 -7
View File
@@ -1,6 +1,6 @@
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts"; import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.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 { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import type { CreateModelsOptions } from "./models.ts"; import type { CreateModelsOptions } from "./models.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts"; import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts";
@@ -68,11 +68,12 @@ export interface ImagesModels {
refresh(provider?: string): Promise<void>; 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 * `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
* `ModelsError` ("oauth"/"auth") on real failures. * `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 * 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?.())); await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
} }
async getAuth(model: ImagesModel<ImagesApi>): Promise<AuthResult | undefined> { getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
const provider = this.providers.get(model.provider); 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; if (!provider) return undefined;
return resolveProviderAuth(provider, model, this.credentials, this.authContext); return resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
} }
async generateImages( async generateImages(
@@ -184,7 +191,7 @@ class ImagesModelsImpl implements MutableImagesModels {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`); 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, apiKey: options?.apiKey,
env: options?.env, 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/credential-store.ts";
export * from "./auth/helpers.ts"; export * from "./auth/helpers.ts";
export * from "./auth/types.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 "./images-models.ts";
export * from "./models.ts"; export * from "./models.ts";
export * from "./models-store.ts";
export * from "./providers/faux.ts"; export * from "./providers/faux.ts";
export * from "./session-resources.ts"; export * from "./session-resources.ts";
export * from "./types.ts"; export * from "./types.ts";
export * from "./utils/diagnostics.ts"; export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts"; export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.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/overflow.ts";
export * from "./utils/retry.ts"; export * from "./utils/retry.ts";
export * from "./utils/typebox-helpers.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 { lazyStream } from "./api/lazy.ts";
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts"; import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.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 {
AuthCheck,
AuthContext,
AuthInteraction,
AuthResult,
AuthType,
Credential,
CredentialStore,
ProviderAuth,
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ProviderModelsStore } from "./models-store.ts";
import type { import type {
Api, Api,
ApiStreamOptions, ApiStreamOptions,
@@ -19,7 +29,35 @@ import type {
Usage, Usage,
} from "./types.ts"; } 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, * 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>[]; getModels(): readonly Model<TApi>[];
/** /**
* Dynamic providers only: fetch and update the model list. Side-effect-free * Dynamic providers only: restore the provider-scoped stored catalog and optionally fetch
* discovery (no loading/downloading); provider-specific model lifecycle * a newer list using the effective credential. Implementations must retain their previous
* belongs in app commands. Concurrent calls share one in-flight fetch. * list on failure and honor the shared abort signal for network requests.
* May reject (network); on rejection the model list stays at its last-known
* state and a later call retries.
*/ */
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>( stream<T extends TApi>(
model: Model<T>, model: Model<T>,
@@ -94,38 +137,49 @@ export interface Models {
getModel(provider: string, id: string): Model<Api> | undefined; getModel(provider: string, id: string): Model<Api> | undefined;
/** /**
* Ask dynamic providers to re-fetch their model lists. With a provider id, * Refresh every configured dynamic provider concurrently. Provider errors and cancellation
* rejects with `ModelsError` ("model_source") on that provider's fetch * are returned without rejecting; static and unconfigured providers are skipped.
* failure; without one, refreshes all providers concurrently best-effort.
* Static providers (no `refreshModels`) are no-ops.
*/ */
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. * Resolves `undefined` when the provider is unknown or unconfigured.
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
* stored credential is preserved for retry; re-login fixes it), code "auth" * stored credential is preserved for retry; re-login fixes it), code "auth"
* when api-key resolution or the credential store fails. Request paths * when api-key resolution or the credential store fails. Request paths
* surface rejections as stream errors; status/availability UIs catch them * surface rejections as stream errors.
* and render "needs re-login" instead of treating them as unconfigured.
*/ */
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>( stream<TApi extends Api>(
model: Model<TApi>, model: Model<TApi>,
context: Context, context: Context,
options?: ApiStreamOptions<TApi>, options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream; ): AssistantMessageEventStream;
complete<TApi extends Api>( complete<TApi extends Api>(
model: Model<TApi>, model: Model<TApi>,
context: Context, context: Context,
options?: ApiStreamOptions<TApi>, options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage>; ): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>; completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
} }
export interface MutableModels extends Models { export interface MutableModels extends Models {
@@ -137,16 +191,35 @@ export interface MutableModels extends Models {
export interface CreateModelsOptions { export interface CreateModelsOptions {
credentials?: CredentialStore; credentials?: CredentialStore;
modelsStore?: ModelsStore;
authContext?: AuthContext; 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 { class ModelsImpl implements MutableModels {
private providers = new Map<string, Provider>(); private providers = new Map<string, Provider>();
private credentials: CredentialStore; private credentials: CredentialStore;
private modelsStore: ModelsStore;
private authContext: AuthContext; private authContext: AuthContext;
constructor(options?: CreateModelsOptions) { constructor(options?: CreateModelsOptions) {
this.credentials = options?.credentials ?? new InMemoryCredentialStore(); this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
this.authContext = options?.authContext ?? defaultAuthContext(); this.authContext = options?.authContext ?? defaultAuthContext();
} }
@@ -196,28 +269,177 @@ class ModelsImpl implements MutableModels {
return this.getModels(provider).find((model) => model.id === id); return this.getModels(provider).find((model) => model.id === id);
} }
async refresh(provider?: string): Promise<void> { async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
if (provider !== undefined) { const allowNetwork = options.allowNetwork ?? true;
const entry = this.providers.get(provider); const errors = new Map<string, Error>();
if (!entry?.refreshModels) return; const refreshable = Array.from(this.providers.values()).filter(
try { (provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
await entry.refreshModels(); provider.refreshModels !== undefined,
} catch (error) { );
if (error instanceof ModelsError) throw error;
throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error });
}
return;
}
// Cannot reject: the async mapper turns even sync throws from ill-behaved await Promise.all(
// providers into rejections, and allSettled captures all of them. refreshable.map(async (provider) => {
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.())); 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> { private async resolveRefreshCredential(
const provider = this.providers.get(model.provider); 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; 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 { private requireProvider(model: Model<Api>): Provider {
@@ -228,30 +450,28 @@ class ModelsImpl implements MutableModels {
return provider; return provider;
} }
private async applyAuth<TOptions extends StreamOptions>( private async applyAuth<TOptions extends StreamOptions & ModelsStreamTransforms>(
model: Model<Api>, model: Model<Api>,
options: TOptions | undefined, options: TOptions | undefined,
): Promise<{ requestModel: Model<Api>; requestOptions: TOptions | undefined }> { ): Promise<{ requestModel: Model<Api>; requestOptions: StreamOptions | undefined }> {
const resolution = await resolveProviderAuth( this.requireProvider(model);
this.requireProvider(model), const resolution = await this.getAuth(model, {
model, apiKey: options?.apiKey,
this.credentials, env: options?.env,
this.authContext, });
{ if (!resolution) {
apiKey: options?.apiKey, throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
env: options?.env, }
}, const auth = resolution.auth;
);
const auth = resolution?.auth;
if (!auth) return { requestModel: model, requestOptions: options };
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; // Explicit request options win per-field; the Models-only transform runs last.
// Explicit request options win per-field; headers/env merge per key.
const apiKey = options?.apiKey ?? auth.apiKey; 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 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 }; return { requestModel, requestOptions };
} }
@@ -259,11 +479,14 @@ class ModelsImpl implements MutableModels {
stream<TApi extends Api>( stream<TApi extends Api>(
model: Model<TApi>, model: Model<TApi>,
context: Context, context: Context,
options?: ApiStreamOptions<TApi>, options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
return lazyStream(model, async () => { return lazyStream(model, async () => {
const provider = this.requireProvider(model); 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>); 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>( async complete<TApi extends Api>(
model: Model<TApi>, model: Model<TApi>,
context: Context, context: Context,
options?: ApiStreamOptions<TApi>, options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage> { ): Promise<AssistantMessage> {
return this.stream(model, context, options).result(); 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 () => { return lazyStream(model, async () => {
const provider = this.requireProvider(model); const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options); 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(); return this.streamSimple(model, context, options).result();
} }
} }
@@ -301,16 +528,11 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
headers?: ProviderHeaders; headers?: ProviderHeaders;
/** Required — every provider has auth semantics, even ambient/keyless ones. */ /** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth; auth: ProviderAuth;
/** Initial model list (empty for purely dynamic providers). */ /** Static baseline model list (empty for purely dynamic providers). */
models: readonly Model<TApi>[]; models: readonly Model<TApi>[];
/** /** Fetch a dynamic model overlay. createProvider restores/persists it through ModelsStore. */
* Dynamic providers: fetch the current list. Stored on success; concurrent fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
* calls share one in-flight fetch. May reject: the stored list then stays filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
* 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>[]>;
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */ /** Single implementation, or map keyed by `model.api` for mixed-API providers. */
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>; api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
} }
@@ -322,9 +544,19 @@ export interface CreateProviderOptions<TApi extends Api = Api> {
* produces a stream error. * produces a stream error.
*/ */
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> { 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; 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 = const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined; typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>); 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, baseUrl: input.baseUrl,
headers: input.headers, headers: input.headers,
auth: input.auth, auth: input.auth,
getModels: () => models, getModels: currentModels,
refreshModels: refreshModels refreshModels: fetchModels
? () => { ? (context) => {
inflightRefresh ??= (async () => { inflightRefresh ??= (async () => {
try { 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 { } finally {
inflightRefresh = undefined; inflightRefresh = undefined;
} }
@@ -363,6 +605,7 @@ export function createProvider<TApi extends Api = Api>(input: CreateProviderOpti
return inflightRefresh; return inflightRefresh;
} }
: undefined, : undefined,
filterModels: input.filterModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)), stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) => streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.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 { opencodeGoProvider } from "./opencode-go.ts";
import { openrouterProvider } from "./openrouter.ts"; import { openrouterProvider } from "./openrouter.ts";
import { openrouterImagesProvider } from "./openrouter-images.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts";
import { radiusProvider } from "./radius.ts";
import { togetherProvider } from "./together.ts"; import { togetherProvider } from "./together.ts";
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
import { xaiProvider } from "./xai.ts"; import { xaiProvider } from "./xai.ts";
@@ -39,6 +40,8 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
import { zaiProvider } from "./zai.ts"; import { zaiProvider } from "./zai.ts";
import { zaiCodingCnProvider } from "./zai-coding-cn.ts"; import { zaiCodingCnProvider } from "./zai-coding-cn.ts";
export { radiusProvider };
/** Providers present in the generated catalog. `KnownProvider` additionally /** Providers present in the generated catalog. `KnownProvider` additionally
* includes purely dynamic providers (e.g. "radius") that have no static * includes purely dynamic providers (e.g. "radius") that have no static
* catalog entry. */ * catalog entry. */
@@ -100,6 +103,7 @@ export function builtinProviders(): Provider[] {
opencodeProvider(), opencodeProvider(),
opencodeGoProvider(), opencodeGoProvider(),
openrouterProvider(), openrouterProvider(),
radiusProvider(),
togetherProvider(), togetherProvider(),
vercelAIGatewayProvider(), vercelAIGatewayProvider(),
xaiProvider(), xaiProvider(),
@@ -1290,6 +1290,60 @@ export const AMAZON_BEDROCK_MODELS = {
contextWindow: 272000, contextWindow: 272000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"bedrock-converse-stream">, } 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": { "openai.gpt-oss-120b": {
id: "openai.gpt-oss-120b", id: "openai.gpt-oss-120b",
name: "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"; import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.models.ts";
/** /**
* Bedrock auth is ambient: the AWS SDK's default credential chain handles the * Bedrock accepts a bearer token or the AWS SDK's default credential chain.
* actual signing, so `resolve` only reports whether the provider is * The login flow can store a token/profile choice; resolve also detects ambient
* configured. A stored credential key is surfaced as the bearer token. * AWS credentials without copying them into pi's credential store.
*/ */
const bedrockAuth: ApiKeyAuth = { const bedrockAuth: ApiKeyAuth = {
name: "Bedrock API key or AWS credentials", name: "AWS credentials or bearer token",
login: async (callbacks) => ({ login: async (interaction) => {
type: "api_key", const method = await interaction.prompt({
key: await callbacks.prompt({ type: "secret", message: "Enter Bedrock API key" }), 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 }) => { resolve: async ({ ctx, credential }) => {
if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored 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_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"))) { if ((await ctx.env("AWS_ACCESS_KEY_ID")) && (await ctx.env("AWS_SECRET_ACCESS_KEY"))) {
return { auth: {}, source: "AWS access keys" }; return { auth: {}, source: "AWS access keys" };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadAnthropicOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { loadAnthropicOAuth } from "../utils/oauth/load.ts";
import { ANTHROPIC_MODELS } from "./anthropic.models.ts"; import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
export function anthropicProvider(): Provider<"anthropic-messages"> { export function anthropicProvider(): Provider<"anthropic-messages"> {
@@ -660,6 +660,23 @@ export const AZURE_OPENAI_RESPONSES_MODELS = {
contextWindow: 1050000, contextWindow: 1050000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"azure-openai-responses">, } 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": { "o1": {
id: "o1", id: "o1",
name: "o1", name: "o1",
+1 -1
View File
@@ -52,7 +52,7 @@ export const CEREBRAS_MODELS = {
cost: { cost: {
input: 2.25, input: 2.25,
output: 2.75, output: 2.75,
cacheRead: 0, cacheRead: 2.25,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 131072, contextWindow: 131072,
@@ -528,6 +528,60 @@ export const CLOUDFLARE_AI_GATEWAY_MODELS = {
contextWindow: 1050000, contextWindow: 1050000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"openai-responses">, } 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": { "o1": {
id: "o1", id: "o1",
name: "o1", name: "o1",
@@ -685,4 +739,22 @@ export const CLOUDFLARE_AI_GATEWAY_MODELS = {
contextWindow: 131072, contextWindow: 131072,
maxTokens: 131072, maxTokens: 131072,
} satisfies Model<"openai-completions">, } 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; } as const;
@@ -4,6 +4,7 @@ import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts"; import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts";
import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts"; import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts";
import { cloudflareStreams } from "./cloudflare-stream.ts";
export function cloudflareAIGatewayProvider(): Provider< export function cloudflareAIGatewayProvider(): Provider<
"anthropic-messages" | "openai-completions" | "openai-responses" "anthropic-messages" | "openai-completions" | "openai-responses"
@@ -14,9 +15,9 @@ export function cloudflareAIGatewayProvider(): Provider<
auth: { apiKey: cloudflareAIGatewayAuth() }, auth: { apiKey: cloudflareAIGatewayAuth() },
models: Object.values(CLOUDFLARE_AI_GATEWAY_MODELS), models: Object.values(CLOUDFLARE_AI_GATEWAY_MODELS),
api: { api: {
"anthropic-messages": anthropicMessagesApi(), "anthropic-messages": cloudflareStreams(anthropicMessagesApi()),
"openai-completions": openAICompletionsApi(), "openai-completions": cloudflareStreams(openAICompletionsApi()),
"openai-responses": openAIResponsesApi(), "openai-responses": cloudflareStreams(openAIResponsesApi()),
}, },
}); });
} }
+14 -27
View File
@@ -1,5 +1,5 @@
import type { ApiKeyAuth, ApiKeyCredential, AuthContext } from "../auth/types.ts"; 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_API_KEY = "CLOUDFLARE_API_KEY";
const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID"; const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID";
@@ -23,22 +23,11 @@ async function resolveValue(
return fromCredential ?? (await ctx.env(name)); 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( async function resolveCloudflareEnv(
kind: CloudflareAuthKind, kind: CloudflareAuthKind,
model: Model<Api> | ImagesModel<ImagesApi>,
ctx: AuthContext, ctx: AuthContext,
credential: ApiKeyCredential | undefined, 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 apiKey = await resolveValue(CLOUDFLARE_API_KEY, ctx, credential);
const accountId = await resolveValue(CLOUDFLARE_ACCOUNT_ID, ctx, credential); const accountId = await resolveValue(CLOUDFLARE_ACCOUNT_ID, ctx, credential);
const gatewayId = kind === "ai-gateway" ? await resolveValue(CLOUDFLARE_GATEWAY_ID, ctx, credential) : undefined; const gatewayId = kind === "ai-gateway" ? await resolveValue(CLOUDFLARE_GATEWAY_ID, ctx, credential) : undefined;
@@ -51,7 +40,6 @@ async function resolveCloudflareEnv(
CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_ACCOUNT_ID: accountId,
...(gatewayId ? { CLOUDFLARE_GATEWAY_ID: gatewayId } : {}), ...(gatewayId ? { CLOUDFLARE_GATEWAY_ID: gatewayId } : {}),
}, },
baseUrl: resolveCloudflareBaseUrl(model, accountId, gatewayId),
source: credential ? "stored credential" : CLOUDFLARE_API_KEY, source: credential ? "stored credential" : CLOUDFLARE_API_KEY,
}; };
} }
@@ -59,16 +47,16 @@ async function resolveCloudflareEnv(
export function cloudflareWorkersAIAuth(): ApiKeyAuth { export function cloudflareWorkersAIAuth(): ApiKeyAuth {
return { return {
name: "Cloudflare API key", name: "Cloudflare API key",
login: async (callbacks) => { login: async (interaction) => {
const key = await callbacks.prompt({ type: "secret", message: "Enter Cloudflare API key" }); const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" }); const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
return { type: "api_key", key, env: { CLOUDFLARE_ACCOUNT_ID: accountId } }; return { type: "api_key", key, env: { CLOUDFLARE_ACCOUNT_ID: accountId } };
}, },
resolve: async ({ model, ctx, credential }) => { resolve: async ({ ctx, credential }) => {
const resolved = await resolveCloudflareEnv("workers-ai", model, ctx, credential); const resolved = await resolveCloudflareEnv("workers-ai", ctx, credential);
if (!resolved) return undefined; if (!resolved) return undefined;
return { return {
auth: { apiKey: resolved.apiKey, baseUrl: resolved.baseUrl }, auth: { apiKey: resolved.apiKey },
env: resolved.env, env: resolved.env,
source: resolved.source, source: resolved.source,
}; };
@@ -79,18 +67,18 @@ export function cloudflareWorkersAIAuth(): ApiKeyAuth {
export function cloudflareAIGatewayAuth(): ApiKeyAuth { export function cloudflareAIGatewayAuth(): ApiKeyAuth {
return { return {
name: "Cloudflare API key", name: "Cloudflare API key",
login: async (callbacks) => { login: async (interaction) => {
const key = await callbacks.prompt({ type: "secret", message: "Enter Cloudflare API key" }); const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" }); const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
const gatewayId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" }); const gatewayId = await interaction.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" });
return { return {
type: "api_key", type: "api_key",
key, key,
env: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId }, env: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId },
}; };
}, },
resolve: async ({ model, ctx, credential }) => { resolve: async ({ ctx, credential }) => {
const resolved = await resolveCloudflareEnv("ai-gateway", model, ctx, credential); const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential);
if (!resolved) return undefined; if (!resolved) return undefined;
return { return {
auth: { auth: {
@@ -99,7 +87,6 @@ export function cloudflareAIGatewayAuth(): ApiKeyAuth {
Authorization: null, Authorization: null,
"x-api-key": null, "x-api-key": null,
}, },
baseUrl: resolved.baseUrl,
}, },
env: resolved.env, env: resolved.env,
source: resolved.source, 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 { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { cloudflareWorkersAIAuth } from "./cloudflare-auth.ts"; import { cloudflareWorkersAIAuth } from "./cloudflare-auth.ts";
import { cloudflareStreams } from "./cloudflare-stream.ts";
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./cloudflare-workers-ai.models.ts"; import { CLOUDFLARE_WORKERS_AI_MODELS } from "./cloudflare-workers-ai.models.ts";
export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> { export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> {
@@ -9,6 +10,6 @@ export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> {
name: "Cloudflare Workers AI", name: "Cloudflare Workers AI",
auth: { apiKey: cloudflareWorkersAIAuth() }, auth: { apiKey: cloudflareWorkersAIAuth() },
models: Object.values(CLOUDFLARE_WORKERS_AI_MODELS), models: Object.values(CLOUDFLARE_WORKERS_AI_MODELS),
api: openAICompletionsApi(), api: cloudflareStreams(openAICompletionsApi()),
}); });
} }
@@ -253,7 +253,7 @@ export const GITHUB_COPILOT_MODELS = {
cacheRead: 0.2, cacheRead: 0.2,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 200000, contextWindow: 1000000,
maxTokens: 64000, maxTokens: 64000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"gemini-3.5-flash": { "gemini-3.5-flash": {
@@ -446,6 +446,63 @@ export const GITHUB_COPILOT_MODELS = {
contextWindow: 1000000, contextWindow: 1000000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"openai-responses">, } 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": { "kimi-k2.7-code": {
id: "kimi-k2.7-code", id: "kimi-k2.7-code",
name: "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 { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
import { loadGitHubCopilotOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { loadGitHubCopilotOAuth } from "../utils/oauth/load.ts";
import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts"; import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts";
export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> { 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 }), oauth: lazyOAuth({ name: "GitHub Copilot", load: loadGitHubCopilotOAuth }),
}, },
models: Object.values(GITHUB_COPILOT_MODELS), 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: { api: {
"anthropic-messages": anthropicMessagesApi(), "anthropic-messages": anthropicMessagesApi(),
"openai-completions": openAICompletionsApi(), "openai-completions": openAICompletionsApi(),
+60 -5
View File
@@ -12,16 +12,71 @@ const VERTEX_ADC_PATH = "~/.config/gcloud/application_default_credentials.json";
*/ */
const vertexAuth: ApiKeyAuth = { const vertexAuth: ApiKeyAuth = {
name: "Google Cloud credentials", 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 }) => { resolve: async ({ ctx, credential }) => {
const key = credential?.key ?? (await ctx.env("GOOGLE_CLOUD_API_KEY")); 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" }; 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 hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH);
const hasProject = Boolean((await ctx.env("GOOGLE_CLOUD_PROJECT")) ?? (await ctx.env("GCLOUD_PROJECT"))); const project =
const hasLocation = Boolean(await ctx.env("GOOGLE_CLOUD_LOCATION")); credential?.env?.GOOGLE_CLOUD_PROJECT ??
if (hasCredentials && hasProject && hasLocation) { (await ctx.env("GOOGLE_CLOUD_PROJECT")) ??
return { auth: {}, source: "gcloud application default credentials" }; (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; return undefined;
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts"; import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts";
import { lazyOAuth } from "../auth/helpers.ts"; import { lazyOAuth } from "../auth/helpers.ts";
import { loadOpenAICodexOAuth } from "../auth/oauth/load.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { loadOpenAICodexOAuth } from "../utils/oauth/load.ts";
import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts"; import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts";
export function openaiCodexProvider(): Provider<"openai-codex-responses"> { export function openaiCodexProvider(): Provider<"openai-codex-responses"> {
@@ -674,6 +674,23 @@ export const OPENAI_MODELS = {
contextWindow: 272000, contextWindow: 272000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"openai-responses">, } 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": { "o1": {
id: "o1", id: "o1",
name: "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, contextWindow: 200000,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"anthropic-messages">, } 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": { "anthropic/claude-fable-5": {
id: "anthropic/claude-fable-5", id: "anthropic/claude-fable-5",
name: "Claude Fable 5", name: "Claude Fable 5",
@@ -565,7 +548,7 @@ export const VERCEL_AI_GATEWAY_MODELS = {
cacheWrite: 18.75, cacheWrite: 18.75,
}, },
contextWindow: 200000, contextWindow: 200000,
maxTokens: 32000, maxTokens: 8192,
} satisfies Model<"anthropic-messages">, } satisfies Model<"anthropic-messages">,
"anthropic/claude-opus-4.1": { "anthropic/claude-opus-4.1": {
id: "anthropic/claude-opus-4.1", id: "anthropic/claude-opus-4.1",
@@ -673,7 +656,7 @@ export const VERCEL_AI_GATEWAY_MODELS = {
cacheWrite: 3.75, cacheWrite: 3.75,
}, },
contextWindow: 1000000, contextWindow: 1000000,
maxTokens: 64000, maxTokens: 8192,
} satisfies Model<"anthropic-messages">, } satisfies Model<"anthropic-messages">,
"anthropic/claude-sonnet-4.5": { "anthropic/claude-sonnet-4.5": {
id: "anthropic/claude-sonnet-4.5", id: "anthropic/claude-sonnet-4.5",
@@ -730,23 +713,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 1000000, contextWindow: 1000000,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"anthropic-messages">, } 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": { "arcee-ai/trinity-large-thinking": {
id: "arcee-ai/trinity-large-thinking", id: "arcee-ai/trinity-large-thinking",
name: "Trinity Large Thinking", name: "Trinity Large Thinking",
@@ -875,12 +841,12 @@ export const VERCEL_AI_GATEWAY_MODELS = {
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.6, input: 0.21,
output: 1.7, output: 0.79,
cacheRead: 0, cacheRead: 0.13,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 128000, contextWindow: 163840,
maxTokens: 128000, maxTokens: 128000,
} satisfies Model<"anthropic-messages">, } satisfies Model<"anthropic-messages">,
"deepseek/deepseek-v3.1-terminus": { "deepseek/deepseek-v3.1-terminus": {
@@ -1206,6 +1172,23 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 1000000, contextWindow: 1000000,
maxTokens: 32000, maxTokens: 32000,
} satisfies Model<"anthropic-messages">, } 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": { "kwaipilot/kat-coder-pro-v1": {
id: "kwaipilot/kat-coder-pro-v1", id: "kwaipilot/kat-coder-pro-v1",
name: "KAT-Coder-Pro V1", name: "KAT-Coder-Pro V1",
@@ -1240,39 +1223,22 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000, contextWindow: 256000,
maxTokens: 256000, maxTokens: 256000,
} satisfies Model<"anthropic-messages">, } satisfies Model<"anthropic-messages">,
"meituan/longcat-flash-chat": { "kwaipilot/kat-coder-pro-v2.5": {
id: "meituan/longcat-flash-chat", id: "kwaipilot/kat-coder-pro-v2.5",
name: "LongCat Flash Chat", name: "Kat Coder Pro V2.5",
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",
api: "anthropic-messages", api: "anthropic-messages",
provider: "vercel-ai-gateway", provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh", baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0, input: 0.74,
output: 0, output: 2.96,
cacheRead: 0, cacheRead: 0.15,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 32768, contextWindow: 256000,
maxTokens: 32768, maxTokens: 80000,
} satisfies Model<"anthropic-messages">, } satisfies Model<"anthropic-messages">,
"meta/llama-3.1-70b": { "meta/llama-3.1-70b": {
id: "meta/llama-3.1-70b", id: "meta/llama-3.1-70b",
@@ -1580,23 +1546,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000, contextWindow: 256000,
maxTokens: 256000, maxTokens: 256000,
} satisfies Model<"anthropic-messages">, } 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": { "mistral/devstral-small-2": {
id: "mistral/devstral-small-2", id: "mistral/devstral-small-2",
name: "Devstral Small 2", name: "Devstral Small 2",
@@ -1801,23 +1750,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 4000, maxTokens: 4000,
} satisfies Model<"anthropic-messages">, } 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": { "moonshotai/kimi-k2": {
id: "moonshotai/kimi-k2", id: "moonshotai/kimi-k2",
name: "Kimi K2 Instruct", name: "Kimi K2 Instruct",
@@ -2972,40 +2904,6 @@ export const VERCEL_AI_GATEWAY_MODELS = {
contextWindow: 256000, contextWindow: 256000,
maxTokens: 256000, maxTokens: 256000,
} satisfies Model<"anthropic-messages">, } 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": { "xiaomi/mimo-v2.5": {
id: "xiaomi/mimo-v2.5", id: "xiaomi/mimo-v2.5",
name: "MiMo M2.5", name: "MiMo M2.5",
@@ -3270,9 +3168,9 @@ export const VERCEL_AI_GATEWAY_MODELS = {
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 3, input: 2.1,
output: 10.25, output: 6.6,
cacheRead: 0.5, cacheRead: 0.21,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 1000000, 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 { 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 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 { function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -53,18 +53,16 @@ describe.sequential("Anthropic OAuth", () => {
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const credentials = await loginAnthropic({ const credentials = await anthropicOAuth.login({
onAuth: (info) => { notify: (event) => {
authUrl = info.url; if (event.type === "auth_url") authUrl = event.url;
}, },
onPrompt: async () => "", prompt: async (prompt) => {
onManualCodeInput: async () => { if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const url = new URL(authUrl); const url = new URL(authUrl);
const state = url.searchParams.get("state"); const state = url.searchParams.get("state");
const redirectUri = url.searchParams.get("redirect_uri"); const redirectUri = url.searchParams.get("redirect_uri");
if (!state || !redirectUri) { if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
throw new Error("Missing OAuth state or redirect_uri in auth URL");
}
return `${redirectUri}?code=manual-code&state=${state}`; return `${redirectUri}?code=manual-code&state=${state}`;
}, },
}); });
@@ -91,7 +89,12 @@ describe.sequential("Anthropic OAuth", () => {
}); });
vi.stubGlobal("fetch", fetchMock); 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.access).toBe("new-access-token");
expect(credentials.refresh).toBe("new-refresh-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 { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { Type } from "typebox"; 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 { import {
closeOpenAICodexWebSocketSessions, closeOpenAICodexWebSocketSessions,
getOpenAICodexWebSocketDebugStats, getOpenAICodexWebSocketDebugStats,
@@ -166,8 +166,9 @@ async function main(): Promise<void> {
const model = getModel("openai-codex", "gpt-5.5") as Model<"openai-codex-responses"> | undefined; 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"); if (!model) throw new Error("Model openai-codex/gpt-5.5 not found");
const modelWithMaxTokens = { ...model, maxTokens: args.maxTokens }; const modelWithMaxTokens = { ...model, maxTokens: args.maxTokens };
const authStorage = AuthStorage.create(); const modelRuntime = await ModelRuntime.create();
const apiKey = (await authStorage.getApiKey("openai-codex")) ?? (await authStorage.getApiKey("openai")); const apiKey =
(await modelRuntime.getAuth("openai-codex"))?.auth.apiKey ?? (await modelRuntime.getAuth("openai"))?.auth.apiKey;
if (!apiKey) { if (!apiKey) {
throw new Error("No OpenAI Codex API key found in coding-agent auth storage."); 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 { afterEach, describe, expect, it, vi } from "vitest";
import { getModels } from "../src/compat.ts"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
githubCopilotOAuthProvider, import { createModels } from "../src/models.ts";
loginGitHubCopilot, import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
refreshGitHubCopilotToken,
} from "../src/utils/oauth/github-copilot.ts";
function jsonResponse(body: unknown, status: number = 200): Response { function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -28,6 +26,33 @@ function getUrl(input: unknown): string {
throw new Error(`Unsupported fetch input: ${String(input)}`); 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", () => { describe("GitHub Copilot OAuth device flow", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -76,13 +101,19 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock); 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"]); expect(credentials.availableModelIds).toEqual(["gpt-4.1"]);
const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? []; const store = new InMemoryCredentialStore();
expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([ await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" }));
"gpt-4.1", 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 () => { it("reports device-code details through onDeviceCode", async () => {
@@ -127,7 +158,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn(); const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({ const loginPromise = loginGitHubCopilotForTest({
onDeviceCode, onDeviceCode,
onPrompt: async () => "", onPrompt: async () => "",
}); });
@@ -166,7 +197,7 @@ describe("GitHub Copilot OAuth device flow", () => {
const onDeviceCode = vi.fn(); const onDeviceCode = vi.fn();
await expect( await expect(
loginGitHubCopilot({ loginGitHubCopilotForTest({
onDeviceCode, onDeviceCode,
onPrompt: async () => "", onPrompt: async () => "",
}), }),
@@ -220,7 +251,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn(); const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({ const loginPromise = loginGitHubCopilotForTest({
onDeviceCode, onDeviceCode,
onPrompt: async () => "", onPrompt: async () => "",
}); });
@@ -308,7 +339,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({ const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
onPrompt: async () => "", onPrompt: async () => "",
onProgress: () => {}, onProgress: () => {},
@@ -382,7 +413,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({ const loginPromise = loginGitHubCopilotForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
onPrompt: async () => "", onPrompt: async () => "",
}); });
+5 -3
View File
@@ -51,10 +51,10 @@ function testProvider(input: {
auth: { auth: {
apiKey: { apiKey: {
name: "Test key", name: "Test key",
resolve: async ({ ctx }) => { resolve: async ({ ctx, credential }) => {
if (!input.envVar) return { auth: {} }; if (!input.envVar) return { auth: {} };
const key = await ctx.env(input.envVar); const key = credential?.key ?? (await ctx.env(input.envVar));
return key ? { auth: { apiKey: key }, source: input.envVar } : undefined; return key ? { auth: { apiKey: key }, source: credential ? "stored" : input.envVar } : undefined;
}, },
}, },
}, },
@@ -93,6 +93,8 @@ describe("ImagesModels", () => {
const model = models.getModel("p1", "model-a")!; const model = models.getModel("p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key"); 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); const result = await models.generateImages(model, context);
expect(result.stopReason).toBe("stop"); expect(result.stopReason).toBe("stop");
+237 -27
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.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 type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions, Usage } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
@@ -56,7 +57,7 @@ function testProvider(input: {
models?: Model<Api>[]; models?: Model<Api>[];
auth?: ProviderAuth; auth?: ProviderAuth;
getModels?: () => readonly Model<Api>[]; getModels?: () => readonly Model<Api>[];
refreshModels?: () => Promise<void>; refreshModels?: Provider["refreshModels"];
calls?: ProviderCall[]; calls?: ProviderCall[];
}): Provider { }): Provider {
const models = input.models ?? [testModel(input.id, "model-a")]; const models = input.models ?? [testModel(input.id, "model-a")];
@@ -106,6 +107,22 @@ function testOAuth(overrides?: Partial<OAuthAuth>): OAuthAuth {
} }
describe("Models runtime", () => { 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", () => { it("applies request-wide pricing tiers above the configured input threshold", () => {
const model = testModel("openai", "gpt-5.6-sol"); const model = testModel("openai", "gpt-5.6-sol");
model.cost = { model.cost = {
@@ -199,7 +216,7 @@ describe("Models runtime", () => {
expect(() => models.getProvider("broken")?.getModels()).toThrow("boom"); 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 list = [testModel("dyn", "before")];
let refreshes = 0; let refreshes = 0;
const models = createModels(); const models = createModels();
@@ -216,17 +233,12 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "static", models: [testModel("static", "s1")] })); models.setProvider(testProvider({ id: "static", models: [testModel("static", "s1")] }));
expect(models.getModel("dyn", "before")).toBeDefined(); 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(refreshes).toBe(1);
expect(models.getModel("dyn", "after")).toBeDefined(); expect(models.getModel("dyn", "after")).toBeDefined();
expect(models.getModel("dyn", "before")).toBeUndefined(); 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( models.setProvider(
testProvider({ testProvider({
id: "flaky", id: "flaky",
@@ -235,9 +247,120 @@ describe("Models runtime", () => {
}, },
}), }),
); );
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" }); const second = await models.refresh();
// refresh-all swallows the same failure expect(refreshes).toBe(2);
await expect(models.refresh()).resolves.toBeUndefined(); 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 () => { 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() } })); models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key"), oauth: testOAuth() } }));
const model = testModel("p1", "model-a"); 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))?.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 // stored oauth credential (persisted via the single write path): beats ambient env
await credentials.modify("p1", async () => ({ await credentials.modify("p1", async () => ({
@@ -256,17 +381,69 @@ describe("Models runtime", () => {
refresh: "r", refresh: "r",
expires: Date.now() + 100000, 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?.auth.apiKey).toBe("oauth-token");
expect(resolution?.source).toBe("OAuth"); expect(resolution?.source).toBe("OAuth");
// stored api-key credential resolves through apiKey auth, beats env // stored api-key credential resolves through apiKey auth, beats env
await credentials.modify("p1", async () => ({ type: "api_key", key: "stored-key" })); 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?.auth.apiKey).toBe("stored-key");
expect(apiKeyResolution?.source).toBe("stored"); 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 () => { it("a stored credential without a matching handler blocks ambient fallback", async () => {
const credentials = new InMemoryCredentialStore(); const credentials = new InMemoryCredentialStore();
const models = createModels({ credentials }); const models = createModels({ credentials });
@@ -274,7 +451,7 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } })); models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } }));
await credentials.modify("p1", async () => ({ type: "oauth", access: "a", refresh: "r", expires: 0 })); 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 () => { it("refreshes expired oauth credentials and persists the rotated credential", async () => {
@@ -291,7 +468,7 @@ describe("Models runtime", () => {
expires: 0, 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(resolution?.auth.apiKey).toBe("new-token");
expect(((await credentials.read("p1")) as { access: string }).access).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 } })); models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 })); 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 // credential preserved for retry / re-login
expect(((await credentials.read("p1")) as { access: string }).access).toBe("old"); 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 } })); models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
const model = testModel("p1", "model-a"); 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(refreshes).toBe(1);
expect(a?.auth.apiKey).toBe("new-1"); expect(a?.auth.apiKey).toBe("new-1");
expect(b?.auth.apiKey).toBe("new-1"); expect(b?.auth.apiKey).toBe("new-1");
@@ -339,6 +516,7 @@ describe("Models runtime", () => {
const base = new InMemoryCredentialStore(); const base = new InMemoryCredentialStore();
const credentials: CredentialStore = { const credentials: CredentialStore = {
read: (pid) => base.read(pid), read: (pid) => base.read(pid),
list: () => base.list(),
modify: (pid, fn) => { modify: (pid, fn) => {
modifies++; modifies++;
return base.modify(pid, fn); return base.modify(pid, fn);
@@ -354,7 +532,7 @@ describe("Models runtime", () => {
const models = createModels({ credentials }); const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } })); 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); expect(modifies).toBe(0);
}); });
@@ -364,16 +542,18 @@ describe("Models runtime", () => {
read: async () => { read: async () => {
throw new Error("disk on fire"); throw new Error("disk on fire");
}, },
list: async () => [],
modify: async () => undefined, modify: async () => undefined,
delete: async () => {}, delete: async () => {},
}; };
const models = createModels({ credentials: readFailing }); const models = createModels({ credentials: readFailing });
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } })); 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 // modify failure during refresh
const modifyFailing: CredentialStore = { const modifyFailing: CredentialStore = {
read: async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }), read: async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }),
list: async () => [{ providerId: "p1", type: "oauth" }],
modify: async () => { modify: async () => {
throw new Error("disk on fire"); throw new Error("disk on fire");
}, },
@@ -381,7 +561,7 @@ describe("Models runtime", () => {
}; };
const oauthModels = createModels({ credentials: modifyFailing }); const oauthModels = createModels({ credentials: modifyFailing });
oauthModels.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } })); 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 () => { it("wraps api-key auth failures in ModelsError", async () => {
@@ -393,7 +573,7 @@ describe("Models runtime", () => {
}; };
const models = createModels(); const models = createModels();
models.setProvider(testProvider({ id: "p1", auth: { apiKey: failing } })); 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 () => { it("uses explicit request api key and env during provider auth resolution", async () => {
@@ -427,7 +607,7 @@ describe("Models runtime", () => {
resolve: async () => ({ resolve: async () => ({
auth: { auth: {
apiKey: "resolved-key", 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", baseUrl: "https://auth.test/v1",
}, },
}), }),
@@ -438,12 +618,12 @@ describe("Models runtime", () => {
const result = await models.completeSimple(model, context, { const result = await models.completeSimple(model, context, {
apiKey: "explicit-key", apiKey: "explicit-key",
headers: { "x-b": "explicit" }, headers: { authorization: "Explicit token", "x-b": "explicit" },
}); });
expect(result.stopReason).toBe("stop"); expect(result.stopReason).toBe("stop");
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0].options?.apiKey).toBe("explicit-key"); 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"); expect(calls[0].model.baseUrl).toBe("https://auth.test/v1");
// without explicit options, resolved auth applies // without explicit options, resolved auth applies
@@ -452,6 +632,36 @@ describe("Models runtime", () => {
expect(calls[1].options?.apiKey).toBe("resolved-key"); 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 () => { it("produces an error stream for unknown providers instead of throwing", async () => {
const models = createModels(); const models = createModels();
const result = await models.completeSimple(testModel("ghost", "model-a"), context); 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 { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; 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 { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.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 { function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
} }
describe.sequential("OAuthAuth adapters", () => { 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(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -104,7 +110,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
models.setProvider(anthropicProvider()); models.setProvider(anthropicProvider());
const model = models.getModels("anthropic")[0]; 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?.auth.apiKey).toBe("oauth-access-token");
expect(result?.source).toBe("OAuth"); expect(result?.source).toBe("OAuth");
}); });
@@ -122,7 +128,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
models.setProvider(githubCopilotProvider()); models.setProvider(githubCopilotProvider());
const model = models.getModels("github-copilot")[0]; 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.apiKey).toBe(access);
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com"); 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 { 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", () => { describe("OAuth device-code polling", () => {
afterEach(() => { afterEach(() => {
+11 -21
View File
@@ -8,8 +8,8 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { getOAuthApiKey } from "../src/utils/oauth/index.ts"; import type { OAuthCredentials } from "../src/auth/types.ts";
import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.ts"; import { builtinProviders } from "../src/providers/all.ts";
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json"); 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") { if (entry.type === "oauth") {
// Build OAuthCredentials record for getOAuthApiKey const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
const oauthCredentials: Record<string, OAuthCredentials> = {}; if (!oauth) return undefined;
for (const [key, value] of Object.entries(storage)) { let credential = entry;
if (value.type === "oauth") {
const { type: _, ...creds } = value;
oauthCredentials[key] = creds;
}
}
let result: { newCredentials: OAuthCredentials; apiKey: string } | null = null;
try { try {
result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials); if (Date.now() >= credential.expires) credential = await oauth.refresh(credential);
} catch (e) { } catch (error) {
console.log(JSON.stringify(e)); console.log(JSON.stringify(error));
return undefined;
} }
if (!result) return undefined; storage[provider] = credential;
// Save refreshed credentials back to auth.json
storage[provider] = { type: "oauth", ...result.newCredentials };
saveAuthStorage(storage); saveAuthStorage(storage);
return (await oauth.toAuth(credential)).apiKey;
return result.apiKey;
} }
return undefined; return undefined;
+56 -28
View File
@@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
loginOpenAICodexDeviceCode,
openaiCodexOAuthProvider,
refreshOpenAICodexToken,
} from "../src/utils/oauth/openai-codex.ts";
function jsonResponse(body: unknown, status: number = 200): Response { function jsonResponse(body: unknown, status: number = 200): Response {
return new Response(JSON.stringify(body), { 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", () => { describe("OpenAI Codex OAuth", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -125,7 +145,7 @@ describe("OpenAI Codex OAuth", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const credentialsPromise = loginOpenAICodexDeviceCode({ const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: (info) => deviceInfos.push(info), onDeviceCode: (info) => deviceInfos.push(info),
}); });
@@ -159,7 +179,7 @@ describe("OpenAI Codex OAuth", () => {
const accessToken = createAccessToken("account-456"); const accessToken = createAccessToken("account-456");
const selectPrompts: Array<{ const selectPrompts: Array<{
message: string; message: string;
options: Array<{ id: string; label: string }>; options: readonly { id: string; label: string }[];
}> = []; }> = [];
const deviceInfos: Array<{ const deviceInfos: Array<{
userCode: string; userCode: string;
@@ -199,20 +219,22 @@ describe("OpenAI Codex OAuth", () => {
); );
await expect( await expect(
openaiCodexOAuthProvider.login({ openaiCodexOAuth.login({
onAuth: () => { prompt: async (prompt) => {
throw new Error("Browser login should not start"); if (prompt.type !== "select") throw new Error("Text prompt should not be used");
},
onDeviceCode: (info) => deviceInfos.push(info),
onPrompt: async () => {
throw new Error("Prompt should not be used");
},
onSelect: async (prompt) => {
selectPrompts.push(prompt); selectPrompts.push(prompt);
return "device_code"; 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({ ).resolves.toMatchObject({
type: "oauth",
access: accessToken, access: accessToken,
refresh: "refresh-token", refresh: "refresh-token",
accountId: "account-456", accountId: "account-456",
@@ -220,6 +242,7 @@ describe("OpenAI Codex OAuth", () => {
expect(selectPrompts).toEqual([ expect(selectPrompts).toEqual([
{ {
type: "select",
message: "Select OpenAI Codex login method:", message: "Select OpenAI Codex login method:",
options: [ options: [
{ id: "browser", label: "Browser login (default)" }, { 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 () => { it("cancels when OpenAI Codex login method selection is cancelled", async () => {
await expect( await expect(
openaiCodexOAuthProvider.login({ openaiCodexOAuth.login({
onAuth: () => {}, prompt: async () => {
onDeviceCode: () => {}, throw new Error("Login cancelled");
onPrompt: async () => "", },
onSelect: async () => undefined, notify: () => {},
}), }),
).rejects.toThrow("Login cancelled"); ).rejects.toThrow("Login cancelled");
}); });
@@ -273,7 +296,7 @@ describe("OpenAI Codex OAuth", () => {
}), }),
); );
const credentialsPromise = loginOpenAICodexDeviceCode({ const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
signal: controller.signal, signal: controller.signal,
}); });
@@ -317,7 +340,7 @@ describe("OpenAI Codex OAuth", () => {
}), }),
); );
const credentialsPromise = loginOpenAICodexDeviceCode({ const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
}); });
const rejectionPromise = credentialsPromise.then( const rejectionPromise = credentialsPromise.then(
@@ -380,7 +403,7 @@ describe("OpenAI Codex OAuth", () => {
}), }),
); );
const credentialsPromise = loginOpenAICodexDeviceCode({ const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
}); });
@@ -418,7 +441,7 @@ describe("OpenAI Codex OAuth", () => {
); );
await expect( await expect(
loginOpenAICodexDeviceCode({ loginOpenAICodexDeviceCodeForTest({
onDeviceCode: () => {}, onDeviceCode: () => {},
}), }),
).rejects.toThrow( ).rejects.toThrow(
@@ -443,9 +466,14 @@ describe("OpenAI Codex OAuth", () => {
}), }),
); );
await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow( await expect(
/OpenAI Codex token refresh failed \(401\).*Could not validate your token/, 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(); expect(consoleError).not.toHaveBeenCalled();
}); });
}); });
+101 -55
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { envApiKeyAuth } from "../src/auth/helpers.ts"; 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 { createModels, createProvider } from "../src/models.ts";
import { InMemoryModelsStore } from "../src/models-store.ts";
import { builtinModels, builtinProviders } from "../src/providers/all.ts"; import { builtinModels, builtinProviders } from "../src/providers/all.ts";
import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts"; import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts";
@@ -34,10 +35,11 @@ describe("builtin providers", () => {
const all = models.getModels(); const all = models.getModels();
expect(all.length).toBeGreaterThan(500); 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) { for (const provider of providers) {
const list = models.getModels(provider.id); 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); expect(list.every((m) => m.provider === provider.id)).toBe(true);
} }
}); });
@@ -49,22 +51,41 @@ describe("builtin providers", () => {
models.setProvider(anthropicProvider()); models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5")!; 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?.auth.apiKey).toBe("oauth-token");
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN"); expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
}); });
it("prompts for and stores a Bedrock API key", async () => { it("runs provider-owned Bedrock bearer token and AWS profile login flows", async () => {
const provider = amazonBedrockProvider(); const auth = amazonBedrockProvider().auth.apiKey!;
const credential = await provider.auth.apiKey?.login?.({ const bearerAnswers = ["bearer-token", "bedrock-token"];
prompt: async (prompt) => { expect(
expect(prompt).toEqual({ type: "secret", message: "Enter Bedrock API key" }); await auth.login?.({
return "bedrock-api-key"; prompt: async () => bearerAnswers.shift()!,
}, notify: () => {},
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 () => { it("reports bedrock as configured from ambient AWS credentials without an api key", async () => {
@@ -72,50 +93,27 @@ describe("builtin providers", () => {
models.setProvider(amazonBedrockProvider()); models.setProvider(amazonBedrockProvider());
const model = models.getModels("amazon-bedrock")[0]; 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?.auth).toEqual({});
expect(result?.source).toBe("AWS_PROFILE"); expect(result?.source).toBe("AWS_PROFILE");
const unconfigured = createModels({ authContext: fakeAuthContext({}) }); const unconfigured = createModels({ authContext: fakeAuthContext({}) });
unconfigured.setProvider(amazonBedrockProvider()); 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 () => { it("requires Cloudflare Workers AI account config and returns scoped env", async () => {
const missingAccount = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key" }) }); const missingAccount = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key" }) });
missingAccount.setProvider(cloudflareWorkersAIProvider()); missingAccount.setProvider(cloudflareWorkersAIProvider());
const model = missingAccount.getModels("cloudflare-workers-ai")[0]; const model = missingAccount.getModels("cloudflare-workers-ai")[0];
expect(await missingAccount.getAuth(model)).toBeUndefined(); expect(await missingAccount.getAuth(model.provider)).toBeUndefined();
const configured = createModels({ const configured = createModels({
authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }), authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }),
}); });
configured.setProvider(cloudflareWorkersAIProvider()); configured.setProvider(cloudflareWorkersAIProvider());
const result = await configured.getAuth(model); const result = await configured.getAuth(model.provider);
expect(result?.auth).toEqual({ expect(result?.auth).toEqual({ apiKey: "cf-key" });
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",
});
expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" }); expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" });
}); });
@@ -125,7 +123,7 @@ describe("builtin providers", () => {
}); });
missingGateway.setProvider(cloudflareAIGatewayProvider()); missingGateway.setProvider(cloudflareAIGatewayProvider());
const model = missingGateway.getModels("cloudflare-ai-gateway")[0]; const model = missingGateway.getModels("cloudflare-ai-gateway")[0];
expect(await missingGateway.getAuth(model)).toBeUndefined(); expect(await missingGateway.getAuth(model.provider)).toBeUndefined();
const configured = createModels({ const configured = createModels({
authContext: fakeAuthContext({ authContext: fakeAuthContext({
@@ -135,14 +133,13 @@ describe("builtin providers", () => {
}), }),
}); });
configured.setProvider(cloudflareAIGatewayProvider()); configured.setProvider(cloudflareAIGatewayProvider());
const result = await configured.getAuth(model); const result = await configured.getAuth(model.provider);
expect(result?.auth).toEqual({ expect(result?.auth).toEqual({
headers: { headers: {
"cf-aig-authorization": "Bearer cf-key", "cf-aig-authorization": "Bearer cf-key",
Authorization: null, Authorization: null,
"x-api-key": null, "x-api-key": null,
}, },
baseUrl: "https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/anthropic",
}); });
expect(result?.env).toEqual({ expect(result?.env).toEqual({
CLOUDFLARE_ACCOUNT_ID: "account-id", 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 () => { it("resolves vertex via ADC file plus project and location", async () => {
const adc = "~/.config/gcloud/application_default_credentials.json"; const adc = "~/.config/gcloud/application_default_credentials.json";
const configured = createModels({ const configured = createModels({
@@ -158,40 +196,38 @@ describe("builtin providers", () => {
configured.setProvider(googleVertexProvider()); configured.setProvider(googleVertexProvider());
const model = configured.getModels("google-vertex")[0]; 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?.auth).toEqual({});
expect(result?.source).toContain("application default"); expect(result?.source).toContain("application default");
// ADC without project/location is not configured // ADC without project/location is not configured
const partial = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj" }, [adc]) }); const partial = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj" }, [adc]) });
partial.setProvider(googleVertexProvider()); partial.setProvider(googleVertexProvider());
expect(await partial.getAuth(model)).toBeUndefined(); expect(await partial.getAuth(model.provider)).toBeUndefined();
// explicit key wins over ADC // explicit key wins over ADC
const keyed = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_API_KEY: "vertex-key" }) }); const keyed = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_API_KEY: "vertex-key" }) });
keyed.setProvider(googleVertexProvider()); 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", () => { describe("envApiKeyAuth", () => {
it("prefers the stored credential key and falls back through env vars in order", async () => { 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 auth = envApiKeyAuth("Test key", ["FIRST_KEY", "SECOND_KEY"]);
const model = { provider: "p1" } as Model<Api>;
const stored = await auth.resolve({ const stored = await auth.resolve({
model,
ctx: fakeAuthContext({ FIRST_KEY: "env" }), ctx: fakeAuthContext({ FIRST_KEY: "env" }),
credential: { type: "api_key", key: "stored" }, credential: { type: "api_key", key: "stored" },
}); });
expect(stored?.auth.apiKey).toBe("stored"); expect(stored?.auth.apiKey).toBe("stored");
expect(stored?.source).toBe("stored credential"); 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?.auth.apiKey).toBe("second");
expect(second?.source).toBe("SECOND_KEY"); 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 () => { it("login prompts for a secret and returns an api-key credential", async () => {
@@ -311,7 +347,7 @@ describe("createProvider", () => {
id: "dynamic", id: "dynamic",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [], models: [],
refreshModels: async () => { fetchModels: async () => {
fetches++; fetches++;
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
return [testModel("api-a", "listed")]; return [testModel("api-a", "listed")];
@@ -319,13 +355,23 @@ describe("createProvider", () => {
api: recordingStreams("a", []), 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([]); 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(fetches).toBe(1);
expect(provider.getModels().map((m) => m.id)).toEqual(["listed"]); expect(provider.getModels().map((m) => m.id)).toEqual(["listed"]);
// a later refresh fetches again // a later refresh fetches again
await provider.refreshModels?.(); await provider.refreshModels?.(refreshContext);
expect(fetches).toBe(2); expect(fetches).toBe(2);
}); });
}); });
+1 -1
View File
@@ -21,7 +21,7 @@ models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5"); const model = models.getModel("anthropic", "claude-haiku-4-5");
if (!model) throw new Error("model not found"); 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(`model: ${model.provider}/${model.id}`);
console.log(`auth: ${auth ? `configured via ${auth.source}` : "not configured"}\n`); console.log(`auth: ${auth ? `configured via ${auth.source}` : "not configured"}\n`);
if (!auth) process.exit(1); if (!auth) process.exit(1);
+14
View File
@@ -4,6 +4,11 @@
### Breaking Changes ### 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)). - 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 ### New Features
@@ -14,10 +19,19 @@
### Added ### 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 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 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`. - 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
- 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)). - 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 ### SDK
```typescript ```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 modelRuntime = await ModelRuntime.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage, modelRuntime,
modelRegistry,
}); });
await session.prompt("What files are in the current directory?"); 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 ## OAuth Support
Add OAuth/SSO authentication that integrates with `/login`: Add OAuth/SSO authentication that integrates with `/login`:
@@ -312,15 +314,6 @@ pi.registerProvider("corporate-ai", {
getApiKey(credentials: OAuthCredentials): string { getApiKey(credentials: OAuthCredentials): string {
return credentials.access; 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 ### OAuthLoginCallbacks
The `callbacks` object provides three ways to authenticate: The `callbacks` object provides UI-neutral interactions for the provider-owned flow:
```typescript ```typescript
interface OAuthLoginCallbacks { interface OAuthLoginCallbacks {
@@ -345,6 +338,9 @@ interface OAuthLoginCallbacks {
expiresInSeconds?: number; expiresInSeconds?: number;
}): void; }): void;
// Show transient progress
onProgress?(message: string): void;
// Prompt user for input (for manual token entry) // Prompt user for input (for manual token entry)
onPrompt(params: { message: string }): Promise<string>; onPrompt(params: { message: string }): Promise<string>;
@@ -660,7 +656,6 @@ interface ProviderConfig {
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>; login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>; refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey(credentials: OAuthCredentials): string; 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 | | `baseUrl` | API endpoint URL |
| `api` | API type (see above) | | `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`. | | `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) | | `headers` | Custom headers (see value resolution below) |
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically | | `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
| `models` | Array of model configurations | | `models` | Array of model configurations |
+7 -1
View File
@@ -1,6 +1,6 @@
# Providers # 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 ## Table of Contents
@@ -18,6 +18,7 @@ Use `/login` in interactive mode, then select a provider:
- ChatGPT Plus/Pro (Codex) - ChatGPT Plus/Pro (Codex)
- Claude Pro/Max - Claude Pro/Max
- GitHub Copilot - GitHub Copilot
- Radius
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. 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 - 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" - 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 ## API Keys
### Environment Variables or Auth File ### Environment Variables or Auth File
@@ -68,6 +73,7 @@ pi
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` | | ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` |
| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` | | OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
| Radius | `RADIUS_API_KEY` | `radius` |
| Hugging Face | `HF_TOKEN` | `huggingface` | | Hugging Face | `HF_TOKEN` | `huggingface` |
| Fireworks | `FIREWORKS_API_KEY` | `fireworks` | | Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
| Together AI | `TOGETHER_API_KEY` | `together` | | 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 ## Quick Start
```typescript ```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent"; import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
// Set up credential storage and model registry
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage, modelRuntime,
modelRegistry,
}); });
session.subscribe((event) => { session.subscribe((event) => {
@@ -369,10 +365,9 @@ When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control
```typescript ```typescript
import { getModel } from "@earendil-works/pi-ai"; 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 modelRuntime = await ModelRuntime.create();
const modelRegistry = ModelRegistry.create(authStorage);
// Find specific built-in model (doesn't check if API key exists) // Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5"); 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 // Find any model by provider/id, including custom models from models.json
// (doesn't check if API key exists) // (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 // Get only models that have valid authentication configured
const available = await modelRegistry.getAvailable(); const available = await modelRuntime.getAvailable();
const { session } = await createAgentSession({ const { session } = await createAgentSession({
model: opus, model: opus,
@@ -395,8 +390,7 @@ const { session } = await createAgentSession({
{ model: haiku, thinkingLevel: "off" }, { model: haiku, thinkingLevel: "off" },
], ],
authStorage, modelRuntime,
modelRegistry,
}); });
``` ```
@@ -415,14 +409,14 @@ import {
const cliModel = resolveCliModel({ const cliModel = resolveCliModel({
cliModel: "anthropic/claude-opus-4-5:high", cliModel: "anthropic/claude-opus-4-5:high",
modelRegistry, modelRuntime,
}); });
if (cliModel.error) throw new Error(cliModel.error); if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning); if (cliModel.warning) console.warn(cliModel.warning);
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics( const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
["anthropic/*:high", "gpt-5"], ["anthropic/*:high", "gpt-5"],
modelRegistry, modelRuntime,
); );
for (const diagnostic of diagnostics) { for (const diagnostic of diagnostics) {
console.warn(diagnostic.message); console.warn(diagnostic.message);
@@ -435,40 +429,41 @@ for (const diagnostic of diagnostics) {
### API Keys and OAuth ### API Keys and OAuth
API key resolution priority (handled by AuthStorage): Authentication resolution priority (handled by `ModelRuntime`):
1. Runtime overrides (via `setRuntimeApiKey`, not persisted) 1. Runtime overrides (via `setRuntimeApiKey`, not persisted)
2. Stored credentials in `auth.json` (API keys or OAuth tokens) 2. Stored credentials in `auth.json` (API keys or OAuth tokens)
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) 3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.json`) 4. Fallback resolver (for custom provider keys from `models.json`)
```typescript ```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 // Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = AuthStorage.create(); const modelRuntime = await ModelRuntime.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({ // Provider-owned auth methods and current status
sessionManager: SessionManager.inMemory(), for (const provider of modelRuntime.getProviders()) {
authStorage, const status = await modelRuntime.checkAuth(provider.id);
modelRegistry, console.log(provider.name, provider.auth, status);
}); }
// Runtime API key override (not persisted to disk) // 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 // Custom credential and model locations
const customAuth = AuthStorage.create("/my/app/auth.json"); const customRuntime = await ModelRuntime.create({
const customRegistry = ModelRegistry.create(customAuth, "/my/app/models.json"); authPath: "/my/app/auth.json",
modelsPath: "/my/app/models.json",
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuth,
modelRegistry: customRegistry,
}); });
// No custom models.json (built-in models only) // Or inject any pi-ai CredentialStore
const simpleRegistry = ModelRegistry.inMemory(authStorage); 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) > 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 { getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox"; import { Type } from "typebox";
import { import {
AuthStorage,
createAgentSession, createAgentSession,
DefaultResourceLoader, DefaultResourceLoader,
defineTool, defineTool,
ModelRegistry, ModelRuntime,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
// Set up auth storage (custom location) const modelRuntime = await ModelRuntime.create({
const authStorage = AuthStorage.create("/custom/agent/auth.json"); authPath: "/custom/agent/auth.json",
modelsPath: "/custom/agent/models.json",
// Runtime API key override (not persisted) });
if (process.env.MY_KEY) { 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 // Inline tool
const statusTool = defineTool({ const statusTool = defineTool({
name: "status", name: "status",
@@ -982,8 +973,7 @@ const { session } = await createAgentSession({
model, model,
thinkingLevel: "off", thinkingLevel: "off",
authStorage, modelRuntime,
modelRegistry,
tools: ["read", "bash", "status"], tools: ["read", "bash", "status"],
customTools: [statusTool], customTools: [statusTool],
@@ -1149,8 +1139,8 @@ createAgentSessionRuntime
AgentSessionRuntime AgentSessionRuntime
// Auth and Models // Auth and Models
AuthStorage ModelRuntime // implements pi-ai Models and owns credential storage
ModelRegistry ModelRegistry // synchronous extension compatibility facade
resolveCliModel resolveCliModel
resolveModelScopeWithDiagnostics resolveModelScopeWithDiagnostics
@@ -46,7 +46,7 @@ import {
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; 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); const decode = (s: string) => atob(s);
@@ -5,11 +5,9 @@
*/ */
import { getModel } from "@earendil-works/pi-ai/compat"; 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 modelRuntime = await ModelRuntime.create();
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
// Option 1: Find a specific built-in model by provider/id // Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5"); 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) // 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) { if (customModel) {
console.log(`Found custom model: ${customModel.provider}/${customModel.id}`); console.log(`Found custom model: ${customModel.provider}/${customModel.id}`);
} }
// Option 3: Pick from available models (have valid API keys) // Option 3: Pick from available models (have valid API keys)
const available = await modelRegistry.getAvailable(); const available = await modelRuntime.getAvailable();
console.log( console.log(
"Available models:", "Available models:",
available.map((m) => `${m.provider}/${m.id}`), available.map((m) => `${m.provider}/${m.id}`),
@@ -34,8 +32,7 @@ if (available.length > 0) {
const { session } = await createAgentSession({ const { session } = await createAgentSession({
model: available[0], model: available[0],
thinkingLevel: "medium", // off, low, medium, high thinkingLevel: "medium", // off, low, medium, high
authStorage, modelRuntime,
modelRegistry,
}); });
try { try {
@@ -1,52 +1,34 @@
/** /**
* API Keys and OAuth * 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"; import { createAgentSession, ModelRuntime, 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);
const modelRuntime = await ModelRuntime.create();
const { session: defaultAuthSession } = await createAgentSession({ const { session: defaultAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage, modelRuntime,
modelRegistry,
}); });
console.log("Session with default auth storage and model registry"); console.log("Session with default model runtime");
defaultAuthSession.dispose(); defaultAuthSession.dispose();
// Custom auth storage location const customRuntime = await ModelRuntime.create({
const customAuthStorage = AuthStorage.create("/tmp/my-app/auth.json"); authPath: "/tmp/my-app/auth.json",
const customModelRegistry = ModelRegistry.create(customAuthStorage, "/tmp/my-app/models.json"); modelsPath: "/tmp/my-app/models.json",
});
const { session: customAuthSession } = await createAgentSession({ const { session: customAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage: customAuthStorage, modelRuntime: customRuntime,
modelRegistry: customModelRegistry,
}); });
console.log("Session with custom auth storage location"); console.log("Session with custom auth and models locations");
customAuthSession.dispose(); customAuthSession.dispose();
// Runtime API key override (not persisted to disk) modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
const { session: runtimeKeySession } = await createAgentSession({ const { session: runtimeKeySession } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage, modelRuntime,
modelRegistry,
}); });
console.log("Session with runtime API key override"); console.log("Session with runtime API key override");
runtimeKeySession.dispose(); 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 { getModel } from "@earendil-works/pi-ai/compat";
import { import {
AuthStorage,
createAgentSession, createAgentSession,
createExtensionRuntime, createExtensionRuntime,
ModelRegistry, ModelRuntime,
type ResourceLoader, type ResourceLoader,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
// Custom auth storage location const modelRuntime = await ModelRuntime.create({
const authStorage = AuthStorage.create("/tmp/my-agent/auth.json"); authPath: "/tmp/my-agent/auth.json",
modelsPath: "/tmp/my-agent/models.json",
// Runtime API key override (not persisted) });
if (process.env.MY_ANTHROPIC_KEY) { 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"); const model = getModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Model not found"); if (!model) throw new Error("Model not found");
@@ -55,8 +51,7 @@ const { session } = await createAgentSession({
agentDir: "/tmp/my-agent", agentDir: "/tmp/my-agent",
model, model,
thinkingLevel: "off", thinkingLevel: "off",
authStorage, modelRuntime,
modelRegistry,
resourceLoader, resourceLoader,
tools: ["read", "bash"], tools: ["read", "bash"],
sessionManager: SessionManager.inMemory(cwd), sessionManager: SessionManager.inMemory(cwd),
+14 -18
View File
@@ -34,46 +34,44 @@ npx tsx examples/sdk/01-minimal.ts
```typescript ```typescript
import { getModel } from "@earendil-works/pi-ai"; import { getModel } from "@earendil-works/pi-ai";
import { import {
AuthStorage,
createAgentSession, createAgentSession,
DefaultResourceLoader, DefaultResourceLoader,
ModelRegistry, ModelRuntime,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
// Auth and models setup const modelRuntime = await ModelRuntime.create();
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
// Minimal // Minimal
const { session } = await createAgentSession({ authStorage, modelRegistry }); const { session } = await createAgentSession({ modelRuntime });
// Custom model // Custom model
const model = getModel("anthropic", "claude-opus-4-5"); 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 // Modify prompt
const loader = new DefaultResourceLoader({ const loader = new DefaultResourceLoader({
systemPromptOverride: (base) => `${base}\n\nBe concise.`, systemPromptOverride: (base) => `${base}\n\nBe concise.`,
}); });
await loader.reload(); await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry }); const { session } = await createAgentSession({ resourceLoader: loader, modelRuntime });
// Read-only // 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 // In-memory
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
authStorage, modelRuntime,
modelRegistry,
}); });
// Full control // Full control
const customAuth = AuthStorage.create("/my/app/auth.json"); const customRuntime = await ModelRuntime.create({
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!); authPath: "/my/app/auth.json",
const customRegistry = ModelRegistry.create(customAuth); modelsPath: "/my/app/models.json",
});
customRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const resourceLoader = new DefaultResourceLoader({ const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () => "You are helpful.", systemPromptOverride: () => "You are helpful.",
@@ -86,8 +84,7 @@ await resourceLoader.reload();
const { session } = await createAgentSession({ const { session } = await createAgentSession({
model, model,
authStorage: customAuth, modelRuntime: customRuntime,
modelRegistry: customRegistry,
resourceLoader, resourceLoader,
tools: ["read", "bash", "my_tool"], tools: ["read", "bash", "my_tool"],
customTools: [myTool], customTools: [myTool],
@@ -108,8 +105,7 @@ await session.prompt("Hello");
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `authStorage` | `AuthStorage.create()` | Credential storage | | `modelRuntime` | Runtime using `agentDir/auth.json` and `models.json` | Canonical model and authentication runtime |
| `modelRegistry` | `ModelRegistry.create(authStorage)` | Model registry |
| `cwd` | `process.cwd()` | Working directory | | `cwd` | `process.cwd()` | Working directory |
| `agentDir` | `~/.pi/agent` | Config directory | | `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use | | `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 { fuzzyFilter } from "@earendil-works/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts"; 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") * 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 * List available models, optionally filtered by search pattern
*/ */
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> { export async function listModels(modelRuntime: ModelRuntime, searchPattern?: string): Promise<void> {
const loadError = modelRegistry.getError(); const loadError = modelRuntime.getError();
if (loadError) { if (loadError) {
console.error(chalk.yellow(`Warning: errors loading models.json:\n${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) { if (models.length === 0) {
console.log(formatNoModelsAvailableMessage()); 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 type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts"; import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts"; import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts"; import { ModelRuntime } from "./model-runtime.ts";
import { import {
DefaultResourceLoader, DefaultResourceLoader,
type DefaultResourceLoaderOptions, type DefaultResourceLoaderOptions,
@@ -38,9 +37,8 @@ export interface AgentSessionRuntimeDiagnostic {
export interface CreateAgentSessionServicesOptions { export interface CreateAgentSessionServicesOptions {
cwd: string; cwd: string;
agentDir?: string; agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager; settingsManager?: SettingsManager;
modelRegistry?: ModelRegistry; modelRuntime?: ModelRuntime;
extensionFlagValues?: Map<string, boolean | string>; extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">; resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions; resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
@@ -74,9 +72,8 @@ export interface CreateAgentSessionFromServicesOptions {
export interface AgentSessionServices { export interface AgentSessionServices {
cwd: string; cwd: string;
agentDir: string; agentDir: string;
authStorage: AuthStorage; modelRuntime: ModelRuntime;
settingsManager: SettingsManager; settingsManager: SettingsManager;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader; resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[]; diagnostics: AgentSessionRuntimeDiagnostic[];
} }
@@ -139,9 +136,13 @@ export async function createAgentSessionServices(
): Promise<AgentSessionServices> { ): Promise<AgentSessionServices> {
const cwd = resolvePath(options.cwd); const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); 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 settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader = new DefaultResourceLoader({ const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}), ...(options.resourceLoaderOptions ?? {}),
cwd, cwd,
@@ -154,7 +155,7 @@ export async function createAgentSessionServices(
const extensionsResult = resourceLoader.getExtensions(); const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try { try {
modelRegistry.registerProvider(name, config); modelRuntime.registerProvider(name, config);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
diagnostics.push({ diagnostics.push({
@@ -164,14 +165,14 @@ export async function createAgentSessionServices(
} }
} }
extensionsResult.runtime.pendingProviderRegistrations = []; extensionsResult.runtime.pendingProviderRegistrations = [];
await modelRuntime.refresh({ allowNetwork: false });
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return { return {
cwd, cwd,
agentDir, agentDir,
authStorage, modelRuntime,
settingsManager, settingsManager,
modelRegistry,
resourceLoader, resourceLoader,
diagnostics, diagnostics,
}; };
@@ -190,9 +191,8 @@ export async function createAgentSessionFromServices(
return createAgentSession({ return createAgentSession({
cwd: options.services.cwd, cwd: options.services.cwd,
agentDir: options.services.agentDir, agentDir: options.services.agentDir,
authStorage: options.services.authStorage, modelRuntime: options.services.modelRuntime,
settingsManager: options.services.settingsManager, settingsManager: options.services.settingsManager,
modelRegistry: options.services.modelRegistry,
resourceLoader: options.services.resourceLoader, resourceLoader: options.services.resourceLoader,
sessionManager: options.sessionManager, sessionManager: options.sessionManager,
model: options.model, model: options.model,
+68 -35
View File
@@ -24,7 +24,15 @@ import type {
PrepareNextTurnContext, PrepareNextTurnContext,
ThinkingLevel, ThinkingLevel,
} from "@earendil-works/pi-agent-core"; } 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 { import {
clampThinkingLevel, clampThinkingLevel,
cleanupSessionResources, cleanupSessionResources,
@@ -83,7 +91,8 @@ import {
} from "./extensions/index.ts"; } from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts"; import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { BashExecutionMessage, CustomMessage } from "./messages.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 { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
@@ -159,6 +168,12 @@ export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
// Types // 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 { export interface AgentSessionConfig {
agent: Agent; agent: Agent;
sessionManager: SessionManager; sessionManager: SessionManager;
@@ -170,8 +185,8 @@ export interface AgentSessionConfig {
resourceLoader: ResourceLoader; resourceLoader: ResourceLoader;
/** SDK custom tools registered outside extensions */ /** SDK custom tools registered outside extensions */
customTools?: ToolDefinition[]; customTools?: ToolDefinition[];
/** Model registry for API key resolution and model discovery */ /** Canonical model/auth runtime used by coding-agent internals. */
modelRegistry: ModelRegistry; modelRuntime: ModelRuntime;
/** Initial active built-in tool names. Default: [read, bash, edit, write] */ /** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[]; initialActiveToolNames?: string[];
/** Optional allowlist of tool names. When provided, only these tool names are exposed. */ /** Optional allowlist of tool names. When provided, only these tool names are exposed. */
@@ -325,8 +340,7 @@ export class AgentSession {
private _extensionErrorListener?: ExtensionErrorListener; private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void; private _extensionErrorUnsubscriber?: () => void;
// Model registry for API key resolution private _modelRuntime: ModelRuntime;
private _modelRegistry: ModelRegistry;
// Tool registry for extension getTools/setTools // Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map(); private _toolRegistry: Map<string, AgentTool> = new Map();
@@ -347,7 +361,7 @@ export class AgentSession {
this._resourceLoader = config.resourceLoader; this._resourceLoader = config.resourceLoader;
this._customTools = config.customTools ?? []; this._customTools = config.customTools ?? [];
this._cwd = config.cwd; this._cwd = config.cwd;
this._modelRegistry = config.modelRegistry; this._modelRuntime = config.modelRuntime;
this._extensionRunnerRef = config.extensionRunnerRef; this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames; this._initialActiveToolNames = config.initialActiveToolNames;
this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; 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 modelRuntime(): ModelRuntime {
get modelRegistry(): ModelRegistry { return this._modelRuntime;
return this._modelRegistry;
} }
private async _getRequiredRequestAuth(model: Model<any>): Promise<{ private async _getRequiredRequestAuth(model: Model<any>): Promise<{
@@ -377,18 +390,25 @@ export class AgentSession {
headers?: Record<string, string>; headers?: Record<string, string>;
env?: Record<string, string>; env?: Record<string, string>;
}> { }> {
const result = await this._modelRegistry.getApiKeyAndHeaders(model); let result: AuthResult | undefined;
if (!result.ok) { try {
if (result.error.startsWith("No API key found")) { 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(formatNoApiKeyFoundMessage(model.provider));
} }
throw new Error(result.error); throw error;
} }
if (result.apiKey) { if (result?.auth.apiKey) {
return { apiKey: result.apiKey, headers: result.headers, env: result.env }; 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) { if (isOAuth) {
throw new Error( throw new Error(
`Authentication failed for "${model.provider}". ` + `Authentication failed for "${model.provider}". ` +
@@ -408,8 +428,14 @@ export class AgentSession {
return this._getRequiredRequestAuth(model); return this._getRequiredRequestAuth(model);
} }
const result = await this._modelRegistry.getApiKeyAndHeaders(model); try {
return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {}; 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()); throw new Error(formatNoModelSelectedMessage());
} }
if (!this._modelRegistry.hasConfiguredAuth(this.model)) { const hasConfiguredAuth =
const isOAuth = this._modelRegistry.isUsingOAuth(this.model); 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) { if (isOAuth) {
throw new Error( throw new Error(
`Authentication failed for "${this.model.provider}". ` + `Authentication failed for "${this.model.provider}". ` +
@@ -1535,7 +1564,7 @@ export class AgentSession {
* @throws Error if no auth is configured for the model * @throws Error if no auth is configured for the model
*/ */
async setModel(model: Model<any>): Promise<void> { 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}`); 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> { 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; if (scopedModels.length <= 1) return undefined;
const currentModel = this.model; const currentModel = this.model;
@@ -1594,7 +1629,7 @@ export class AgentSession {
} }
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> { 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; if (availableModels.length <= 1) return undefined;
const currentModel = this.model; const currentModel = this.model;
@@ -2004,12 +2039,10 @@ export class AgentSession {
let headers: Record<string, string> | undefined; let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined; let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) { if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult.ok || !authResult.apiKey) { if (!authResult?.auth.apiKey) return false;
return false; apiKey = authResult.auth.apiKey;
} headers = withoutDeletedHeaders(authResult.auth.headers);
apiKey = authResult.apiKey;
headers = authResult.headers;
env = authResult.env; env = authResult.env;
} else { } else {
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model)); ({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
@@ -2267,7 +2300,7 @@ export class AgentSession {
return; return;
} }
const refreshedModel = this._modelRegistry.find(currentModel.provider, currentModel.id); const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
if (!refreshedModel || refreshedModel === currentModel) { if (!refreshedModel || refreshedModel === currentModel) {
return; return;
} }
@@ -2343,7 +2376,7 @@ export class AgentSession {
refreshTools: () => this._refreshToolRegistry(), refreshTools: () => this._refreshToolRegistry(),
getCommands, getCommands,
setModel: async (model) => { setModel: async (model) => {
if (!this.modelRegistry.hasConfiguredAuth(model)) return false; if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;
await this.setModel(model); await this.setModel(model);
return true; return true;
}, },
@@ -2383,11 +2416,11 @@ export class AgentSession {
}, },
{ {
registerProvider: (name, config) => { registerProvider: (name, config) => {
this._modelRegistry.registerProvider(name, config); this._modelRuntime.registerProvider(name, config);
this._refreshCurrentModelFromRegistry(); this._refreshCurrentModelFromRegistry();
}, },
unregisterProvider: (name) => { unregisterProvider: (name) => {
this._modelRegistry.unregisterProvider(name); this._modelRuntime.unregisterProvider(name);
this._refreshCurrentModelFromRegistry(); this._refreshCurrentModelFromRegistry();
}, },
}, },
@@ -2523,7 +2556,7 @@ export class AgentSession {
extensionsResult.runtime, extensionsResult.runtime,
this._cwd, this._cwd,
this.sessionManager, this.sessionManager,
this._modelRegistry, new ModelRegistry(this._modelRuntime),
); );
if (this._extensionRunnerRef) { if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner; this._extensionRunnerRef.current = this._extensionRunner;
+50 -318
View File
@@ -1,19 +1,9 @@
/** /**
* Credential storage for API keys and OAuth tokens. * CredentialStore implementation backed by auth.json.
* Handles loading, saving, and refreshing credentials from auth.json. * Provider auth orchestration belongs to ModelRuntime and pi-ai Models.
*
* Uses file locking to prevent race conditions when multiple pi instances
* try to refresh tokens simultaneously.
*/ */
import { import type { Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
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 { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
@@ -21,29 +11,7 @@ import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts"; import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts"; import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = { type AuthStorageData = Record<string, Credential>;
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 LockResult<T> = { type LockResult<T> = {
result: T; result: T;
@@ -200,11 +168,8 @@ export class InMemoryAuthStorageBackend implements AuthStorageBackend {
/** /**
* Credential storage backed by a JSON file. * Credential storage backed by a JSON file.
*/ */
export class AuthStorage { export class AuthStorage implements CredentialStore {
private data: AuthStorageData = {}; private data: AuthStorageData = {};
private runtimeOverrides: Map<string, string> = new Map();
private loadError: Error | null = null;
private errors: Error[] = [];
private storage: AuthStorageBackend; private storage: AuthStorageBackend;
private constructor(storage: AuthStorageBackend) { private constructor(storage: AuthStorageBackend) {
@@ -226,26 +191,6 @@ export class AuthStorage {
return AuthStorage.fromStorage(storage); 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 { private parseStorageData(content: string | undefined): AuthStorageData {
if (!content) { if (!content) {
return {}; return {};
@@ -264,276 +209,63 @@ export class AuthStorage {
return { result: undefined }; return { result: undefined };
}); });
this.data = this.parseStorageData(content); this.data = this.parseStorageData(content);
this.loadError = null; } catch {
} catch (error) { // Preserve the last valid in-memory snapshot.
this.loadError = error as Error;
this.recordError(error);
} }
} }
private persistProviderChange(provider: string, credential: AuthCredential | undefined): AuthStorageData { async read(provider: string): Promise<Credential | undefined> {
if (this.loadError) { const credential = this.data[provider];
this.reload(); if (credential?.type !== "api_key") return credential;
} if (credential.key === undefined) return credential;
return { ...credential, key: resolveConfigValue(credential.key, credential.env) };
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 modify(
* Get credential for a provider. provider: string,
*/ fn: (current: Credential | undefined) => Promise<Credential | undefined>,
get(provider: string): AuthCredential | undefined { ): Promise<Credential | undefined> {
return this.data[provider] ?? undefined; return this.storage.withLockAsync(async (content) => {
} const currentData = this.parseStorageData(content);
const next = await fn(currentData[provider]);
/** if (next === undefined) {
* Get provider-scoped environment values for an API key credential. this.data = currentData;
*/ return { result: currentData[provider] };
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 };
} }
if (Date.now() < cred.expires) { const merged: AuthStorageData = { ...currentData, [provider]: next };
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 },
};
this.data = merged; this.data = merged;
this.loadError = null; return { result: next, next: JSON.stringify(merged, null, 2) };
return { result: refreshed, next: JSON.stringify(merged, null, 2) };
}); });
return result;
} }
/** async delete(provider: string): Promise<void> {
* Get API key for a provider. await this.storage.withLockAsync(async (content) => {
* Priority: const currentData = this.parseStorageData(content);
* 1. Runtime override (CLI --api-key) delete currentData[provider];
* 2. API key from auth.json this.data = currentData;
* 3. OAuth token from auth.json (auto-refreshed with locking) return { result: undefined, next: JSON.stringify(currentData, null, 2) };
* 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;
} }
/** /** List credential metadata without resolving configured key values. */
* Get all registered OAuth providers async list(): Promise<readonly CredentialInfo[]> {
*/ return Object.entries(this.data).map(([providerId, credential]) => ({ providerId, type: credential.type }));
getOAuthProviders() { }
return getOAuthProviders(); }
/**
* 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; 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 { 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. */ /** The last request seen by the scan; everything in its prompt should be cached. */
@@ -79,7 +79,7 @@ function detectMiss(
const readPerToken = const readPerToken =
usage.cacheRead > 0 usage.cacheRead > 0
? usage.cost.cacheRead / usage.cacheRead ? 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 { return {
missedTokens, missedTokens,
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat"; import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat";
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth"; 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 type { KeyId } from "@earendil-works/pi-tui";
import * as _bundledPiTui from "@earendil-works/pi-tui"; import * as _bundledPiTui from "@earendil-works/pi-tui";
import { createJiti } from "jiti/static"; import { createJiti } from "jiti/static";
@@ -58,12 +59,14 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
"@earendil-works/pi-ai": _bundledPiAiCompat, "@earendil-works/pi-ai": _bundledPiAiCompat,
"@earendil-works/pi-ai/compat": _bundledPiAiCompat, "@earendil-works/pi-ai/compat": _bundledPiAiCompat,
"@earendil-works/pi-ai/oauth": _bundledPiAiOauth, "@earendil-works/pi-ai/oauth": _bundledPiAiOauth,
"@earendil-works/pi-ai/providers/all": _bundledPiAiProviders,
"@earendil-works/pi-coding-agent": _bundledPiCodingAgent, "@earendil-works/pi-coding-agent": _bundledPiCodingAgent,
"@mariozechner/pi-agent-core": _bundledPiAgentCore, "@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui, "@mariozechner/pi-tui": _bundledPiTui,
"@mariozechner/pi-ai": _bundledPiAiCompat, "@mariozechner/pi-ai": _bundledPiAiCompat,
"@mariozechner/pi-ai/compat": _bundledPiAiCompat, "@mariozechner/pi-ai/compat": _bundledPiAiCompat,
"@mariozechner/pi-ai/oauth": _bundledPiAiOauth, "@mariozechner/pi-ai/oauth": _bundledPiAiOauth,
"@mariozechner/pi-ai/providers/all": _bundledPiAiProviders,
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent, "@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
}; };
@@ -102,20 +105,26 @@ function getAliases(): Record<string, string> {
// global API keep working at runtime until compat is removed. // global API keep working at runtime until compat is removed.
const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat"); 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 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 = { _aliases = {
"@earendil-works/pi-coding-agent": piCodingAgentEntry, "@earendil-works/pi-coding-agent": piCodingAgentEntry,
"@earendil-works/pi-agent-core": piAgentCoreEntry, "@earendil-works/pi-agent-core": piAgentCoreEntry,
"@earendil-works/pi-tui": piTuiEntry, "@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/compat": piAiCompatEntry,
"@earendil-works/pi-ai/oauth": piAiOauthEntry, "@earendil-works/pi-ai/oauth": piAiOauthEntry,
"@earendil-works/pi-ai": piAiCompatEntry,
"@mariozechner/pi-coding-agent": piCodingAgentEntry, "@mariozechner/pi-coding-agent": piCodingAgentEntry,
"@mariozechner/pi-agent-core": piAgentCoreEntry, "@mariozechner/pi-agent-core": piAgentCoreEntry,
"@mariozechner/pi-tui": piTuiEntry, "@mariozechner/pi-tui": piTuiEntry,
"@mariozechner/pi-ai": piAiCompatEntry, "@mariozechner/pi-ai/providers/all": piAiProvidersEntry,
"@mariozechner/pi-ai/compat": piAiCompatEntry, "@mariozechner/pi-ai/compat": piAiCompatEntry,
"@mariozechner/pi-ai/oauth": piAiOauthEntry, "@mariozechner/pi-ai/oauth": piAiOauthEntry,
"@mariozechner/pi-ai": piAiCompatEntry,
typebox: typeboxEntry, typebox: typeboxEntry,
"typebox/compile": typeboxCompileEntry, "typebox/compile": typeboxCompileEntry,
"typebox/value": typeboxValueEntry, "typebox/value": typeboxValueEntry,
@@ -603,6 +603,10 @@ export class ExtensionRunner {
}); });
} }
getModelRegistry(): ModelRegistry {
return this.modelRegistry;
}
getRegisteredCommands(): ResolvedCommand[] { getRegisteredCommands(): ResolvedCommand[] {
this.commandDiagnostics = []; this.commandDiagnostics = [];
return this.resolveRegisteredCommands(); return this.resolveRegisteredCommands();
@@ -1424,13 +1424,15 @@ export interface ProviderConfig {
oauth?: { oauth?: {
/** Display name for the provider in login UI. */ /** Display name for the provider in login UI. */
name: string; name: string;
/** @deprecated Retained for source compatibility; canonical auth flows ignore it. */
usesCallbackServer?: boolean;
/** Run the login flow, return credentials to persist. */ /** Run the login flow, return credentials to persist. */
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>; login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
/** Refresh expired credentials, return updated credentials to persist. */ /** Refresh expired credentials, return updated credentials to persist. */
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>; refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
/** Convert credentials to API key string for the provider. */ /** Convert credentials to API key string for the provider. */
getApiKey(credentials: OAuthCredentials): string; 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>[]; 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 { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.ts"; import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.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 */ /** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = { export const defaultModelPerProvider: Record<KnownProvider, string> = {
@@ -269,9 +269,9 @@ export interface ResolveModelScopeResult {
export async function resolveModelScopeWithDiagnostics( export async function resolveModelScopeWithDiagnostics(
patterns: string[], patterns: string[],
modelRegistry: ModelRegistry, modelRuntime: ModelRuntime,
): Promise<ResolveModelScopeResult> { ): Promise<ResolveModelScopeResult> {
const availableModels = await modelRegistry.getAvailable(); const availableModels = [...(await modelRuntime.getAvailable())];
const scopedModels: ScopedModel[] = []; const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = []; const diagnostics: ModelScopeDiagnostic[] = [];
@@ -331,8 +331,8 @@ export async function resolveModelScopeWithDiagnostics(
return { scopedModels, diagnostics }; return { scopedModels, diagnostics };
} }
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> { export async function resolveModelScope(patterns: string[], modelRuntime: ModelRuntime): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry); const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRuntime);
for (const diagnostic of diagnostics) { for (const diagnostic of diagnostics) {
console.warn(chalk.yellow(`Warning: ${diagnostic.message}`)); console.warn(chalk.yellow(`Warning: ${diagnostic.message}`));
} }
@@ -365,9 +365,9 @@ export function resolveCliModel(options: {
cliProvider?: string; cliProvider?: string;
cliModel?: string; cliModel?: string;
cliThinking?: ThinkingLevel; cliThinking?: ThinkingLevel;
modelRegistry: ModelRegistry; modelRuntime: ModelRuntime;
}): ResolveCliModelResult { }): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRegistry } = options; const { cliProvider, cliModel, cliThinking, modelRuntime } = options;
if (!cliModel) { if (!cliModel) {
return { model: undefined, warning: undefined, error: undefined }; 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. // Important: use *all* models here, not just models with pre-configured auth.
// This allows "--api-key" to be used for first-time setup. // This allows "--api-key" to be used for first-time setup.
const availableModels = modelRegistry.getAll(); const availableModels = [...modelRuntime.getModels()];
if (availableModels.length === 0) { if (availableModels.length === 0) {
return { return {
model: undefined, model: undefined,
@@ -455,8 +455,8 @@ export function resolveCliModel(options: {
const rawExactMatches = availableModels.filter( const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model), (m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
); );
if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) { if (rawExactMatches.length > 0 && !modelRuntime.hasConfiguredAuth(model.provider)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m)); const authenticatedRawMatches = rawExactMatches.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
if (authenticatedRawMatches.length === 1) { if (authenticatedRawMatches.length === 1) {
return { return {
model: authenticatedRawMatches[0], model: authenticatedRawMatches[0],
@@ -556,7 +556,7 @@ export async function findInitialModel(options: {
defaultProvider?: string; defaultProvider?: string;
defaultModelId?: string; defaultModelId?: string;
defaultThinkingLevel?: ThinkingLevel; defaultThinkingLevel?: ThinkingLevel;
modelRegistry: ModelRegistry; modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> { }): Promise<InitialModelResult> {
const { const {
cliProvider, cliProvider,
@@ -566,7 +566,7 @@ export async function findInitialModel(options: {
defaultProvider, defaultProvider,
defaultModelId, defaultModelId,
defaultThinkingLevel, defaultThinkingLevel,
modelRegistry, modelRuntime,
} = options; } = options;
let model: Model<Api> | undefined; let model: Model<Api> | undefined;
@@ -577,7 +577,7 @@ export async function findInitialModel(options: {
const resolved = resolveCliModel({ const resolved = resolveCliModel({
cliProvider, cliProvider,
cliModel, cliModel,
modelRegistry, modelRuntime,
}); });
if (resolved.error) { if (resolved.error) {
console.error(chalk.red(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. // 3. Try saved default from settings if auth is configured.
if (defaultProvider && defaultModelId) { if (defaultProvider && defaultModelId) {
const found = modelRegistry.find(defaultProvider, defaultModelId); const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRegistry.hasConfiguredAuth(found)) { if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found; model = found;
if (defaultThinkingLevel) { if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel; thinkingLevel = defaultThinkingLevel;
@@ -610,7 +610,7 @@ export async function findInitialModel(options: {
} }
// 4. Try first available model with valid API key // 4. Try first available model with valid API key
const availableModels = await modelRegistry.getAvailable(); const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) { if (availableModels.length > 0) {
// Try to find a default model from known providers // Try to find a default model from known providers
@@ -638,12 +638,12 @@ export async function restoreModelFromSession(
savedModelId: string, savedModelId: string,
currentModel: Model<Api> | undefined, currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean, shouldPrintMessages: boolean,
modelRegistry: ModelRegistry, modelRuntime: ModelRuntime,
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> { ): 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 // 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 (restoredModel && hasConfiguredAuth) {
if (shouldPrintMessages) { if (shouldPrintMessages) {
@@ -671,7 +671,7 @@ export async function restoreModelFromSession(
} }
// Try to find any available model // Try to find any available model
const availableModels = await modelRegistry.getAvailable(); const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) { if (availableModels.length > 0) {
// Try to find a default model from known providers // 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"; 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 { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; 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 { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts"; import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts"; import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts"; import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts"; import { findInitialModel } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts"; import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts";
@@ -37,10 +36,8 @@ export interface CreateAgentSessionOptions {
/** Global config directory. Default: ~/.pi/agent */ /** Global config directory. Default: ~/.pi/agent */
agentDir?: string; agentDir?: string;
/** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */ /** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */
authStorage?: AuthStorage; modelRuntime?: ModelRuntime;
/** Model registry. Default: ModelRegistry.create(authStorage, agentDir/models.json) */
modelRegistry?: ModelRegistry;
/** Model to use. Default: from settings, else first available */ /** Model to use. Default: from settings, else first available */
model?: Model<any>; model?: Model<any>;
@@ -169,11 +166,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader; let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined; const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined; const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const authStorage = options.authStorage ?? AuthStorage.create(authPath); const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath);
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(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 session has data, try to restore model from it
if (!model && hasExistingSession && existingSession.model) { if (!model && hasExistingSession && existingSession.model) {
const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); const restoredModel = modelRuntime.getModel(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
model = restoredModel; model = restoredModel;
} }
if (!model) { if (!model) {
@@ -211,7 +206,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
defaultProvider: settingsManager.getDefaultProvider(), defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(), defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRegistry, modelRuntime,
}); });
model = result.model; model = result.model;
if (!model) { if (!model) {
@@ -300,11 +295,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}, },
convertToLlm: convertToLlmWithBlockImages, convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => { 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 providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". // 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 timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs = const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); 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; const headerRunner = extensionRunnerRef.current;
if (headerRunner?.hasHandlers("before_provider_headers")) { return modelRuntime.streamSimple(model, context, {
headers = await headerRunner.emitBeforeProviderHeaders(headers ?? {});
}
return streamSimple(model, context, {
...options, ...options,
apiKey: auth.apiKey,
env,
timeoutMs, timeoutMs,
websocketConnectTimeoutMs, websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, 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) => { onPayload: async (payload, _model) => {
@@ -390,7 +376,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
scopedModels: options.scopedModels, scopedModels: options.scopedModels,
resourceLoader, resourceLoader,
customTools: options.customTools, customTools: options.customTools,
modelRegistry, modelRuntime,
initialActiveToolNames, initialActiveToolNames,
allowedToolNames, allowedToolNames,
excludedToolNames, excludedToolNames,
+6 -11
View File
@@ -23,17 +23,7 @@ export {
parseSkillBlock, parseSkillBlock,
type SessionStats, type SessionStats,
} from "./core/agent-session.ts"; } from "./core/agent-session.ts";
// Auth and model registry export { readStoredCredential } from "./core/auth-storage.ts";
export {
type ApiKeyCredential,
type AuthCredential,
type AuthStatus,
AuthStorage,
type AuthStorageBackend,
FileAuthStorageBackend,
InMemoryAuthStorageBackend,
type OAuthCredential,
} from "./core/auth-storage.ts";
// Compaction // Compaction
export { export {
type BranchPreparation, type BranchPreparation,
@@ -178,6 +168,11 @@ export {
resolveModelScopeWithDiagnostics, resolveModelScopeWithDiagnostics,
type ScopedModel, type ScopedModel,
} from "./core/model-resolver.ts"; } from "./core/model-resolver.ts";
export {
type CreateModelRuntimeOptions,
ModelRuntime,
type ModelRuntimeAuthOverrides,
} from "./core/model-runtime.ts";
export type { export type {
PackageManager, PackageManager,
PathMetadata, PathMetadata,
+12 -14
View File
@@ -23,12 +23,11 @@ import {
createAgentSessionServices, createAgentSessionServices,
} from "./core/agent-session-services.ts"; } from "./core/agent-session-services.ts";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts"; import { exportFromFile } from "./core/export-html/index.ts";
import type { InlineExtension } from "./core/extensions/types.ts"; import type { InlineExtension } from "./core/extensions/types.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.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 { 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 { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts";
@@ -358,7 +357,7 @@ function buildSessionOptions(
parsed: Args, parsed: Args,
scopedModels: ScopedModel[], scopedModels: ScopedModel[],
hasExistingSession: boolean, hasExistingSession: boolean,
modelRegistry: ModelRegistry, modelRuntime: ModelRuntime,
settingsManager: SettingsManager, settingsManager: SettingsManager,
): { ): {
options: CreateAgentSessionOptions; options: CreateAgentSessionOptions;
@@ -377,7 +376,7 @@ function buildSessionOptions(
cliProvider: parsed.provider, cliProvider: parsed.provider,
cliModel: parsed.model, cliModel: parsed.model,
cliThinking: parsed.thinking, cliThinking: parsed.thinking,
modelRegistry, modelRuntime,
}); });
if (resolved.warning) { if (resolved.warning) {
diagnostics.push({ type: "warning", message: 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 // Check if saved default is in scoped models - use it if so, otherwise first scoped model
const savedProvider = settingsManager.getDefaultProvider(); const savedProvider = settingsManager.getDefaultProvider();
const savedModelId = settingsManager.getDefaultModel(); 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; const savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;
if (savedInScope) { 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) // (handled by caller before createAgentSession)
// Tools // Tools
@@ -611,7 +610,6 @@ export async function main(args: string[], options?: MainOptions) {
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes); const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
const authStorage = AuthStorage.create();
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd, cwd,
agentDir, agentDir,
@@ -634,7 +632,6 @@ export async function main(args: string[], options?: MainOptions) {
const services = await createAgentSessionServices({ const services = await createAgentSessionServices({
cwd, cwd,
agentDir, agentDir,
authStorage,
settingsManager: runtimeSettingsManager, settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags, extensionFlagValues: parsed.unknownFlags,
resourceLoaderReloadOptions: shouldResolveProjectTrust resourceLoaderReloadOptions: shouldResolveProjectTrust
@@ -676,7 +673,7 @@ export async function main(args: string[], options?: MainOptions) {
extensionFactories: options?.extensionFactories, extensionFactories: options?.extensionFactories,
}, },
}); });
const { settingsManager, modelRegistry, resourceLoader } = services; const { settingsManager, modelRuntime, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [ const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics, ...projectTrustDiagnostics,
...services.diagnostics, ...services.diagnostics,
@@ -689,7 +686,7 @@ export async function main(args: string[], options?: MainOptions) {
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels(); const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
const scopedModels = const scopedModels =
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : []; modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRuntime) : [];
const { const {
options: sessionOptions, options: sessionOptions,
cliThinkingFromModel, cliThinkingFromModel,
@@ -698,7 +695,7 @@ export async function main(args: string[], options?: MainOptions) {
parsed, parsed,
scopedModels, scopedModels,
sessionManager.buildSessionContext().messages.length > 0, sessionManager.buildSessionContext().messages.length > 0,
modelRegistry, modelRuntime,
settingsManager, settingsManager,
); );
diagnostics.push(...sessionOptionDiagnostics); 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", message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
}); });
} else { } 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"); time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime; const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services; const { settingsManager, modelRuntime, resourceLoader } = services;
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
@@ -759,7 +757,7 @@ export async function main(args: string[], options?: MainOptions) {
if (parsed.listModels !== undefined) { if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern); await listModels(modelRuntime, searchPattern);
process.exit(0); process.exit(0);
} }
@@ -138,7 +138,7 @@ export class FooterComponent implements Component {
statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
} }
// Show cost with "(sub)" indicator if using OAuth subscription // 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) { if (totalCost || usingSubscription) {
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statsParts.push(costStr); 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 { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { openBrowser } from "../../../utils/open-browser.ts"; import { openBrowser } from "../../../utils/open-browser.ts";
import { theme } from "../theme/theme.ts"; import { theme } from "../theme/theme.ts";
@@ -38,8 +38,7 @@ export class LoginDialogComponent extends Container implements Focusable {
this.tui = tui; this.tui = tui;
this.onComplete = onComplete; this.onComplete = onComplete;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId); const providerName = providerNameOverride || providerId;
const providerName = providerNameOverride || providerInfo?.name || providerId;
const title = titleOverride ?? `Login to ${providerName}`; const title = titleOverride ?? `Login to ${providerName}`;
// Top border // 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 { showDetails(lines: string[]): void {
this.contentContainer.clear(); this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Spacer(1));
@@ -188,13 +185,19 @@ export class LoginDialogComponent extends Container implements Focusable {
this.tui.requestRender(); this.tui.requestRender();
} }
/** /** Show provider-owned information and links without starting an auth callback flow. */
* Show informational text without prompting for input. showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
*/
showInfo(lines: string[]): void {
this.showDetails(lines);
this.contentContainer.addChild(new Spacer(1)); 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(); this.tui.requestRender();
} }
@@ -9,7 +9,7 @@ import {
Text, Text,
type TUI, type TUI,
} from "@earendil-works/pi-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 type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts"; import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts"; import { theme } from "../theme/theme.ts";
@@ -52,7 +52,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
private selectedIndex: number = 0; private selectedIndex: number = 0;
private currentModel?: Model<any>; private currentModel?: Model<any>;
private settingsManager: SettingsManager; private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry; private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void; private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void; private onCancelCallback: () => void;
private errorMessage?: string; private errorMessage?: string;
@@ -61,12 +61,15 @@ export class ModelSelectorComponent extends Container implements Focusable {
private scope: ModelScope = "all"; private scope: ModelScope = "all";
private scopeText?: Text; private scopeText?: Text;
private scopeHintText?: Text; private scopeHintText?: Text;
private readonly refreshAbortController = new AbortController();
private refreshTimeout?: ReturnType<typeof setTimeout>;
private closed = false;
constructor( constructor(
tui: TUI, tui: TUI,
currentModel: Model<any> | undefined, currentModel: Model<any> | undefined,
settingsManager: SettingsManager, settingsManager: SettingsManager,
modelRegistry: ModelRegistry, modelRuntime: ModelRuntime,
scopedModels: ReadonlyArray<ScopedModelItem>, scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void, onSelect: (model: Model<any>) => void,
onCancel: () => void, onCancel: () => void,
@@ -77,7 +80,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.tui = tui; this.tui = tui;
this.currentModel = currentModel; this.currentModel = currentModel;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.modelRegistry = modelRegistry; this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels; this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect; this.onSelectCallback = onSelect;
@@ -123,50 +126,23 @@ export class ModelSelectorComponent extends Container implements Focusable {
// Add bottom border // Add bottom border
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
// Load models and do initial render // Render the current snapshot immediately, then refresh in the background.
this.loadModels().then(() => { this.loadModelsFromSnapshot();
if (initialSearchInput) { if (initialSearchInput) this.filterModels(initialSearchInput);
this.filterModels(initialSearchInput); else this.updateList();
} else { this.tui.requestRender();
this.updateList(); void this.refreshModels();
}
// Request re-render after models are loaded
this.tui.requestRender();
});
} }
private async loadModels(): Promise<void> { private loadModelsFromSnapshot(): void {
let models: ModelItem[]; const models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({
provider: model.provider,
// Refresh to pick up any changes to models.json id: model.id,
this.modelRegistry.refresh(); model,
}));
// 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;
}
this.allModels = this.sortModels(models); this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => { 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; return refreshed ? { ...scoped, model: refreshed } : scoped;
}); });
this.scopedModelItems = this.scopedModels.map((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)); 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[] { private sortModels(models: ModelItem[]): ModelItem[] {
const sorted = [...models]; const sorted = [...models];
// Sort: current model first, then by provider // Sort: current model first, then by provider
@@ -316,6 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
} }
// Escape or Ctrl+C // Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
this.close();
this.onCancelCallback(); this.onCancelCallback();
} }
// Pass everything else to search input // Pass everything else to search input
@@ -326,6 +334,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
} }
private handleSelect(model: Model<any>): void { private handleSelect(model: Model<any>): void {
this.close();
// Save as new default // Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model); this.onSelectCallback(model);
@@ -1,3 +1,4 @@
import type { ApiKeyAuth, AuthCheck, OAuthAuth } from "@earendil-works/pi-ai";
import { import {
Container, Container,
type Focusable, type Focusable,
@@ -7,7 +8,6 @@ import {
Spacer, Spacer,
TruncatedText, TruncatedText,
} from "@earendil-works/pi-tui"; } from "@earendil-works/pi-tui";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts";
import { theme } from "../theme/theme.ts"; import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts"; import { DynamicBorder } from "./dynamic-border.ts";
@@ -15,6 +15,8 @@ export type AuthSelectorProvider = {
id: string; id: string;
name: string; name: string;
authType: "oauth" | "api_key"; authType: "oauth" | "api_key";
method?: ApiKeyAuth | OAuthAuth;
status?: AuthCheck;
}; };
export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string { export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string {
@@ -42,26 +44,20 @@ export class OAuthSelectorComponent extends Container implements Focusable {
private filteredProviders: AuthSelectorProvider[]; private filteredProviders: AuthSelectorProvider[];
private selectedIndex: number = 0; private selectedIndex: number = 0;
private mode: "login" | "logout"; private mode: "login" | "logout";
private authStorage: AuthStorage;
private getAuthStatus: (providerId: string) => AuthStatus;
private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void; private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void;
private onCancelCallback: () => void; private onCancelCallback: () => void;
private showAuthTypeLabels: boolean; private showAuthTypeLabels: boolean;
constructor( constructor(
mode: "login" | "logout", mode: "login" | "logout",
authStorage: AuthStorage,
providers: AuthSelectorProvider[], providers: AuthSelectorProvider[],
onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void, onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void,
onCancel: () => void, onCancel: () => void,
getAuthStatus?: (providerId: string) => AuthStatus,
initialSearchInput?: string, initialSearchInput?: string,
) { ) {
super(); super();
this.mode = mode; this.mode = mode;
this.authStorage = authStorage;
this.getAuthStatus = getAuthStatus ?? ((providerId) => this.authStorage.getAuthStatus(providerId));
this.allProviders = providers; this.allProviders = providers;
this.filteredProviders = providers; this.filteredProviders = providers;
this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1; 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 { private filterProviders(query: string): void {
this.filteredProviders = query 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.allProviders;
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1))); this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1)));
this.updateList(); this.updateList();
@@ -162,29 +162,22 @@ export class OAuthSelectorComponent extends Container implements Focusable {
} }
private formatStatusIndicator(provider: AuthSelectorProvider): string { private formatStatusIndicator(provider: AuthSelectorProvider): string {
const credential = this.authStorage.get(provider.id); if (!provider.status) return theme.fg("muted", " • unconfigured");
if (credential?.type === provider.authType) return theme.fg("success", " ✓ configured"); if (provider.status.type !== provider.authType) {
if (credential) { const label = provider.status.type === "oauth" ? "subscription configured" : "API key configured";
const label = credential.type === "oauth" ? "subscription configured" : "API key configured";
return theme.fg("muted", " • ") + theme.fg("warning", label); return theme.fg("muted", " • ") + theme.fg("warning", label);
} }
if (provider.authType !== "api_key") return theme.fg("muted", " • unconfigured"); if (
!provider.status.source ||
const status = this.getAuthStatus(provider.id); provider.status.source === "OAuth" ||
switch (status.source) { provider.status.source === "stored credential"
case "environment": ) {
return theme.fg("success", ` ✓ env: ${status.label ?? "API key"}`); return theme.fg("success", " ✓ configured");
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");
} }
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 { handleInput(keyData: string): void {
@@ -8,15 +8,8 @@ import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
type AssistantMessage, import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat";
getProviders,
type ImageContent,
type Message,
type Model,
type OAuthProviderId,
type OAuthSelectPrompt,
} from "@earendil-works/pi-ai/compat";
import type { import type {
AutocompleteItem, AutocompleteItem,
AutocompleteProvider, AutocompleteProvider,
@@ -85,7 +78,6 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.t
import { createCompactionSummaryMessage } from "../../core/messages.ts"; import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts"; import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.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 type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.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 = 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 { function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); 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; 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 = { type LoginProviderCompletionOption = {
id: string; id: string;
name: string; name: string;
@@ -569,12 +545,12 @@ export class InteractiveMode {
const modelCommand = slashCommands.find((command) => command.name === "model"); const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) { if (modelCommand) {
modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { modelCommand.getArgumentCompletions = async (prefix: string): Promise<AutocompleteItem[] | null> => {
// Get available models (scoped or from registry) // Get available models (scoped or from registry)
const models = const models =
this.session.scopedModels.length > 0 this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model) ? this.session.scopedModels.map((s) => s.model)
: this.session.modelRegistry.getAvailable(); : await this.session.modelRuntime.getAvailable();
if (models.length === 0) return null; if (models.length === 0) return null;
@@ -877,7 +853,7 @@ export class InteractiveMode {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
} }
const modelsJsonError = this.session.modelRegistry.getError(); const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) { if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`); this.showError(`models.json error: ${modelsJsonError}`);
} }
@@ -1777,7 +1753,7 @@ export class InteractiveMode {
hasUI: true, hasUI: true,
cwd: this.sessionManager.getCwd(), cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager, sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry, modelRegistry: extensionRunner.getModelRegistry(),
model: this.session.model, model: this.session.model,
isIdle: () => this.session.isIdle, isIdle: () => this.session.isIdle,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(), isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
@@ -3291,7 +3267,7 @@ export class InteractiveMode {
// Cache-miss notices are not persisted; re-derive them from the full entry // 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. // list and re-inject them after the assistant messages that paid for them.
const cacheMisses = this.settingsManager.getShowCacheMissNotices() const cacheMisses = this.settingsManager.getShowCacheMissNotices()
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRegistry) ? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRuntime)
: new Map<AssistantMessage, CacheMiss>(); : new Map<AssistantMessage, CacheMiss>();
if (options.updateFooter) { if (options.updateFooter) {
@@ -3395,7 +3371,7 @@ export class InteractiveMode {
if (!this.settingsManager.getShowCacheMissNotices()) return; if (!this.settingsManager.getShowCacheMissNotices()) return;
// Entries don't contain `message` yet: message_end fires before persistence. // 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); if (miss) this.addCacheMissNotice(miss);
} }
@@ -4328,9 +4304,9 @@ export class InteractiveMode {
return this.session.scopedModels.map((scoped) => scoped.model); return this.session.scopedModels.map((scoped) => scoped.model);
} }
this.session.modelRegistry.refresh();
try { try {
return await this.session.modelRegistry.getAvailable(); await this.session.modelRuntime.refresh();
return [...(await this.session.modelRuntime.getAvailable())];
} catch { } catch {
return []; return [];
} }
@@ -4356,15 +4332,13 @@ export class InteractiveMode {
return; return;
} }
const storedCredential = this.session.modelRegistry.authStorage.get("anthropic");
if (storedCredential?.type === "oauth") {
this.anthropicSubscriptionWarningShown = true;
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
return;
}
try { 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)) { if (!isAnthropicSubscriptionAuthKey(apiKey)) {
return; return;
} }
@@ -4432,7 +4406,7 @@ export class InteractiveMode {
this.ui, this.ui,
this.session.model, this.session.model,
this.settingsManager, this.settingsManager,
this.session.modelRegistry, this.session.modelRuntime,
this.session.scopedModels, this.session.scopedModels,
async (model) => { async (model) => {
try { try {
@@ -4460,8 +4434,8 @@ export class InteractiveMode {
private async showModelsSelector(): Promise<void> { private async showModelsSelector(): Promise<void> {
// Get all available models // Get all available models
this.session.modelRegistry.refresh(); await this.session.modelRuntime.refresh();
const allModels = this.session.modelRegistry.getAvailable(); const allModels = [...(await this.session.modelRuntime.getAvailable())];
if (allModels.length === 0) { if (allModels.length === 0) {
this.showStatus("No models available"); this.showStatus("No models available");
@@ -4482,7 +4456,7 @@ export class InteractiveMode {
// Fall back to settings // Fall back to settings
const patterns = this.settingsManager.getEnabledModels(); const patterns = this.settingsManager.getEnabledModels();
if (patterns !== undefined && patterns.length > 0) { 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}`); currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
} }
} }
@@ -4491,7 +4465,7 @@ export class InteractiveMode {
const updateSessionModels = async (enabledIds: string[] | null) => { const updateSessionModels = async (enabledIds: string[] | null) => {
currentEnabledIds = enabledIds === null ? null : [...enabledIds]; currentEnabledIds = enabledIds === null ? null : [...enabledIds];
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) { 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( this.session.setScopedModels(
newScopedModels.map((sm) => ({ newScopedModels.map((sm) => ({
model: sm.model, model: sm.model,
@@ -4805,48 +4779,46 @@ export class InteractiveMode {
} }
private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] { private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] {
const authStorage = this.session.modelRegistry.authStorage; const options: AuthSelectorProvider[] = [];
const oauthProviders = authStorage.getOAuthProviders(); for (const provider of this.session.modelRuntime.getProviders()) {
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id)); const authStatus = this.session.modelRuntime.getProviderAuthStatus(provider.id);
const options: AuthSelectorProvider[] = oauthProviders.map((provider) => ({ const status = authStatus.configured
id: provider.id, ? {
name: provider.name, type: this.session.modelRuntime.isUsingOAuth(provider.id) ? ("oauth" as const) : ("api_key" as const),
authType: "oauth", source: authStatus.label ?? authStatus.source,
})); }
: undefined;
const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider)); if ((!authType || authType === "oauth") && provider.auth.oauth) {
for (const providerId of modelProviders) { options.push({
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) { id: provider.id,
continue; 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",
});
} }
return options.sort((a, b) => a.name.localeCompare(b.name));
const filteredOptions = authType ? options.filter((option) => option.authType === authType) : options;
return filteredOptions.sort((a, b) => a.name.localeCompare(b.name));
} }
private getLogoutProviderOptions(): AuthSelectorProvider[] { private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> {
const authStorage = this.session.modelRegistry.authStorage; return (await this.session.modelRuntime.listCredentials())
const options: AuthSelectorProvider[] = []; .map(({ providerId, type }) => ({
for (const providerId of authStorage.list()) {
const credential = authStorage.get(providerId);
if (!credential) {
continue;
}
options.push({
id: providerId, id: providerId,
name: this.session.modelRegistry.getProviderDisplayName(providerId), name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId,
authType: credential.type, authType: type,
}); status: { type, source: "stored credential" },
} }))
.sort((a, b) => a.name.localeCompare(b.name));
return options.sort((a, b) => a.name.localeCompare(b.name));
} }
private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] {
@@ -4863,6 +4835,7 @@ export class InteractiveMode {
} }
private async handleLoginCommand(providerRef?: string): Promise<void> { private async handleLoginCommand(providerRef?: string): Promise<void> {
await this.session.modelRuntime.getAvailable();
if (!providerRef) { if (!providerRef) {
this.showLoginAuthTypeSelector(); this.showLoginAuthTypeSelector();
return; return;
@@ -4888,8 +4861,10 @@ export class InteractiveMode {
private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> { private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> {
if (providerOption.authType === "oauth") { if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name); await this.showLoginDialog(providerOption.id, providerOption.name);
} else { } else if (providerOption.method?.login) {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
} else {
this.showAmbientAuthDialog(providerOption);
} }
} }
@@ -4964,7 +4939,6 @@ export class InteractiveMode {
this.showSelector((done) => { this.showSelector((done) => {
const selector = new OAuthSelectorComponent( const selector = new OAuthSelectorComponent(
"login", "login",
this.session.modelRegistry.authStorage,
providerOptions, providerOptions,
async (providerId, selectedAuthType) => { async (providerId, selectedAuthType) => {
done(); done();
@@ -4986,7 +4960,6 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
} }
}, },
(providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId),
initialSearchInput, initialSearchInput,
); );
return { component: selector, focus: selector }; return { component: selector, focus: selector };
@@ -4999,7 +4972,7 @@ export class InteractiveMode {
return; return;
} }
const providerOptions = this.getLogoutProviderOptions(); const providerOptions = await this.getLogoutProviderOptions();
if (providerOptions.length === 0) { if (providerOptions.length === 0) {
this.showStatus( this.showStatus(
"No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.", "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) => { this.showSelector((done) => {
const selector = new OAuthSelectorComponent( const selector = new OAuthSelectorComponent(
mode, mode,
this.session.modelRegistry.authStorage,
providerOptions, providerOptions,
async (providerId: string) => { async (providerId: string) => {
done(); done();
@@ -5021,8 +4993,7 @@ export class InteractiveMode {
} }
try { try {
this.session.modelRegistry.authStorage.logout(providerOption.id); await this.session.modelRuntime.logout(providerOption.id);
this.session.modelRegistry.refresh();
await this.updateAvailableProviderCount(); await this.updateAvailableProviderCount();
const message = const message =
providerOption.authType === "oauth" providerOption.authType === "oauth"
@@ -5048,14 +5019,14 @@ export class InteractiveMode {
authType: "oauth" | "api_key", authType: "oauth" | "api_key",
previousModel: Model<any> | undefined, previousModel: Model<any> | undefined,
): Promise<void> { ): Promise<void> {
this.session.modelRegistry.refresh(); await this.session.modelRuntime.getAvailable();
const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`; const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`;
let selectedModel: Model<any> | undefined; let selectedModel: Model<any> | undefined;
let selectionError: string | undefined; let selectionError: string | undefined;
if (isUnknownModel(previousModel)) { if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRegistry.getAvailable(); const availableModels = await this.session.modelRuntime.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId); const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) { if (!hasDefaultModelProvider(providerId)) {
selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; 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> { private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> {
const previousModel = this.session.model; const previousModel = this.session.model;
@@ -5128,13 +5122,7 @@ export class InteractiveMode {
}; };
try { try {
const apiKey = (await dialog.showPrompt("Enter API key:")).trim(); await this.loginProvider(dialog, providerId, "api_key");
if (!apiKey) {
throw new Error("API key cannot be empty.");
}
this.session.modelRegistry.authStorage.set(providerId, { type: "api_key", key: apiKey });
restoreEditor(); restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel);
} catch (error: unknown) { } catch (error: unknown) {
@@ -5146,8 +5134,11 @@ export class InteractiveMode {
} }
} }
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> { private showAuthSelect(
return new Promise((resolve) => { dialog: LoginDialogComponent,
prompt: Extract<AuthPrompt, { type: "select" }>,
): Promise<string> {
return new Promise((resolve, reject) => {
const restoreDialog = () => { const restoreDialog = () => {
this.editorContainer.clear(); this.editorContainer.clear();
this.editorContainer.addChild(dialog); this.editorContainer.addChild(dialog);
@@ -5160,11 +5151,13 @@ export class InteractiveMode {
labels, labels,
(optionLabel) => { (optionLabel) => {
restoreDialog(); 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(); restoreDialog();
resolve(undefined); reject(new Error("Login cancelled"));
}, },
); );
this.editorContainer.clear(); 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> { 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; const previousModel = this.session.model;
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName);
// 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
this.editorContainer.clear(); this.editorContainer.clear();
this.editorContainer.addChild(dialog); this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog); this.ui.setFocus(dialog);
this.ui.requestRender(); 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 = () => { const restoreEditor = () => {
this.editorContainer.clear(); this.editorContainer.clear();
this.editorContainer.addChild(this.editor); this.editorContainer.addChild(this.editor);
@@ -5216,51 +5232,7 @@ export class InteractiveMode {
}; };
try { try {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProviderId, { await this.loginProvider(dialog, providerId, "oauth");
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
restoreEditor(); restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel);
} catch (error: unknown) { } catch (error: unknown) {
@@ -5361,7 +5333,7 @@ export class InteractiveMode {
showDiagnosticsWhenQuiet: true, showDiagnosticsWhenQuiet: true,
}); });
const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload(); const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload();
const modelsJsonError = this.session.modelRegistry.getError(); const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) { if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`); this.showError(`models.json error: ${modelsJsonError}`);
} }
@@ -5606,7 +5578,7 @@ export class InteractiveMode {
const stats = this.session.getSessionStats(); const stats = this.session.getSessionStats();
const sessionName = this.sessionManager.getSessionName(); const sessionName = this.sessionManager.getSessionName();
const entries = this.sessionManager.getEntries(); 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` // Cost/token totals per provider/model actually used (e.g. OpenRouter `auto`
// resolves to a concrete responseModel), sorted by cost descending. // resolves to a concrete responseModel), sorted by cost descending.
@@ -465,7 +465,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// ================================================================= // =================================================================
case "set_model": { 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); const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
if (!model) { if (!model) {
return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`); 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": { case "get_available_models": {
const models = await session.modelRegistry.getAvailable(); const models = await session.modelRuntime.getAvailable();
return success(id, "get_available_models", { models }); 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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 { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts"; import { createTestResourceLoader } from "./utilities.ts";
describe("AgentSession auto-compaction queue resume", () => { describe("AgentSession auto-compaction queue resume", () => {
@@ -18,7 +18,7 @@ describe("AgentSession auto-compaction queue resume", () => {
let settingsManager: SettingsManager; let settingsManager: SettingsManager;
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(async () => {
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`); tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
mkdirSync(tempDir, { recursive: true }); mkdirSync(tempDir, { recursive: true });
vi.useFakeTimers(); vi.useFakeTimers();
@@ -35,15 +35,15 @@ describe("AgentSession auto-compaction queue resume", () => {
sessionManager = SessionManager.inMemory(); sessionManager = SessionManager.inMemory();
settingsManager = SettingsManager.create(tempDir, tempDir); settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key"); await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const modelRegistry = ModelRegistry.create(authStorage, tempDir); const modelRegistry = await createModelRegistry(authStorage, tempDir);
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
}); });
}); });
@@ -48,7 +48,7 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!; const model = getModel("anthropic", "claude-sonnet-4-5")!;
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir); sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); 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 = { const servicesOptions = {
agentDir: tempDir, agentDir: tempDir,
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/** /**
* E2E tests for AgentSession compaction behavior. * 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 { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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 { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts";
import { createCodingTools } from "../src/index.ts"; import { createCodingTools } from "../src/index.ts";
@@ -27,7 +27,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
let sessionManager: SessionManager; let sessionManager: SessionManager;
let events: AgentSessionEvent[]; let events: AgentSessionEvent[];
beforeEach(() => { beforeEach(async () => {
// Create temp directory for session files // Create temp directory for session files
tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`); tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true }); 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 model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({ const agent = new Agent({
getApiKey: () => API_KEY, 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 // Use minimal keepRecentTokens so small test conversations have something to summarize
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage); const modelRegistry = await createModelRegistry(authStorage);
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
}); });
@@ -81,7 +81,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
} }
it("should trigger manual compaction via compact()", async () => { it("should trigger manual compaction via compact()", async () => {
createSession(); await createSession();
// Send a few prompts to build up history // Send a few prompts to build up history
await session.prompt("What is 2+2? Reply with just the number."); await session.prompt("What is 2+2? Reply with just the number.");
@@ -107,7 +107,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000); }, 120000);
it("should maintain valid session state after compaction", async () => { it("should maintain valid session state after compaction", async () => {
createSession(); await createSession();
// Build up history // Build up history
await session.prompt("What is the capital of France? One word answer."); await session.prompt("What is the capital of France? One word answer.");
@@ -132,7 +132,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 180000); }, 180000);
it("should persist compaction to session file", async () => { it("should persist compaction to session file", async () => {
createSession(); await createSession();
await session.prompt("Say hello"); await session.prompt("Say hello");
await session.agent.waitForIdle(); await session.agent.waitForIdle();
@@ -160,7 +160,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000); }, 120000);
it("should work with --no-session mode (in-memory only)", async () => { it("should work with --no-session mode (in-memory only)", async () => {
createSession(true); // in-memory mode await createSession(true); // in-memory mode
// Send prompts // Send prompts
await session.prompt("What is 2+2? Reply with just the number."); await session.prompt("What is 2+2? Reply with just the number.");
@@ -182,7 +182,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000); }, 120000);
it("should emit compaction events during manual compaction", async () => { it("should emit compaction events during manual compaction", async () => {
createSession(); await createSession();
// Build some history // Build some history
await session.prompt("Say hello"); await session.prompt("Say hello");
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/** /**
* Tests for AgentSession concurrent prompt guard. * Tests for AgentSession concurrent prompt guard.
*/ */
@@ -18,7 +19,6 @@ import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts"; import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.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 { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts";
import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts"; import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts";
@@ -62,7 +62,7 @@ describe("AgentSession concurrent prompt guard", () => {
let session: AgentSession; let session: AgentSession;
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(async () => {
tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`); tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true }); 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")!; const model = getModel("anthropic", "claude-sonnet-4-5")!;
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
@@ -111,16 +111,16 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); 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 // 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({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
}); });
@@ -128,7 +128,7 @@ describe("AgentSession concurrent prompt guard", () => {
} }
it("should throw when prompt() called while streaming", async () => { it("should throw when prompt() called while streaming", async () => {
createSession(); await createSession();
// Start first prompt (don't await, it will block until abort) // Start first prompt (don't await, it will block until abort)
const firstPrompt = session.prompt("First message"); const firstPrompt = session.prompt("First message");
@@ -150,7 +150,7 @@ describe("AgentSession concurrent prompt guard", () => {
}); });
it("should allow steer() while streaming", async () => { it("should allow steer() while streaming", async () => {
createSession(); await createSession();
// Start first prompt // Start first prompt
const firstPrompt = session.prompt("First message"); const firstPrompt = session.prompt("First message");
@@ -166,7 +166,7 @@ describe("AgentSession concurrent prompt guard", () => {
}); });
it("should allow followUp() while streaming", async () => { it("should allow followUp() while streaming", async () => {
createSession(); await createSession();
// Start first prompt // Start first prompt
const firstPrompt = session.prompt("First message"); const firstPrompt = session.prompt("First message");
@@ -236,8 +236,8 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir); const modelRegistry = await createModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key"); await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const extensionsResult = await createTestExtensionsResult([ const extensionsResult = await createTestExtensionsResult([
(pi) => { (pi) => {
@@ -255,7 +255,7 @@ describe("AgentSession concurrent prompt guard", () => {
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader({ extensionsResult }), resourceLoader: createTestResourceLoader({ extensionsResult }),
}); });
session.subscribe((event) => { session.subscribe((event) => {
@@ -314,15 +314,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir); const modelRegistry = await createModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key"); await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
}); });
@@ -420,15 +420,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir); const modelRegistry = await createModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key"); await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool }, baseToolsOverride: { dummy: tool },
}); });
@@ -567,15 +567,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory(); const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir); const modelRegistry = await createModelRegistry(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key"); await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir, cwd: tempDir,
modelRegistry, modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(), resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool }, baseToolsOverride: { dummy: tool },
}); });

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