feat(coding-agent): merge model runtime facade
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-139
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+20
-110
@@ -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);
|
||||||
+63
-189
@@ -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];
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => "",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
|||||||
@@ -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>[];
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)",
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user