feat(coding-agent): replace model registry with model runtime

Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
This commit is contained in:
Mario Zechner
2026-07-14 17:48:45 +02:00
parent 6731a0ba9e
commit 9993c96907
133 changed files with 5103 additions and 4340 deletions
+65
View File
@@ -2,11 +2,76 @@
## [Unreleased]
### 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.
- Removed extension OAuth `modifyModels`. Provider catalogs are now composed independently of credentials; credential-specific availability belongs to canonical provider filtering. The legacy extension OAuth callback and credential types remain available from pi-ai's root and `oauth` subpath.
#### SDK migration
Construct one `ModelRuntime` and pass it to `createAgentSession()`:
```typescript
// Before
const authStorage = AuthStorage.create(authPath);
const modelRegistry = await ModelRegistry.create(authStorage, modelsPath);
authStorage.setRuntimeApiKey("anthropic", apiKey);
const { session } = await createAgentSession({ authStorage, modelRegistry });
// After
const modelRuntime = await ModelRuntime.create({ authPath, modelsPath });
// Or: ModelRuntime.create({ credentials: myCredentialStore, modelsPath })
modelRuntime.setRuntimeApiKey("anthropic", apiKey);
const { session } = await createAgentSession({ modelRuntime });
```
Replace `ModelRegistry` projections with the corresponding `ModelRuntime`/pi-ai `Models` methods:
```typescript
const allModels = modelRuntime.getModels();
const model = modelRuntime.getModel(providerId, modelId);
const availableModels = await modelRuntime.getAvailable();
const authStatus = await modelRuntime.checkAuth(providerId);
const requestAuth = await modelRuntime.getAuth(model); // Includes model headers
modelRuntime.registerProvider(providerId, providerConfig); // Still synchronous
await modelRuntime.reloadConfig();
```
`ModelRuntime.stream*()` resolves auth and configured headers itself. Do not call `getAuth(model)` before streaming merely to reconstruct request options. For SDK-level header interception, use the Models-only transform so auth is resolved once:
```typescript
modelRuntime.streamSimple(model, context, {
transformHeaders: async (headers) => ({
...headers,
"X-Request-ID": requestId,
}),
});
```
Use `ModelRuntime` for model lookup, availability, provider auth, login/logout, runtime API-key overrides, provider registration, and config refresh. `ModelRegistry` remains a synchronous-read compatibility facade for extensions; SDK code should use `ModelRuntime`. Extensions that explicitly refresh it must await completion:
```typescript
await ctx.modelRegistry.refresh();
const models = ctx.modelRegistry.getAll();
```
### 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 the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
- Added request-wide input-token pricing tiers to custom model costs in `models.json`, `modelOverrides`, and extension-registered providers.
### 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.
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
+3 -5
View File
@@ -453,14 +453,12 @@ See [docs/packages.md](docs/packages.md).
### SDK
```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
await session.prompt("What files are in the current directory?");
+6 -11
View File
@@ -253,6 +253,8 @@ pi.registerProvider("custom-api", {
});
```
The key is resolved for each request. An explicit request `Authorization` header takes precedence over the generated value.
## OAuth Support
Add OAuth/SSO authentication that integrates with `/login`:
@@ -312,15 +314,6 @@ pi.registerProvider("corporate-ai", {
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
// Optional: modify models based on user's subscription
modifyModels(models, credentials) {
const region = decodeRegionFromToken(credentials.access);
return models.map(m => ({
...m,
baseUrl: `https://${region}.ai.corp.com/v1`
}));
}
}
});
@@ -330,7 +323,7 @@ After registration, users can authenticate via `/login corporate-ai`.
### OAuthLoginCallbacks
The `callbacks` object provides three ways to authenticate:
The `callbacks` object provides UI-neutral interactions for the provider-owned flow:
```typescript
interface OAuthLoginCallbacks {
@@ -345,6 +338,9 @@ interface OAuthLoginCallbacks {
expiresInSeconds?: number;
}): void;
// Show transient progress
onProgress?(message: string): void;
// Prompt user for input (for manual token entry)
onPrompt(params: { message: string }): Promise<string>;
@@ -660,7 +656,6 @@ interface ProviderConfig {
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey(credentials: OAuthCredentials): string;
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
}
```
+41 -51
View File
@@ -16,16 +16,12 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
## Quick Start
```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
// Set up credential storage and model registry
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
session.subscribe((event) => {
@@ -369,10 +365,9 @@ When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control
```typescript
import { getModel } from "@earendil-works/pi-ai";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
@@ -380,10 +375,10 @@ if (!opus) throw new Error("Model not found");
// Find any model by provider/id, including custom models from models.json
// (doesn't check if API key exists)
const customModel = modelRegistry.find("my-provider", "my-model");
const customModel = modelRuntime.getModel("my-provider", "my-model");
// Get only models that have valid API keys configured
const available = await modelRegistry.getAvailable();
// Get only models that have valid authentication configured
const available = await modelRuntime.getAvailable();
const { session } = await createAgentSession({
model: opus,
@@ -395,8 +390,7 @@ const { session } = await createAgentSession({
{ model: haiku, thinkingLevel: "off" },
],
authStorage,
modelRegistry,
modelRuntime,
});
```
@@ -415,14 +409,14 @@ import {
const cliModel = resolveCliModel({
cliModel: "anthropic/claude-opus-4-5:high",
modelRegistry,
modelRuntime,
});
if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning);
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
["anthropic/*:high", "gpt-5"],
modelRegistry,
modelRuntime,
);
for (const diagnostic of diagnostics) {
console.warn(diagnostic.message);
@@ -435,40 +429,41 @@ for (const diagnostic of diagnostics) {
### API Keys and OAuth
API key resolution priority (handled by AuthStorage):
Authentication resolution priority (handled by `ModelRuntime`):
1. Runtime overrides (via `setRuntimeApiKey`, not persisted)
2. Stored credentials in `auth.json` (API keys or OAuth tokens)
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.json`)
```typescript
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { createAgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
});
// Provider-owned auth methods and current status
for (const provider of modelRuntime.getProviders()) {
const status = await modelRuntime.checkAuth(provider.id);
console.log(provider.name, provider.auth, status);
}
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");
// Custom auth storage location
const customAuth = AuthStorage.create("/my/app/auth.json");
const customRegistry = ModelRegistry.create(customAuth, "/my/app/models.json");
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuth,
modelRegistry: customRegistry,
// Custom credential and model locations
const customRuntime = await ModelRuntime.create({
authPath: "/my/app/auth.json",
modelsPath: "/my/app/models.json",
});
// No custom models.json (built-in models only)
const simpleRegistry = ModelRegistry.inMemory(authStorage);
// Or inject any pi-ai CredentialStore
const credentials = new InMemoryCredentialStore();
const inMemoryRuntime = await ModelRuntime.create({ credentials });
const { session } = await createAgentSession({
modelRuntime: customRuntime,
});
```
> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
@@ -927,26 +922,22 @@ interface LoadExtensionsResult {
import { getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
defineTool,
ModelRegistry,
ModelRuntime,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Set up auth storage (custom location)
const authStorage = AuthStorage.create("/custom/agent/auth.json");
// Runtime API key override (not persisted)
const modelRuntime = await ModelRuntime.create({
authPath: "/custom/agent/auth.json",
modelsPath: "/custom/agent/models.json",
});
if (process.env.MY_KEY) {
authStorage.setRuntimeApiKey("anthropic", process.env.MY_KEY);
modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}
// Model registry (no custom models.json)
const modelRegistry = ModelRegistry.create(authStorage);
// Inline tool
const statusTool = defineTool({
name: "status",
@@ -982,8 +973,7 @@ const { session } = await createAgentSession({
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
modelRuntime,
tools: ["read", "bash", "status"],
customTools: [statusTool],
@@ -1149,8 +1139,8 @@ createAgentSessionRuntime
AgentSessionRuntime
// Auth and Models
AuthStorage
ModelRegistry
ModelRuntime // implements pi-ai Models and owns credential storage
ModelRegistry // synchronous extension compatibility facade
resolveCliModel
resolveModelScopeWithDiagnostics
@@ -46,7 +46,7 @@ import {
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// =============================================================================
// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts)
// OAuth implementation adapted for the legacy extension compatibility interface.
// =============================================================================
const decode = (s: string) => atob(s);
@@ -5,11 +5,9 @@
*/
import { getModel } from "@earendil-works/pi-ai/compat";
import { AuthStorage, createAgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { createAgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5");
@@ -18,13 +16,13 @@ if (opus) {
}
// Option 2: Find model via registry (includes custom models from models.json)
const customModel = modelRegistry.find("my-provider", "my-model");
const customModel = modelRuntime.getModel("my-provider", "my-model");
if (customModel) {
console.log(`Found custom model: ${customModel.provider}/${customModel.id}`);
}
// Option 3: Pick from available models (have valid API keys)
const available = await modelRegistry.getAvailable();
const available = await modelRuntime.getAvailable();
console.log(
"Available models:",
available.map((m) => `${m.provider}/${m.id}`),
@@ -34,8 +32,7 @@ if (available.length > 0) {
const { session } = await createAgentSession({
model: available[0],
thinkingLevel: "medium", // off, low, medium, high
authStorage,
modelRegistry,
modelRuntime,
});
try {
@@ -1,52 +1,34 @@
/**
* API Keys and OAuth
*
* Configure API key resolution via AuthStorage and ModelRegistry.
* Configure provider auth through ModelRuntime.
*/
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
// Default: AuthStorage uses ~/.pi/agent/auth.json
// ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
const modelRuntime = await ModelRuntime.create();
const { session: defaultAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
console.log("Session with default auth storage and model registry");
console.log("Session with default model runtime");
defaultAuthSession.dispose();
// Custom auth storage location
const customAuthStorage = AuthStorage.create("/tmp/my-app/auth.json");
const customModelRegistry = ModelRegistry.create(customAuthStorage, "/tmp/my-app/models.json");
const customRuntime = await ModelRuntime.create({
authPath: "/tmp/my-app/auth.json",
modelsPath: "/tmp/my-app/models.json",
});
const { session: customAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuthStorage,
modelRegistry: customModelRegistry,
modelRuntime: customRuntime,
});
console.log("Session with custom auth storage location");
console.log("Session with custom auth and models locations");
customAuthSession.dispose();
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");
const { session: runtimeKeySession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
console.log("Session with runtime API key override");
runtimeKeySession.dispose();
// No models.json - only built-in models
const simpleRegistry = ModelRegistry.inMemory(authStorage);
const { session: builtInModelsSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry: simpleRegistry,
});
console.log("Session with only built-in models");
builtInModelsSession.dispose();
@@ -6,26 +6,22 @@
import { getModel } from "@earendil-works/pi-ai/compat";
import {
AuthStorage,
createAgentSession,
createExtensionRuntime,
ModelRegistry,
ModelRuntime,
type ResourceLoader,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Custom auth storage location
const authStorage = AuthStorage.create("/tmp/my-agent/auth.json");
// Runtime API key override (not persisted)
const modelRuntime = await ModelRuntime.create({
authPath: "/tmp/my-agent/auth.json",
modelsPath: "/tmp/my-agent/models.json",
});
if (process.env.MY_ANTHROPIC_KEY) {
authStorage.setRuntimeApiKey("anthropic", process.env.MY_ANTHROPIC_KEY);
modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_ANTHROPIC_KEY);
}
// Model registry with no custom models.json
const modelRegistry = ModelRegistry.inMemory(authStorage);
const model = getModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Model not found");
@@ -55,8 +51,7 @@ const { session } = await createAgentSession({
agentDir: "/tmp/my-agent",
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
modelRuntime,
resourceLoader,
tools: ["read", "bash"],
sessionManager: SessionManager.inMemory(cwd),
+14 -18
View File
@@ -34,46 +34,44 @@ npx tsx examples/sdk/01-minimal.ts
```typescript
import { getModel } from "@earendil-works/pi-ai";
import {
AuthStorage,
createAgentSession,
DefaultResourceLoader,
ModelRegistry,
ModelRuntime,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// Auth and models setup
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRuntime = await ModelRuntime.create();
// Minimal
const { session } = await createAgentSession({ authStorage, modelRegistry });
const { session } = await createAgentSession({ modelRuntime });
// Custom model
const model = getModel("anthropic", "claude-opus-4-5");
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
const { session } = await createAgentSession({ model, thinkingLevel: "high", modelRuntime });
// Modify prompt
const loader = new DefaultResourceLoader({
systemPromptOverride: (base) => `${base}\n\nBe concise.`,
});
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
const { session } = await createAgentSession({ resourceLoader: loader, modelRuntime });
// Read-only
const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], authStorage, modelRegistry });
const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], modelRuntime });
// In-memory
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
modelRuntime,
});
// Full control
const customAuth = AuthStorage.create("/my/app/auth.json");
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const customRegistry = ModelRegistry.create(customAuth);
const customRuntime = await ModelRuntime.create({
authPath: "/my/app/auth.json",
modelsPath: "/my/app/models.json",
});
customRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () => "You are helpful.",
@@ -86,8 +84,7 @@ await resourceLoader.reload();
const { session } = await createAgentSession({
model,
authStorage: customAuth,
modelRegistry: customRegistry,
modelRuntime: customRuntime,
resourceLoader,
tools: ["read", "bash", "my_tool"],
customTools: [myTool],
@@ -108,8 +105,7 @@ await session.prompt("Hello");
| Option | Default | Description |
|--------|---------|-------------|
| `authStorage` | `AuthStorage.create()` | Credential storage |
| `modelRegistry` | `ModelRegistry.create(authStorage)` | Model registry |
| `modelRuntime` | Runtime using `agentDir/auth.json` and `models.json` | Canonical model and authentication runtime |
| `cwd` | `process.cwd()` | Working directory |
| `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use |
+4 -4
View File
@@ -6,7 +6,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
import { fuzzyFilter } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts";
import type { ModelRegistry } from "../core/model-registry.ts";
import type { ModelRuntime } from "../core/model-runtime.ts";
/**
* Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")
@@ -26,13 +26,13 @@ function formatTokenCount(count: number): string {
/**
* List available models, optionally filtered by search pattern
*/
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
const loadError = modelRegistry.getError();
export async function listModels(modelRuntime: ModelRuntime, searchPattern?: string): Promise<void> {
const loadError = modelRuntime.getError();
if (loadError) {
console.error(chalk.yellow(`Warning: errors loading models.json:\n${loadError}`));
}
const models = modelRegistry.getAvailable();
const models = [...(await modelRuntime.getAvailable())];
if (models.length === 0) {
console.log(formatNoModelsAvailableMessage());
@@ -3,9 +3,8 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
import { ModelRuntime } from "./model-runtime.ts";
import {
DefaultResourceLoader,
type DefaultResourceLoaderOptions,
@@ -38,9 +37,8 @@ export interface AgentSessionRuntimeDiagnostic {
export interface CreateAgentSessionServicesOptions {
cwd: string;
agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager;
modelRegistry?: ModelRegistry;
modelRuntime?: ModelRuntime;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
@@ -74,9 +72,8 @@ export interface CreateAgentSessionFromServicesOptions {
export interface AgentSessionServices {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
modelRuntime: ModelRuntime;
settingsManager: SettingsManager;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
@@ -139,9 +136,13 @@ export async function createAgentSessionServices(
): Promise<AgentSessionServices> {
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const modelRuntime =
options.modelRuntime ??
(await ModelRuntime.create({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
}));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
cwd,
@@ -154,7 +155,7 @@ export async function createAgentSessionServices(
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
modelRuntime.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
@@ -169,9 +170,8 @@ export async function createAgentSessionServices(
return {
cwd,
agentDir,
authStorage,
modelRuntime,
settingsManager,
modelRegistry,
resourceLoader,
diagnostics,
};
@@ -190,9 +190,8 @@ export async function createAgentSessionFromServices(
return createAgentSession({
cwd: options.services.cwd,
agentDir: options.services.agentDir,
authStorage: options.services.authStorage,
modelRuntime: options.services.modelRuntime,
settingsManager: options.services.settingsManager,
modelRegistry: options.services.modelRegistry,
resourceLoader: options.services.resourceLoader,
sessionManager: options.sessionManager,
model: options.model,
+68 -35
View File
@@ -24,7 +24,15 @@ import type {
PrepareNextTurnContext,
ThinkingLevel,
} from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat";
import type {
AssistantMessage,
AuthResult,
ImageContent,
Message,
Model,
ProviderHeaders,
TextContent,
} from "@earendil-works/pi-ai/compat";
import {
clampThinkingLevel,
cleanupSessionResources,
@@ -83,7 +91,8 @@ import {
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import { ModelRegistry } from "./model-registry.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
@@ -159,6 +168,12 @@ export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
// Types
// ============================================================================
function withoutDeletedHeaders(headers: ProviderHeaders | undefined): Record<string, string> | undefined {
return headers
? Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null))
: undefined;
}
export interface AgentSessionConfig {
agent: Agent;
sessionManager: SessionManager;
@@ -170,8 +185,8 @@ export interface AgentSessionConfig {
resourceLoader: ResourceLoader;
/** SDK custom tools registered outside extensions */
customTools?: ToolDefinition[];
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Canonical model/auth runtime used by coding-agent internals. */
modelRuntime: ModelRuntime;
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[];
/** Optional allowlist of tool names. When provided, only these tool names are exposed. */
@@ -325,8 +340,7 @@ export class AgentSession {
private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void;
// Model registry for API key resolution
private _modelRegistry: ModelRegistry;
private _modelRuntime: ModelRuntime;
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map();
@@ -347,7 +361,7 @@ export class AgentSession {
this._resourceLoader = config.resourceLoader;
this._customTools = config.customTools ?? [];
this._cwd = config.cwd;
this._modelRegistry = config.modelRegistry;
this._modelRuntime = config.modelRuntime;
this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames;
this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
@@ -367,9 +381,8 @@ export class AgentSession {
});
}
/** Model registry for API key resolution and model discovery */
get modelRegistry(): ModelRegistry {
return this._modelRegistry;
get modelRuntime(): ModelRuntime {
return this._modelRuntime;
}
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
@@ -377,18 +390,25 @@ export class AgentSession {
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
if (!result.ok) {
if (result.error.startsWith("No API key found")) {
let result: AuthResult | undefined;
try {
result = await this._modelRuntime.getAuth(model);
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined;
if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") {
throw new Error(formatNoApiKeyFoundMessage(model.provider));
}
throw new Error(result.error);
throw error;
}
if (result.apiKey) {
return { apiKey: result.apiKey, headers: result.headers, env: result.env };
if (result?.auth.apiKey) {
return {
apiKey: result.auth.apiKey,
headers: withoutDeletedHeaders(result.auth.headers),
env: result.env,
};
}
const isOAuth = this._modelRegistry.isUsingOAuth(model);
const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
if (isOAuth) {
throw new Error(
`Authentication failed for "${model.provider}". ` +
@@ -408,8 +428,14 @@ export class AgentSession {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {};
try {
const result = await this._modelRuntime.getAuth(model);
return result
? { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env }
: {};
} catch {
return {};
}
}
/**
@@ -1141,8 +1167,11 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
if (!this._modelRegistry.hasConfiguredAuth(this.model)) {
const isOAuth = this._modelRegistry.isUsingOAuth(this.model);
const hasConfiguredAuth =
this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
(await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
if (!hasConfiguredAuth) {
const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
if (isOAuth) {
throw new Error(
`Authentication failed for "${this.model.provider}". ` +
@@ -1535,7 +1564,7 @@ export class AgentSession {
* @throws Error if no auth is configured for the model
*/
async setModel(model: Model<any>): Promise<void> {
if (!this._modelRegistry.hasConfiguredAuth(model)) {
if (!(await this._modelRuntime.checkAuth(model.provider))) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
@@ -1565,7 +1594,13 @@ export class AgentSession {
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
const checks = await Promise.all(
this._scopedModels.map(async (scoped) => ({
scoped,
auth: await this._modelRuntime.checkAuth(scoped.model.provider),
})),
);
const scopedModels = checks.filter(({ auth }) => auth !== undefined).map(({ scoped }) => scoped);
if (scopedModels.length <= 1) return undefined;
const currentModel = this.model;
@@ -1594,7 +1629,7 @@ export class AgentSession {
}
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const availableModels = await this._modelRegistry.getAvailable();
const availableModels = await this._modelRuntime.getAvailable();
if (availableModels.length <= 1) return undefined;
const currentModel = this.model;
@@ -2004,12 +2039,10 @@ export class AgentSession {
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
return false;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey;
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
} else {
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
@@ -2267,7 +2300,7 @@ export class AgentSession {
return;
}
const refreshedModel = this._modelRegistry.find(currentModel.provider, currentModel.id);
const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
if (!refreshedModel || refreshedModel === currentModel) {
return;
}
@@ -2343,7 +2376,7 @@ export class AgentSession {
refreshTools: () => this._refreshToolRegistry(),
getCommands,
setModel: async (model) => {
if (!this.modelRegistry.hasConfiguredAuth(model)) return false;
if (!this._modelRuntime.hasConfiguredAuth(model.provider)) return false;
await this.setModel(model);
return true;
},
@@ -2383,11 +2416,11 @@ export class AgentSession {
},
{
registerProvider: (name, config) => {
this._modelRegistry.registerProvider(name, config);
this._modelRuntime.registerProvider(name, config);
this._refreshCurrentModelFromRegistry();
},
unregisterProvider: (name) => {
this._modelRegistry.unregisterProvider(name);
this._modelRuntime.unregisterProvider(name);
this._refreshCurrentModelFromRegistry();
},
},
@@ -2523,7 +2556,7 @@ export class AgentSession {
extensionsResult.runtime,
this._cwd,
this.sessionManager,
this._modelRegistry,
new ModelRegistry(this._modelRuntime),
);
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
+50 -318
View File
@@ -1,19 +1,9 @@
/**
* Credential storage for API keys and OAuth tokens.
* Handles loading, saving, and refreshing credentials from auth.json.
*
* Uses file locking to prevent race conditions when multiple pi instances
* try to refresh tokens simultaneously.
* CredentialStore implementation backed by auth.json.
* Provider auth orchestration belongs to ModelRuntime and pi-ai Models.
*/
import {
findEnvKeys,
getEnvApiKey,
type OAuthCredentials,
type OAuthLoginCallbacks,
type OAuthProviderId,
} from "@earendil-works/pi-ai/compat";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import type { Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
@@ -21,29 +11,7 @@ import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
key: string;
env?: Record<string, string>;
};
export type OAuthCredential = {
type: "oauth";
} & OAuthCredentials;
export type AuthCredential = ApiKeyCredential | OAuthCredential;
export type AuthStorageData = Record<string, AuthCredential>;
export type AuthStatus = {
configured: boolean;
source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command";
label?: string;
};
export interface GetApiKeyOptions {
includeFallback?: boolean;
}
type AuthStorageData = Record<string, Credential>;
type LockResult<T> = {
result: T;
@@ -200,11 +168,8 @@ export class InMemoryAuthStorageBackend implements AuthStorageBackend {
/**
* Credential storage backed by a JSON file.
*/
export class AuthStorage {
export class AuthStorage implements CredentialStore {
private data: AuthStorageData = {};
private runtimeOverrides: Map<string, string> = new Map();
private loadError: Error | null = null;
private errors: Error[] = [];
private storage: AuthStorageBackend;
private constructor(storage: AuthStorageBackend) {
@@ -226,26 +191,6 @@ export class AuthStorage {
return AuthStorage.fromStorage(storage);
}
/**
* Set a runtime API key override (not persisted to disk).
* Used for CLI --api-key flag.
*/
setRuntimeApiKey(provider: string, apiKey: string): void {
this.runtimeOverrides.set(provider, apiKey);
}
/**
* Remove a runtime API key override.
*/
removeRuntimeApiKey(provider: string): void {
this.runtimeOverrides.delete(provider);
}
private recordError(error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push(normalizedError);
}
private parseStorageData(content: string | undefined): AuthStorageData {
if (!content) {
return {};
@@ -264,276 +209,63 @@ export class AuthStorage {
return { result: undefined };
});
this.data = this.parseStorageData(content);
this.loadError = null;
} catch (error) {
this.loadError = error as Error;
this.recordError(error);
} catch {
// Preserve the last valid in-memory snapshot.
}
}
private persistProviderChange(provider: string, credential: AuthCredential | undefined): AuthStorageData {
if (this.loadError) {
this.reload();
}
if (this.loadError) {
const error = new Error(
`Cannot update auth storage because it could not be loaded: ${this.loadError.message}`,
);
this.recordError(error);
throw error;
}
try {
let persistedData: AuthStorageData = {};
this.storage.withLock((current) => {
const currentData = this.parseStorageData(current);
const merged: AuthStorageData = { ...currentData };
if (credential) {
merged[provider] = credential;
} else {
delete merged[provider];
}
persistedData = merged;
return { result: undefined, next: JSON.stringify(merged, null, 2) };
});
this.loadError = null;
return persistedData;
} catch (error) {
this.recordError(error);
throw error;
}
async read(provider: string): Promise<Credential | undefined> {
const credential = this.data[provider];
if (credential?.type !== "api_key") return credential;
if (credential.key === undefined) return credential;
return { ...credential, key: resolveConfigValue(credential.key, credential.env) };
}
/**
* Get credential for a provider.
*/
get(provider: string): AuthCredential | undefined {
return this.data[provider] ?? undefined;
}
/**
* Get provider-scoped environment values for an API key credential.
*/
getProviderEnv(provider: string): Record<string, string> | undefined {
const cred = this.data[provider];
return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined;
}
/**
* Set credential for a provider.
*/
set(provider: string, credential: AuthCredential): void {
this.data = this.persistProviderChange(provider, credential);
}
/**
* Remove credential for a provider.
*/
remove(provider: string): void {
this.data = this.persistProviderChange(provider, undefined);
}
/**
* List all providers with credentials.
*/
list(): string[] {
return Object.keys(this.data);
}
/**
* Check if credentials exist for a provider in auth.json.
*/
has(provider: string): boolean {
return provider in this.data;
}
/**
* Check if any form of auth is configured for a provider.
* Unlike getApiKey(), this doesn't refresh OAuth tokens.
*/
hasAuth(provider: string): boolean {
if (this.runtimeOverrides.has(provider)) return true;
if (this.data[provider]) return true;
if (getEnvApiKey(provider)) return true;
return false;
}
/**
* Return auth status without exposing credential values or refreshing tokens.
*/
getAuthStatus(provider: string): AuthStatus {
if (this.data[provider]) {
return { configured: true, source: "stored" };
}
if (this.runtimeOverrides.has(provider)) {
return { configured: false, source: "runtime", label: "--api-key" };
}
const envKeys = findEnvKeys(provider);
if (envKeys?.[0]) {
return { configured: false, source: "environment", label: envKeys[0] };
}
return { configured: false };
}
/**
* Get all credentials (for passing to getOAuthApiKey).
*/
getAll(): AuthStorageData {
return { ...this.data };
}
drainErrors(): Error[] {
const drained = [...this.errors];
this.errors = [];
return drained;
}
/**
* Login to an OAuth provider.
*/
async login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise<void> {
const provider = getOAuthProvider(providerId);
if (!provider) {
throw new Error(`Unknown OAuth provider: ${providerId}`);
}
const credentials = await provider.login(callbacks);
this.set(providerId, { type: "oauth", ...credentials });
}
/**
* Logout from a provider.
*/
logout(provider: string): void {
this.remove(provider);
}
/**
* Refresh OAuth token with backend locking to prevent race conditions.
* Multiple pi instances may try to refresh simultaneously when tokens expire.
*/
private async refreshOAuthTokenWithLock(
providerId: OAuthProviderId,
): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const provider = getOAuthProvider(providerId);
if (!provider) {
return null;
}
const result = await this.storage.withLockAsync(async (current) => {
const currentData = this.parseStorageData(current);
this.data = currentData;
this.loadError = null;
const cred = currentData[providerId];
if (cred?.type !== "oauth") {
return { result: null };
async modify(
provider: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.storage.withLockAsync(async (content) => {
const currentData = this.parseStorageData(content);
const next = await fn(currentData[provider]);
if (next === undefined) {
this.data = currentData;
return { result: currentData[provider] };
}
if (Date.now() < cred.expires) {
return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } };
}
const oauthCreds: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(currentData)) {
if (value.type === "oauth") {
oauthCreds[key] = value;
}
}
const refreshed = await getOAuthApiKey(providerId, oauthCreds);
if (!refreshed) {
return { result: null };
}
const merged: AuthStorageData = {
...currentData,
[providerId]: { type: "oauth", ...refreshed.newCredentials },
};
const merged: AuthStorageData = { ...currentData, [provider]: next };
this.data = merged;
this.loadError = null;
return { result: refreshed, next: JSON.stringify(merged, null, 2) };
return { result: next, next: JSON.stringify(merged, null, 2) };
});
return result;
}
/**
* Get API key for a provider.
* Priority:
* 1. Runtime override (CLI --api-key)
* 2. API key from auth.json
* 3. OAuth token from auth.json (auto-refreshed with locking)
* 4. Environment variable
*/
async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(providerId);
if (runtimeKey) {
return runtimeKey;
}
const cred = this.data[providerId];
if (cred?.type === "api_key") {
return resolveConfigValue(cred.key, cred.env);
}
if (cred?.type === "oauth") {
const provider = getOAuthProvider(providerId);
if (!provider) {
// Unknown OAuth provider, can't get API key
return undefined;
}
// Check if token needs refresh
const needsRefresh = Date.now() >= cred.expires;
if (needsRefresh) {
// Use locked refresh to prevent race conditions
try {
const result = await this.refreshOAuthTokenWithLock(providerId);
if (result) {
return result.apiKey;
}
} catch (error) {
this.recordError(error);
// Refresh failed - re-read file to check if another instance succeeded
this.reload();
const updatedCred = this.data[providerId];
if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
// Another instance refreshed successfully, use those credentials
return provider.getApiKey(updatedCred);
}
// Refresh truly failed - return undefined so model discovery skips this provider
// User can /login to re-authenticate (credentials preserved for retry)
return undefined;
}
} else {
// Token not expired, use current access token
return provider.getApiKey(cred);
}
}
if (options.includeFallback === false) return undefined;
// Fall back to environment variable
const envKey = getEnvApiKey(providerId);
if (envKey) return envKey;
return undefined;
async delete(provider: string): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const currentData = this.parseStorageData(content);
delete currentData[provider];
this.data = currentData;
return { result: undefined, next: JSON.stringify(currentData, null, 2) };
});
}
/**
* Get all registered OAuth providers
*/
getOAuthProviders() {
return getOAuthProviders();
/** List credential metadata without resolving configured key values. */
async list(): Promise<readonly CredentialInfo[]> {
return Object.entries(this.data).map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
}
/**
* One-off synchronous read of a stored credential from an auth.json file,
* without instantiating a store or resolving configured key values.
*/
export function readStoredCredential(
providerId: string,
authPath: string = join(getAgentDir(), "auth.json"),
): Credential | undefined {
try {
const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData;
return data[providerId];
} catch {
return undefined;
}
}
@@ -29,9 +29,9 @@ export interface CacheWasteTotals {
missCount: number;
}
/** Minimal pricing lookup, satisfied by ModelRegistry. Cost is $/million tokens. */
/** Minimal pricing lookup, satisfied by ModelRuntime. Cost is $/million tokens. */
export interface ModelPriceSource {
find(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
getModel(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
}
/** The last request seen by the scan; everything in its prompt should be cached. */
@@ -79,7 +79,7 @@ function detectMiss(
const readPerToken =
usage.cacheRead > 0
? usage.cost.cacheRead / usage.cacheRead
: (models.find(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
: (models.getModel(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
return {
missedTokens,
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat";
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
import * as _bundledPiAiProviders from "@earendil-works/pi-ai/providers/all";
import type { KeyId } from "@earendil-works/pi-tui";
import * as _bundledPiTui from "@earendil-works/pi-tui";
import { createJiti } from "jiti/static";
@@ -58,12 +59,14 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
"@earendil-works/pi-ai": _bundledPiAiCompat,
"@earendil-works/pi-ai/compat": _bundledPiAiCompat,
"@earendil-works/pi-ai/oauth": _bundledPiAiOauth,
"@earendil-works/pi-ai/providers/all": _bundledPiAiProviders,
"@earendil-works/pi-coding-agent": _bundledPiCodingAgent,
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui,
"@mariozechner/pi-ai": _bundledPiAiCompat,
"@mariozechner/pi-ai/compat": _bundledPiAiCompat,
"@mariozechner/pi-ai/oauth": _bundledPiAiOauth,
"@mariozechner/pi-ai/providers/all": _bundledPiAiProviders,
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
};
@@ -102,20 +105,26 @@ function getAliases(): Record<string, string> {
// global API keep working at runtime until compat is removed.
const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat");
const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth");
const piAiProvidersEntry = resolveWorkspaceOrImport(
"ai/dist/providers/all.js",
"@earendil-works/pi-ai/providers/all",
);
_aliases = {
"@earendil-works/pi-coding-agent": piCodingAgentEntry,
"@earendil-works/pi-agent-core": piAgentCoreEntry,
"@earendil-works/pi-tui": piTuiEntry,
"@earendil-works/pi-ai": piAiCompatEntry,
"@earendil-works/pi-ai/providers/all": piAiProvidersEntry,
"@earendil-works/pi-ai/compat": piAiCompatEntry,
"@earendil-works/pi-ai/oauth": piAiOauthEntry,
"@earendil-works/pi-ai": piAiCompatEntry,
"@mariozechner/pi-coding-agent": piCodingAgentEntry,
"@mariozechner/pi-agent-core": piAgentCoreEntry,
"@mariozechner/pi-tui": piTuiEntry,
"@mariozechner/pi-ai": piAiCompatEntry,
"@mariozechner/pi-ai/providers/all": piAiProvidersEntry,
"@mariozechner/pi-ai/compat": piAiCompatEntry,
"@mariozechner/pi-ai/oauth": piAiOauthEntry,
"@mariozechner/pi-ai": piAiCompatEntry,
typebox: typeboxEntry,
"typebox/compile": typeboxCompileEntry,
"typebox/value": typeboxValueEntry,
@@ -602,6 +602,10 @@ export class ExtensionRunner {
});
}
getModelRegistry(): ModelRegistry {
return this.modelRegistry;
}
getRegisteredCommands(): ResolvedCommand[] {
this.commandDiagnostics = [];
return this.resolveRegisteredCommands();
@@ -1424,14 +1424,14 @@ export interface ProviderConfig {
oauth?: {
/** Display name for the provider in login UI. */
name: string;
/** @deprecated Retained for source compatibility; canonical auth flows ignore it. */
usesCallbackServer?: boolean;
/** Run the login flow, return credentials to persist. */
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
/** Refresh expired credentials, return updated credentials to persist. */
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
/** Convert credentials to API key string for the provider. */
getApiKey(credentials: OAuthCredentials): string;
/** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
}
@@ -0,0 +1,277 @@
/** 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()),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
});
const OpenAIResponsesCompatSchema = Type.Object({
supportsDeveloperRole: Type.Optional(Type.Boolean()),
sendSessionIdHeader: Type.Optional(Type.Boolean()),
supportsLongCacheRetention: 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()),
});
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 })),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
authHeader: Type.Optional(Type.Boolean()),
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
});
const ModelsConfigSchema = Type.Object({
providers: Type.Record(Type.String(), ProviderConfigSchema),
});
const validateModelsConfig = Compile(ModelsConfigSchema);
export type ModelsJsonModel = Static<typeof ModelDefinitionSchema>;
export type ModelsJsonModelOverride = Static<typeof ModelOverrideSchema>;
export type ModelsJsonProvider = Static<typeof ProviderConfigSchema>;
type ModelsJson = Static<typeof ModelsConfigSchema>;
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
const requiredProperty = requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
for (const child of Object.values(value)) deepFreeze(child);
return Object.freeze(value);
}
/** One immutable load of models.json. */
export class ModelConfig {
private readonly providers: ReadonlyMap<string, ModelsJsonProvider>;
private readonly error: string | undefined;
private constructor(providers: ReadonlyMap<string, ModelsJsonProvider>, error?: string) {
this.providers = providers;
this.error = error;
}
static async load(modelsJsonPath: string | undefined): Promise<ModelConfig> {
if (!modelsJsonPath) return new ModelConfig(new Map());
const path = normalizePath(modelsJsonPath);
let content: string;
try {
content = await readFile(path, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new ModelConfig(new Map());
return new ModelConfig(
new Map(),
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${path}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(stripJsonComments(content));
} catch (error) {
return new ModelConfig(
new Map(),
`Failed to parse models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${path}`,
);
}
if (!validateModelsConfig.Check(parsed)) {
const errors =
validateModelsConfig
.Errors(parsed)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown schema error";
return new ModelConfig(new Map(), `Invalid models.json schema:\n${errors}\n\nFile: ${path}`);
}
const config = parsed as ModelsJson;
const providers = new Map<string, ModelsJsonProvider>();
for (const [providerId, provider] of Object.entries(config.providers)) {
providers.set(providerId, deepFreeze(structuredClone(provider)));
}
return new ModelConfig(providers);
}
getProvider(providerId: string): ModelsJsonProvider | undefined {
return this.providers.get(providerId);
}
getProviderIds(): readonly string[] {
return [...this.providers.keys()];
}
getError(): string | undefined {
return this.error;
}
}
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ModelRegistry } from "./model-registry.ts";
import type { ModelRuntime } from "./model-runtime.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
@@ -268,9 +268,9 @@ export interface ResolveModelScopeResult {
export async function resolveModelScopeWithDiagnostics(
patterns: string[],
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
): Promise<ResolveModelScopeResult> {
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = [];
@@ -330,8 +330,8 @@ export async function resolveModelScopeWithDiagnostics(
return { scopedModels, diagnostics };
}
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
export async function resolveModelScope(patterns: string[], modelRuntime: ModelRuntime): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRuntime);
for (const diagnostic of diagnostics) {
console.warn(chalk.yellow(`Warning: ${diagnostic.message}`));
}
@@ -364,9 +364,9 @@ export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
cliThinking?: ThinkingLevel;
modelRegistry: ModelRegistry;
modelRuntime: ModelRuntime;
}): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRegistry } = options;
const { cliProvider, cliModel, cliThinking, modelRuntime } = options;
if (!cliModel) {
return { model: undefined, warning: undefined, error: undefined };
@@ -374,7 +374,7 @@ export function resolveCliModel(options: {
// Important: use *all* models here, not just models with pre-configured auth.
// This allows "--api-key" to be used for first-time setup.
const availableModels = modelRegistry.getAll();
const availableModels = [...modelRuntime.getModels()];
if (availableModels.length === 0) {
return {
model: undefined,
@@ -454,8 +454,8 @@ export function resolveCliModel(options: {
const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
);
if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m));
if (rawExactMatches.length > 0 && !modelRuntime.hasConfiguredAuth(model.provider)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
if (authenticatedRawMatches.length === 1) {
return {
model: authenticatedRawMatches[0],
@@ -555,7 +555,7 @@ export async function findInitialModel(options: {
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: ThinkingLevel;
modelRegistry: ModelRegistry;
modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> {
const {
cliProvider,
@@ -565,7 +565,7 @@ export async function findInitialModel(options: {
defaultProvider,
defaultModelId,
defaultThinkingLevel,
modelRegistry,
modelRuntime,
} = options;
let model: Model<Api> | undefined;
@@ -576,7 +576,7 @@ export async function findInitialModel(options: {
const resolved = resolveCliModel({
cliProvider,
cliModel,
modelRegistry,
modelRuntime,
});
if (resolved.error) {
console.error(chalk.red(resolved.error));
@@ -598,8 +598,8 @@ export async function findInitialModel(options: {
// 3. Try saved default from settings if auth is configured.
if (defaultProvider && defaultModelId) {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found && modelRegistry.hasConfiguredAuth(found)) {
const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
@@ -609,7 +609,7 @@ export async function findInitialModel(options: {
}
// 4. Try first available model with valid API key
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -637,12 +637,12 @@ export async function restoreModelFromSession(
savedModelId: string,
currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
const restoredModel = modelRuntime.getModel(savedProvider, savedModelId);
// Check if restored model exists and still has auth configured
const hasConfiguredAuth = restoredModel ? modelRegistry.hasConfiguredAuth(restoredModel) : false;
const hasConfiguredAuth = restoredModel ? modelRuntime.hasConfiguredAuth(restoredModel.provider) : false;
if (restoredModel && hasConfiguredAuth) {
if (shouldPrintMessages) {
@@ -670,7 +670,7 @@ export async function restoreModelFromSession(
}
// Try to find any available model
const availableModels = await modelRegistry.getAvailable();
const availableModels = [...(await modelRuntime.getAvailable())];
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -0,0 +1,489 @@
import { 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 ModelsSimpleStreamOptions,
type ModelsStreamTransforms,
type MutableModels,
type Provider,
type ProviderHeaders,
type SimpleStreamOptions,
type StreamOptions,
} from "@earendil-works/pi-ai";
import { builtinProviders } 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 {
type AuthStatus,
type CompatibilityRequestConfig,
composeModelProvider,
configuredRequestAuthStatus,
type ProviderConfigInput,
resolveCompatibilityRequestConfig,
resolveConfiguredModelHeaders,
validateExtensionProvider,
} from "./provider-composer.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;
}
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 builtins: ReadonlyMap<string, Provider>;
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
private readonly compositionErrors = new Map<string, string>();
private readonly modelsPath: string | undefined;
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,
providers: readonly Provider[],
) {
this.credentials = credentials;
this.config = config;
this.modelsPath = modelsPath;
this.builtins = new Map(providers.map((provider) => [provider.id, provider]));
this.models = createModels({ credentials });
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 runtime = new ModelRuntime(credentials, config, modelsPath, builtinProviders());
await runtime.refreshAvailability();
return runtime;
}
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),
},
};
}
setRuntimeApiKey(providerId: string, apiKey: string): 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)),
};
void this.forceRefreshAvailability().catch(() => {});
}
removeRuntimeApiKey(providerId: string): void {
this.credentials.removeRuntimeApiKey(providerId);
void this.forceRefreshAvailability().catch(() => {});
}
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.forceRefreshAvailability();
return credential;
}
async logout(providerId: string): Promise<void> {
await this.models.logout(providerId);
await this.forceRefreshAvailability();
}
async reloadConfig(): Promise<void> {
this.config = await ModelConfig.load(this.modelsPath);
this.rebuildProviders();
await this.forceRefreshAvailability();
}
async refresh(providerId?: string): Promise<void> {
await this.models.refresh(providerId);
this.updateModelSnapshot();
await this.forceRefreshAvailability();
}
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.forceRefreshAvailability().catch(() => {});
}
unregisterProvider(providerId: string): void {
this.extensionProviders.delete(providerId);
this.recomposeProvider(providerId);
this.updateModelSnapshot();
void this.forceRefreshAvailability().catch(() => {});
}
}
@@ -0,0 +1,513 @@
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;
}
/** 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];
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
if (
!config.models?.length &&
!config.baseUrl &&
!config.headers &&
!config.compat &&
!hasOverrides &&
!config.apiKey &&
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.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);
// models.json modelOverrides are the topmost user-config layer: they apply once,
// after custom-model upserts and extension model replacement.
const getModels = () =>
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension).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 ? () => base.refreshModels!() : 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,35 +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",
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)",
};
@@ -0,0 +1,48 @@
import type { Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
/** Async credential store overlay for non-persistent runtime API keys. */
export class RuntimeCredentials implements CredentialStore {
private readonly store: CredentialStore;
private readonly overrides = new Map<string, string>();
constructor(store: CredentialStore) {
this.store = store;
}
setRuntimeApiKey(providerId: string, apiKey: string): void {
this.overrides.set(providerId, apiKey);
}
removeRuntimeApiKey(providerId: string): void {
this.overrides.delete(providerId);
}
hasRuntimeApiKey(providerId: string): boolean {
return this.overrides.has(providerId);
}
async read(providerId: string): Promise<Credential | undefined> {
const override = this.overrides.get(providerId);
return override ? { type: "api_key", key: override } : this.store.read(providerId);
}
async list(): Promise<readonly CredentialInfo[]> {
const entries = new Map((await this.store.list()).map((entry) => [entry.providerId, entry]));
for (const providerId of this.overrides.keys()) {
entries.set(providerId, { providerId, type: "api_key" });
}
return [...entries.values()];
}
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.store.modify(providerId, fn);
}
async delete(providerId: string): Promise<void> {
this.overrides.delete(providerId);
await this.store.delete(providerId);
}
}
+21 -35
View File
@@ -1,16 +1,15 @@
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
@@ -37,10 +36,8 @@ export interface CreateAgentSessionOptions {
/** Global config directory. Default: ~/.pi/agent */
agentDir?: string;
/** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */
authStorage?: AuthStorage;
/** Model registry. Default: ModelRegistry.create(authStorage, agentDir/models.json) */
modelRegistry?: ModelRegistry;
/** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */
modelRuntime?: ModelRuntime;
/** Model to use. Default: from settings, else first available */
model?: Model<any>;
@@ -169,11 +166,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const authStorage = options.authStorage ?? AuthStorage.create(authPath);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath);
const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
@@ -194,8 +189,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// If session has data, try to restore model from it
if (!model && hasExistingSession && existingSession.model) {
const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) {
const restoredModel = modelRuntime.getModel(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
model = restoredModel;
}
if (!model) {
@@ -211,7 +206,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRegistry,
modelRuntime,
});
model = result.model;
if (!model) {
@@ -300,11 +295,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const auth = await modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) {
throw new Error(auth.error);
}
const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined;
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
@@ -313,28 +303,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
let headers = mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
auth.headers,
options?.headers,
);
// Let extensions inject/adjust per-request headers (e.g. tracing, session correlation)
// after static assembly, before the provider HTTP call.
const headerRunner = extensionRunnerRef.current;
if (headerRunner?.hasHandlers("before_provider_headers")) {
headers = await headerRunner.emitBeforeProviderHeaders(headers ?? {});
}
return streamSimple(model, context, {
return modelRuntime.streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
env,
timeoutMs,
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
headers,
transformHeaders: async (requestHeaders) => {
const headers = mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
requestHeaders,
);
return headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers ?? {})
: (headers ?? {});
},
});
},
onPayload: async (payload, _model) => {
@@ -390,7 +376,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
scopedModels: options.scopedModels,
resourceLoader,
customTools: options.customTools,
modelRegistry,
modelRuntime,
initialActiveToolNames,
allowedToolNames,
excludedToolNames,
+6 -11
View File
@@ -23,17 +23,7 @@ export {
parseSkillBlock,
type SessionStats,
} from "./core/agent-session.ts";
// Auth and model registry
export {
type ApiKeyCredential,
type AuthCredential,
type AuthStatus,
AuthStorage,
type AuthStorageBackend,
FileAuthStorageBackend,
InMemoryAuthStorageBackend,
type OAuthCredential,
} from "./core/auth-storage.ts";
export { readStoredCredential } from "./core/auth-storage.ts";
// Compaction
export {
type BranchPreparation,
@@ -178,6 +168,11 @@ export {
resolveModelScopeWithDiagnostics,
type ScopedModel,
} from "./core/model-resolver.ts";
export {
type CreateModelRuntimeOptions,
ModelRuntime,
type ModelRuntimeAuthOverrides,
} from "./core/model-runtime.ts";
export type {
PackageManager,
PathMetadata,
+12 -14
View File
@@ -23,12 +23,11 @@ import {
createAgentSessionServices,
} from "./core/agent-session-services.ts";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { InlineExtension } from "./core/extensions/types.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import type { ModelRuntime } from "./core/model-runtime.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
@@ -358,7 +357,7 @@ function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
hasExistingSession: boolean,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
settingsManager: SettingsManager,
): {
options: CreateAgentSessionOptions;
@@ -377,7 +376,7 @@ function buildSessionOptions(
cliProvider: parsed.provider,
cliModel: parsed.model,
cliThinking: parsed.thinking,
modelRegistry,
modelRuntime,
});
if (resolved.warning) {
diagnostics.push({ type: "warning", message: resolved.warning });
@@ -400,7 +399,7 @@ function buildSessionOptions(
// Check if saved default is in scoped models - use it if so, otherwise first scoped model
const savedProvider = settingsManager.getDefaultProvider();
const savedModelId = settingsManager.getDefaultModel();
const savedModel = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined;
const savedModel = savedProvider && savedModelId ? modelRuntime.getModel(savedProvider, savedModelId) : undefined;
const savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;
if (savedInScope) {
@@ -433,7 +432,7 @@ function buildSessionOptions(
}));
}
// API key from CLI - set in authStorage
// API key from CLI - set as a non-persistent runtime override
// (handled by caller before createAgentSession)
// Tools
@@ -611,7 +610,6 @@ export async function main(args: string[], options?: MainOptions) {
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
const authStorage = AuthStorage.create();
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
@@ -634,7 +632,6 @@ export async function main(args: string[], options?: MainOptions) {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderReloadOptions: shouldResolveProjectTrust
@@ -676,7 +673,7 @@ export async function main(args: string[], options?: MainOptions) {
extensionFactories: options?.extensionFactories,
},
});
const { settingsManager, modelRegistry, resourceLoader } = services;
const { settingsManager, modelRuntime, resourceLoader } = services;
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics,
...services.diagnostics,
@@ -689,7 +686,7 @@ export async function main(args: string[], options?: MainOptions) {
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
const scopedModels =
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : [];
modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRuntime) : [];
const {
options: sessionOptions,
cliThinkingFromModel,
@@ -698,7 +695,7 @@ export async function main(args: string[], options?: MainOptions) {
parsed,
scopedModels,
sessionManager.buildSessionContext().messages.length > 0,
modelRegistry,
modelRuntime,
settingsManager,
);
diagnostics.push(...sessionOptionDiagnostics);
@@ -710,7 +707,8 @@ export async function main(args: string[], options?: MainOptions) {
message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
});
} else {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
await services.modelRuntime.getAvailable();
}
}
@@ -745,7 +743,7 @@ export async function main(args: string[], options?: MainOptions) {
});
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
const { settingsManager, modelRuntime, resourceLoader } = services;
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
@@ -759,7 +757,7 @@ export async function main(args: string[], options?: MainOptions) {
if (parsed.listModels !== undefined) {
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRegistry, searchPattern);
await listModels(modelRuntime, searchPattern);
process.exit(0);
}
@@ -138,7 +138,7 @@ export class FooterComponent implements Component {
statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
}
// Show cost with "(sub)" indicator if using OAuth subscription
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
const usingSubscription = state.model ? this.session.modelRuntime.isUsingOAuth(state.model.provider) : false;
if (totalCost || usingSubscription) {
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statsParts.push(costStr);
@@ -1,4 +1,4 @@
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@earendil-works/pi-ai";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { openBrowser } from "../../../utils/open-browser.ts";
import { theme } from "../theme/theme.ts";
@@ -38,8 +38,7 @@ export class LoginDialogComponent extends Container implements Focusable {
this.tui = tui;
this.onComplete = onComplete;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerNameOverride || providerInfo?.name || providerId;
const providerName = providerNameOverride || providerId;
const title = titleOverride ?? `Login to ${providerName}`;
// Top border
@@ -176,17 +175,19 @@ export class LoginDialogComponent extends Container implements Focusable {
});
}
/**
* Show informational text without prompting for input.
*/
showInfo(lines: string[]): void {
this.contentContainer.clear();
/** Show provider-owned information and links without starting an auth callback flow. */
showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
this.contentContainer.addChild(new Spacer(1));
for (const line of lines) {
this.contentContainer.addChild(new Text(line, 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.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0));
this.tui.requestRender();
}
@@ -9,7 +9,7 @@ import {
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { ModelRuntime } from "../../../core/model-runtime.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
@@ -52,7 +52,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
private selectedIndex: number = 0;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage?: string;
@@ -66,7 +66,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
tui: TUI,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
modelRuntime: ModelRuntime,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
@@ -77,7 +77,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.modelRegistry = modelRegistry;
this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
@@ -139,17 +139,17 @@ export class ModelSelectorComponent extends Container implements Focusable {
let models: ModelItem[];
// Refresh to pick up any changes to models.json
this.modelRegistry.refresh();
await this.modelRuntime.refresh();
// Check for models.json errors
const loadError = this.modelRegistry.getError();
const loadError = this.modelRuntime.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();
const availableModels = await this.modelRuntime.getAvailable();
models = availableModels.map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
@@ -166,7 +166,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => {
const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id);
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
return refreshed ? { ...scoped, model: refreshed } : scoped;
});
this.scopedModelItems = this.scopedModels.map((scoped) => ({
@@ -1,3 +1,4 @@
import type { ApiKeyAuth, AuthCheck, OAuthAuth } from "@earendil-works/pi-ai";
import {
Container,
type Focusable,
@@ -7,7 +8,6 @@ import {
Spacer,
TruncatedText,
} from "@earendil-works/pi-tui";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
@@ -15,6 +15,8 @@ export type AuthSelectorProvider = {
id: string;
name: string;
authType: "oauth" | "api_key";
method?: ApiKeyAuth | OAuthAuth;
status?: AuthCheck;
};
export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string {
@@ -42,26 +44,20 @@ export class OAuthSelectorComponent extends Container implements Focusable {
private filteredProviders: AuthSelectorProvider[];
private selectedIndex: number = 0;
private mode: "login" | "logout";
private authStorage: AuthStorage;
private getAuthStatus: (providerId: string) => AuthStatus;
private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void;
private onCancelCallback: () => void;
private showAuthTypeLabels: boolean;
constructor(
mode: "login" | "logout",
authStorage: AuthStorage,
providers: AuthSelectorProvider[],
onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void,
onCancel: () => void,
getAuthStatus?: (providerId: string) => AuthStatus,
initialSearchInput?: string,
) {
super();
this.mode = mode;
this.authStorage = authStorage;
this.getAuthStatus = getAuthStatus ?? ((providerId) => this.authStorage.getAuthStatus(providerId));
this.allProviders = providers;
this.filteredProviders = providers;
this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1;
@@ -105,7 +101,11 @@ export class OAuthSelectorComponent extends Container implements Focusable {
private filterProviders(query: string): void {
this.filteredProviders = query
? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType}`)
? fuzzyFilter(
this.allProviders,
query,
(provider) => `${provider.name} ${provider.id} ${provider.authType} ${provider.method?.name ?? ""}`,
)
: this.allProviders;
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1)));
this.updateList();
@@ -162,29 +162,22 @@ export class OAuthSelectorComponent extends Container implements Focusable {
}
private formatStatusIndicator(provider: AuthSelectorProvider): string {
const credential = this.authStorage.get(provider.id);
if (credential?.type === provider.authType) return theme.fg("success", " ✓ configured");
if (credential) {
const label = credential.type === "oauth" ? "subscription configured" : "API key configured";
if (!provider.status) return theme.fg("muted", " • unconfigured");
if (provider.status.type !== provider.authType) {
const label = provider.status.type === "oauth" ? "subscription configured" : "API key configured";
return theme.fg("muted", " • ") + theme.fg("warning", label);
}
if (provider.authType !== "api_key") return theme.fg("muted", " • unconfigured");
const status = this.getAuthStatus(provider.id);
switch (status.source) {
case "environment":
return theme.fg("success", ` ✓ env: ${status.label ?? "API key"}`);
case "runtime":
return theme.fg("success", " ✓ runtime API key");
case "fallback":
return theme.fg("success", " ✓ custom API key");
case "models_json_key":
return theme.fg("success", " ✓ key in models.json");
case "models_json_command":
return theme.fg("success", " ✓ command in models.json");
default:
return theme.fg("muted", " • unconfigured");
if (
!provider.status.source ||
provider.status.source === "OAuth" ||
provider.status.source === "stored credential"
) {
return theme.fg("success", " ✓ configured");
}
const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source)
? `env: ${provider.status.source}`
: provider.status.source;
return theme.fg("success", `${source}`);
}
handleInput(keyData: string): void {
@@ -8,15 +8,8 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
type AssistantMessage,
getProviders,
type ImageContent,
type Message,
type Model,
type OAuthProviderId,
type OAuthSelectPrompt,
} from "@earendil-works/pi-ai/compat";
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat";
import type {
AutocompleteItem,
AutocompleteProvider,
@@ -54,7 +47,6 @@ import {
getAgentDir,
getAuthPath,
getDebugLogPath,
getDocsPath,
getShareViewerUrl,
VERSION,
} from "../../config.ts";
@@ -85,7 +77,6 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.t
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";
@@ -212,7 +203,7 @@ function isDeadTerminalError(error: unknown): boolean {
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
@@ -248,24 +239,6 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof
return providerId in defaultModelPerProvider;
}
const BEDROCK_PROVIDER_ID = "amazon-bedrock";
const BUILT_IN_MODEL_PROVIDERS = new Set<string>(getProviders());
export function isApiKeyLoginProvider(
providerId: string,
oauthProviderIds: ReadonlySet<string>,
builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS,
): boolean {
if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) {
return true;
}
if (builtInProviderIds.has(providerId)) {
return false;
}
return !oauthProviderIds.has(providerId);
}
type LoginProviderCompletionOption = {
id: string;
name: string;
@@ -571,12 +544,12 @@ export class InteractiveMode {
const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) {
modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
modelCommand.getArgumentCompletions = async (prefix: string): Promise<AutocompleteItem[] | null> => {
// Get available models (scoped or from registry)
const models =
this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model)
: this.session.modelRegistry.getAvailable();
: await this.session.modelRuntime.getAvailable();
if (models.length === 0) return null;
@@ -879,7 +852,7 @@ export class InteractiveMode {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
}
const modelsJsonError = this.session.modelRegistry.getError();
const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
@@ -1779,7 +1752,7 @@ export class InteractiveMode {
hasUI: true,
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
modelRegistry: extensionRunner.getModelRegistry(),
model: this.session.model,
isIdle: () => this.session.isIdle,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
@@ -3288,7 +3261,7 @@ export class InteractiveMode {
// Cache-miss notices are not persisted; re-derive them from the full entry
// list and re-inject them after the assistant messages that paid for them.
const cacheMisses = this.settingsManager.getShowCacheMissNotices()
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRegistry)
? collectCacheMisses(this.sessionManager.getEntries(), this.session.modelRuntime)
: new Map<AssistantMessage, CacheMiss>();
if (options.updateFooter) {
@@ -3392,7 +3365,7 @@ export class InteractiveMode {
if (!this.settingsManager.getShowCacheMissNotices()) return;
// Entries don't contain `message` yet: message_end fires before persistence.
const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRegistry);
const miss = detectCacheMiss(this.sessionManager.getEntries(), message, this.session.modelRuntime);
if (miss) this.addCacheMissNotice(miss);
}
@@ -4325,9 +4298,9 @@ export class InteractiveMode {
return this.session.scopedModels.map((scoped) => scoped.model);
}
this.session.modelRegistry.refresh();
try {
return await this.session.modelRegistry.getAvailable();
await this.session.modelRuntime.refresh();
return [...(await this.session.modelRuntime.getAvailable())];
} catch {
return [];
}
@@ -4353,15 +4326,13 @@ export class InteractiveMode {
return;
}
const storedCredential = this.session.modelRegistry.authStorage.get("anthropic");
if (storedCredential?.type === "oauth") {
this.anthropicSubscriptionWarningShown = true;
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
return;
}
try {
const apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);
if ((await this.session.modelRuntime.checkAuth("anthropic"))?.type === "oauth") {
this.anthropicSubscriptionWarningShown = true;
this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING);
return;
}
const apiKey = (await this.session.modelRuntime.getAuth(model.provider))?.auth.apiKey;
if (!isAnthropicSubscriptionAuthKey(apiKey)) {
return;
}
@@ -4429,7 +4400,7 @@ export class InteractiveMode {
this.ui,
this.session.model,
this.settingsManager,
this.session.modelRegistry,
this.session.modelRuntime,
this.session.scopedModels,
async (model) => {
try {
@@ -4457,8 +4428,8 @@ export class InteractiveMode {
private async showModelsSelector(): Promise<void> {
// Get all available models
this.session.modelRegistry.refresh();
const allModels = this.session.modelRegistry.getAvailable();
await this.session.modelRuntime.refresh();
const allModels = [...(await this.session.modelRuntime.getAvailable())];
if (allModels.length === 0) {
this.showStatus("No models available");
@@ -4479,7 +4450,7 @@ export class InteractiveMode {
// Fall back to settings
const patterns = this.settingsManager.getEnabledModels();
if (patterns !== undefined && patterns.length > 0) {
const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
const scopedModels = await resolveModelScope(patterns, this.session.modelRuntime);
currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
}
}
@@ -4488,7 +4459,7 @@ export class InteractiveMode {
const updateSessionModels = async (enabledIds: string[] | null) => {
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry);
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRuntime);
this.session.setScopedModels(
newScopedModels.map((sm) => ({
model: sm.model,
@@ -4790,48 +4761,46 @@ export class InteractiveMode {
}
private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] {
const authStorage = this.session.modelRegistry.authStorage;
const oauthProviders = authStorage.getOAuthProviders();
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
const options: AuthSelectorProvider[] = oauthProviders.map((provider) => ({
id: provider.id,
name: provider.name,
authType: "oauth",
}));
const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider));
for (const providerId of modelProviders) {
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) {
continue;
const options: AuthSelectorProvider[] = [];
for (const provider of this.session.modelRuntime.getProviders()) {
const authStatus = this.session.modelRuntime.getProviderAuthStatus(provider.id);
const status = authStatus.configured
? {
type: this.session.modelRuntime.isUsingOAuth(provider.id) ? ("oauth" as const) : ("api_key" as const),
source: authStatus.label ?? authStatus.source,
}
: undefined;
if ((!authType || authType === "oauth") && provider.auth.oauth) {
options.push({
id: provider.id,
name: provider.name,
authType: "oauth",
method: provider.auth.oauth,
status,
});
}
if ((!authType || authType === "api_key") && provider.auth.apiKey) {
options.push({
id: provider.id,
name: provider.name,
authType: "api_key",
method: provider.auth.apiKey,
status,
});
}
options.push({
id: providerId,
name: this.session.modelRegistry.getProviderDisplayName(providerId),
authType: "api_key",
});
}
const filteredOptions = authType ? options.filter((option) => option.authType === authType) : options;
return filteredOptions.sort((a, b) => a.name.localeCompare(b.name));
return options.sort((a, b) => a.name.localeCompare(b.name));
}
private getLogoutProviderOptions(): AuthSelectorProvider[] {
const authStorage = this.session.modelRegistry.authStorage;
const options: AuthSelectorProvider[] = [];
for (const providerId of authStorage.list()) {
const credential = authStorage.get(providerId);
if (!credential) {
continue;
}
options.push({
private async getLogoutProviderOptions(): Promise<AuthSelectorProvider[]> {
return (await this.session.modelRuntime.listCredentials())
.map(({ providerId, type }) => ({
id: providerId,
name: this.session.modelRegistry.getProviderDisplayName(providerId),
authType: credential.type,
});
}
return options.sort((a, b) => a.name.localeCompare(b.name));
name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId,
authType: type,
status: { type, source: "stored credential" },
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] {
@@ -4848,6 +4817,7 @@ export class InteractiveMode {
}
private async handleLoginCommand(providerRef?: string): Promise<void> {
await this.session.modelRuntime.getAvailable();
if (!providerRef) {
this.showLoginAuthTypeSelector();
return;
@@ -4873,10 +4843,10 @@ export class InteractiveMode {
private async startProviderLogin(providerOption: AuthSelectorProvider): Promise<void> {
if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name);
} else if (providerOption.id === BEDROCK_PROVIDER_ID) {
this.showBedrockSetupDialog(providerOption.id, providerOption.name);
} else {
} else if (providerOption.method?.login) {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
} else {
this.showAmbientAuthDialog(providerOption);
}
}
@@ -4951,7 +4921,6 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
"login",
this.session.modelRegistry.authStorage,
providerOptions,
async (providerId, selectedAuthType) => {
done();
@@ -4973,7 +4942,6 @@ export class InteractiveMode {
this.ui.requestRender();
}
},
(providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId),
initialSearchInput,
);
return { component: selector, focus: selector };
@@ -4986,7 +4954,7 @@ export class InteractiveMode {
return;
}
const providerOptions = this.getLogoutProviderOptions();
const providerOptions = await this.getLogoutProviderOptions();
if (providerOptions.length === 0) {
this.showStatus(
"No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.",
@@ -4997,7 +4965,6 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
mode,
this.session.modelRegistry.authStorage,
providerOptions,
async (providerId: string) => {
done();
@@ -5008,8 +4975,7 @@ export class InteractiveMode {
}
try {
this.session.modelRegistry.authStorage.logout(providerOption.id);
this.session.modelRegistry.refresh();
await this.session.modelRuntime.logout(providerOption.id);
await this.updateAvailableProviderCount();
const message =
providerOption.authType === "oauth"
@@ -5035,14 +5001,14 @@ export class InteractiveMode {
authType: "oauth" | "api_key",
previousModel: Model<any> | undefined,
): Promise<void> {
this.session.modelRegistry.refresh();
await this.session.modelRuntime.getAvailable();
const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`;
let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
const availableModels = this.session.modelRegistry.getAvailable();
const availableModels = await this.session.modelRuntime.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
@@ -5082,7 +5048,7 @@ export class InteractiveMode {
}
}
private showBedrockSetupDialog(providerId: string, providerName: string): void {
private showAmbientAuthDialog(providerOption: AuthSelectorProvider): void {
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
@@ -5092,17 +5058,12 @@ export class InteractiveMode {
const dialog = new LoginDialogComponent(
this.ui,
providerId,
providerOption.id,
() => restoreEditor(),
providerName,
"Amazon Bedrock setup",
providerOption.name,
`${providerOption.name} setup`,
);
dialog.showInfo([
theme.fg("text", "Amazon Bedrock uses AWS credentials instead of a single API key."),
theme.fg("text", "Configure an AWS profile, IAM keys, bearer token, or role-based credentials."),
theme.fg("muted", "See:"),
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
]);
dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside pi.`, [], true);
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
@@ -5135,13 +5096,7 @@ export class InteractiveMode {
};
try {
const apiKey = (await dialog.showPrompt("Enter API key:")).trim();
if (!apiKey) {
throw new Error("API key cannot be empty.");
}
this.session.modelRegistry.authStorage.set(providerId, { type: "api_key", key: apiKey });
await this.loginProvider(dialog, providerId, "api_key");
restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel);
} catch (error: unknown) {
@@ -5153,8 +5108,11 @@ export class InteractiveMode {
}
}
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> {
return new Promise((resolve) => {
private showAuthSelect(
dialog: LoginDialogComponent,
prompt: Extract<AuthPrompt, { type: "select" }>,
): Promise<string> {
return new Promise((resolve, reject) => {
const restoreDialog = () => {
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
@@ -5167,11 +5125,13 @@ export class InteractiveMode {
labels,
(optionLabel) => {
restoreDialog();
resolve(prompt.options.find((option) => option.label === optionLabel)?.id);
const id = prompt.options.find((option) => option.label === optionLabel)?.id;
if (id) resolve(id);
else reject(new Error("Login cancelled"));
},
() => {
restoreDialog();
resolve(undefined);
reject(new Error("Login cancelled"));
},
);
this.editorContainer.clear();
@@ -5181,40 +5141,63 @@ export class InteractiveMode {
});
}
private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise<string> {
let response: Promise<string>;
if (prompt.type === "select") {
response = this.showAuthSelect(dialog, prompt);
} else if (prompt.type === "manual_code") {
response = dialog.showManualInput(prompt.message);
} else {
response = dialog.showPrompt(prompt.message, prompt.placeholder);
}
if (!prompt.signal) return response;
if (prompt.signal.aborted) throw new Error("Login cancelled");
const signal = prompt.signal;
let onAbort: (() => void) | undefined;
const aborted = new Promise<string>((_resolve, reject) => {
onAbort = () => reject(new Error("Login cancelled"));
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([response, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
private notifyAuthDialog(dialog: LoginDialogComponent, event: AuthEvent): void {
if (event.type === "auth_url") {
dialog.showAuth(event.url, event.instructions);
} else if (event.type === "device_code") {
dialog.showDeviceCode(event);
dialog.showWaiting("Waiting for authentication...");
} else if (event.type === "info") {
dialog.showInfo(event.message, event.links);
} else {
dialog.showProgress(event.message);
}
}
private async loginProvider(
dialog: LoginDialogComponent,
providerId: string,
method: "api_key" | "oauth",
): Promise<void> {
await this.session.modelRuntime.login(providerId, method, {
signal: dialog.signal,
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
notify: (event) => this.notifyAuthDialog(dialog, event),
});
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage
.getOAuthProviders()
.find((provider) => provider.id === providerId);
const previousModel = this.session.model;
// Providers that use callback servers (can paste redirect URL)
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
// Create login dialog component
const dialog = new LoginDialogComponent(
this.ui,
providerId,
(_success, _message) => {
// Completion handled below
},
providerName,
);
// Show dialog in editor container
const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => {}, providerName);
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
// Promise for manual code input (racing with callback server)
let manualCodeResolve: ((code: string) => void) | undefined;
let manualCodeReject: ((err: Error) => void) | undefined;
const manualCodePromise = new Promise<string>((resolve, reject) => {
manualCodeResolve = resolve;
manualCodeReject = reject;
});
// Restore editor helper
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
@@ -5223,51 +5206,7 @@ export class InteractiveMode {
};
try {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProviderId, {
onAuth: (info: { url: string; instructions?: string }) => {
dialog.showAuth(info.url, info.instructions);
if (usesCallbackServer) {
// Show input for manual paste, racing with callback
dialog
.showManualInput("Paste redirect URL below, or complete login in browser:")
.then((value) => {
if (value && manualCodeResolve) {
manualCodeResolve(value);
manualCodeResolve = undefined;
}
})
.catch(() => {
if (manualCodeReject) {
manualCodeReject(new Error("Login cancelled"));
manualCodeReject = undefined;
}
});
}
// For Anthropic: onPrompt is called immediately after
},
onDeviceCode: (info) => {
dialog.showDeviceCode(info);
dialog.showWaiting("Waiting for authentication...");
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
return dialog.showPrompt(prompt.message, prompt.placeholder);
},
onProgress: (message: string) => {
dialog.showProgress(message);
},
onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt),
onManualCodeInput: () => manualCodePromise,
signal: dialog.signal,
});
// Success
await this.loginProvider(dialog, providerId, "oauth");
restoreEditor();
await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel);
} catch (error: unknown) {
@@ -5368,7 +5307,7 @@ export class InteractiveMode {
showDiagnosticsWhenQuiet: true,
});
const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload();
const modelsJsonError = this.session.modelRegistry.getError();
const modelsJsonError = this.session.modelRuntime.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
@@ -5613,7 +5552,7 @@ export class InteractiveMode {
const stats = this.session.getSessionStats();
const sessionName = this.sessionManager.getSessionName();
const entries = this.sessionManager.getEntries();
const cacheWaste = computeCacheWaste(entries, this.session.modelRegistry);
const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime);
// Cost/token totals per provider/model actually used (e.g. OpenRouter `auto`
// resolves to a concrete responseModel), sorted by cost descending.
@@ -465,7 +465,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// =================================================================
case "set_model": {
const models = await session.modelRegistry.getAvailable();
const models = await session.modelRuntime.getAvailable();
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
if (!model) {
return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
@@ -483,7 +483,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
}
case "get_available_models": {
const models = await session.modelRegistry.getAvailable();
const models = await session.modelRuntime.getAvailable();
return success(id, "get_available_models", { models });
}
@@ -7,9 +7,9 @@ import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
describe("AgentSession auto-compaction queue resume", () => {
@@ -18,7 +18,7 @@ describe("AgentSession auto-compaction queue resume", () => {
let settingsManager: SettingsManager;
let tempDir: string;
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
vi.useFakeTimers();
@@ -35,15 +35,15 @@ describe("AgentSession auto-compaction queue resume", () => {
sessionManager = SessionManager.inMemory();
settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const modelRegistry = await createModelRegistry(authStorage, tempDir);
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
});
@@ -48,7 +48,7 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: API_KEY! }));
const servicesOptions = {
agentDir: tempDir,
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* E2E tests for AgentSession compaction behavior.
*
@@ -15,7 +16,6 @@ import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createCodingTools } from "../src/index.ts";
@@ -27,7 +27,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
let sessionManager: SessionManager;
let events: AgentSessionEvent[];
beforeEach(() => {
beforeEach(async () => {
// Create temp directory for session files
tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
@@ -45,7 +45,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}
});
function createSession(inMemory = false) {
async function createSession(inMemory = false) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
@@ -61,14 +61,14 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
// Use minimal keepRecentTokens so small test conversations have something to summarize
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -81,7 +81,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}
it("should trigger manual compaction via compact()", async () => {
createSession();
await createSession();
// Send a few prompts to build up history
await session.prompt("What is 2+2? Reply with just the number.");
@@ -107,7 +107,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should maintain valid session state after compaction", async () => {
createSession();
await createSession();
// Build up history
await session.prompt("What is the capital of France? One word answer.");
@@ -132,7 +132,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 180000);
it("should persist compaction to session file", async () => {
createSession();
await createSession();
await session.prompt("Say hello");
await session.agent.waitForIdle();
@@ -160,7 +160,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should work with --no-session mode (in-memory only)", async () => {
createSession(true); // in-memory mode
await createSession(true); // in-memory mode
// Send prompts
await session.prompt("What is 2+2? Reply with just the number.");
@@ -182,7 +182,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}, 120000);
it("should emit compaction events during manual compaction", async () => {
createSession();
await createSession();
// Build some history
await session.prompt("Say hello");
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* Tests for AgentSession concurrent prompt guard.
*/
@@ -18,7 +19,6 @@ import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts";
@@ -62,7 +62,7 @@ describe("AgentSession concurrent prompt guard", () => {
let session: AgentSession;
let tempDir: string;
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
@@ -78,7 +78,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
});
function createSession() {
async function createSession() {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
let abortSignal: AbortSignal | undefined;
@@ -111,16 +111,16 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
const modelRegistry = await createModelRegistry(authStorage, tempDir);
// Set a runtime API key so validation passes
authStorage.setRuntimeApiKey("anthropic", "test-key");
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -128,7 +128,7 @@ describe("AgentSession concurrent prompt guard", () => {
}
it("should throw when prompt() called while streaming", async () => {
createSession();
await createSession();
// Start first prompt (don't await, it will block until abort)
const firstPrompt = session.prompt("First message");
@@ -150,7 +150,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow steer() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -166,7 +166,7 @@ describe("AgentSession concurrent prompt guard", () => {
});
it("should allow followUp() while streaming", async () => {
createSession();
await createSession();
// Start first prompt
const firstPrompt = session.prompt("First message");
@@ -236,8 +236,8 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const extensionsResult = await createTestExtensionsResult([
(pi) => {
@@ -255,7 +255,7 @@ describe("AgentSession concurrent prompt guard", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader({ extensionsResult }),
});
session.subscribe((event) => {
@@ -314,15 +314,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -420,15 +420,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool },
});
@@ -567,15 +567,15 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
baseToolsOverride: { dummy: tool },
});
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
import type { ExtensionFactory } from "../src/core/sdk.ts";
import { createAgentSession } from "../src/core/sdk.ts";
@@ -30,7 +31,11 @@ describe("AgentSession dynamic provider registration", () => {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(agentDir, "models.json"),
});
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
@@ -45,7 +50,7 @@ describe("AgentSession dynamic provider registration", () => {
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager,
sessionManager,
authStorage,
modelRuntime,
resourceLoader,
});
@@ -7,9 +7,9 @@ import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -54,7 +54,7 @@ describe("AgentSession retry", () => {
let session: AgentSession;
let tempDir: string;
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-retry-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
@@ -68,7 +68,11 @@ describe("AgentSession retry", () => {
}
});
function createSession(options?: { failCount?: number; maxRetries?: number; delayAssistantMessageEndMs?: number }) {
async function createSession(options?: {
failCount?: number;
maxRetries?: number;
delayAssistantMessageEndMs?: number;
}) {
const failCount = options?.failCount ?? 1;
const maxRetries = options?.maxRetries ?? 3;
const delayAssistantMessageEndMs = options?.delayAssistantMessageEndMs ?? 0;
@@ -102,8 +106,8 @@ describe("AgentSession retry", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries, baseDelayMs: 1 } });
session = new AgentSession({
@@ -111,7 +115,7 @@ describe("AgentSession retry", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -130,7 +134,7 @@ describe("AgentSession retry", () => {
}
it("retries after a transient error and succeeds", async () => {
const created = createSession({ failCount: 1 });
const created = await createSession({ failCount: 1 });
const events: string[] = [];
created.session.subscribe((event) => {
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
@@ -145,7 +149,7 @@ describe("AgentSession retry", () => {
});
it("exhausts max retries and emits failure", async () => {
const created = createSession({ failCount: 99, maxRetries: 2 });
const created = await createSession({ failCount: 99, maxRetries: 2 });
const events: string[] = [];
created.session.subscribe((event) => {
if (event.type === "auto_retry_start") events.push(`start:${event.attempt}`);
@@ -162,7 +166,7 @@ describe("AgentSession retry", () => {
});
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
const created = createSession({ failCount: 1, delayAssistantMessageEndMs: 40 });
const created = await createSession({ failCount: 1, delayAssistantMessageEndMs: 40 });
await created.session.prompt("Test");
@@ -171,7 +175,7 @@ describe("AgentSession retry", () => {
});
it("retries provider network_error failures", async () => {
const created = createSession({ failCount: 0 });
const created = await createSession({ failCount: 0 });
let callCount = 0;
const streamFn = () => {
callCount++;
@@ -204,15 +208,15 @@ describe("AgentSession retry", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -289,8 +293,8 @@ describe("AgentSession retry", () => {
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = await createModelRegistry(authStorage, tempDir);
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } });
session = new AgentSession({
@@ -298,7 +302,7 @@ describe("AgentSession retry", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
baseToolsOverride: { echo: echoTool },
});
@@ -10,6 +10,7 @@ import {
createAgentSessionServices,
} from "../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import type {
ExtensionFactory,
@@ -42,11 +43,33 @@ describe("AgentSessionRuntime session lifecycle events", () => {
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(tempDir, "models.json"),
});
const model = faux.getModel();
modelRuntime.registerProvider(model.provider, {
baseUrl: model.baseUrl,
api: model.api,
models: [
{
id: model.id,
name: model.name,
api: model.api,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
baseUrl: model.baseUrl,
},
],
});
const runtimeOptions = {
agentDir: tempDir,
authStorage,
modelRuntime,
model: faux.getModel(),
resourceLoaderOptions: {
extensionFactories: [extensionFactory],
@@ -3,9 +3,9 @@ import { type AssistantMessage, getModel, type Usage } from "@earendil-works/pi-
import { describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
const model = getModel("anthropic", "claude-sonnet-4-5")!;
@@ -48,11 +48,11 @@ function createUserMessage(text: string, timestamp: number) {
};
}
function createSession() {
async function createSession() {
const settingsManager = SettingsManager.inMemory();
const sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey("anthropic", "test-key");
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
const session = new AgentSession({
agent: new Agent({
getApiKey: () => "test-key",
@@ -66,7 +66,7 @@ function createSession() {
sessionManager,
settingsManager,
cwd: process.cwd(),
modelRegistry: ModelRegistry.inMemory(authStorage),
modelRuntime: getModelRuntime(await createInMemoryModelRegistry(authStorage)),
resourceLoader: createTestResourceLoader(),
});
@@ -78,8 +78,8 @@ function syncAgentMessages(session: AgentSession, sessionManager: SessionManager
}
describe("AgentSession.getSessionStats", () => {
it("exposes the current context usage alongside token totals", () => {
const { session, sessionManager } = createSession();
it("exposes the current context usage alongside token totals", async () => {
const { session, sessionManager } = await createSession();
try {
sessionManager.appendMessage(createUserMessage("hello", 1));
@@ -96,8 +96,8 @@ describe("AgentSession.getSessionStats", () => {
}
});
it("reports unknown current context usage immediately after compaction", () => {
const { session, sessionManager } = createSession();
it("reports unknown current context usage immediately after compaction", async () => {
const { session, sessionManager } = await createSession();
try {
sessionManager.appendMessage(createUserMessage("first", 1));
@@ -119,8 +119,8 @@ describe("AgentSession.getSessionStats", () => {
}
});
it("uses post-compaction usage for current context instead of stale kept usage", () => {
const { session, sessionManager } = createSession();
it("uses post-compaction usage for current context instead of stale kept usage", async () => {
const { session, sessionManager } = await createSession();
try {
sessionManager.appendMessage(createUserMessage("first", 1));
@@ -143,8 +143,8 @@ describe("AgentSession.getSessionStats", () => {
}
});
it("ignores zero-usage messages when checking for post-compaction context usage", () => {
const { session, sessionManager } = createSession();
it("ignores zero-usage messages when checking for post-compaction context usage", async () => {
const { session, sessionManager } = await createSession();
try {
sessionManager.appendMessage(createUserMessage("first", 1));
@@ -15,8 +15,8 @@ import { API_KEY, createTestSession, type TestSessionContext } from "./utilities
describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => {
let ctx: TestSessionContext;
beforeEach(() => {
ctx = createTestSession({
beforeEach(async () => {
ctx = await createTestSession({
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
settingsOverrides: { compaction: { keepRecentTokens: 1 } },
});
@@ -279,8 +279,8 @@ describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => {
describe.skipIf(!API_KEY)("AgentSession tree navigation - branch scenarios", () => {
let ctx: TestSessionContext;
beforeEach(() => {
ctx = createTestSession({
beforeEach(async () => {
ctx = await createTestSession({
systemPrompt: "You are a helpful assistant. Reply with just a few words.",
});
});
+172 -654
View File
@@ -1,17 +1,14 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
import { createModels, type Provider } from "@earendil-works/pi-ai";
import lockfile from "proper-lockfile";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts";
import * as shellModule from "../src/utils/shell.ts";
describe("AuthStorage", () => {
let tempDir: string;
let authJsonPath: string;
let authStorage: AuthStorage;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-test-auth-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@@ -20,680 +17,201 @@ describe("AuthStorage", () => {
});
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true });
}
clearConfigValueCache();
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
vi.restoreAllMocks();
});
function writeAuthJson(data: Record<string, unknown>) {
function writeAuthJson(data: Record<string, unknown>): void {
writeFileSync(authJsonPath, JSON.stringify(data));
}
function toShPath(value: string): string {
return value.replace(/\\/g, "/").replace(/"/g, '\\"');
}
test("reads and resolves stored API-key credentials", async () => {
const original = process.env.TEST_AUTH_STORAGE_KEY;
process.env.TEST_AUTH_STORAGE_KEY = "environment-key";
try {
writeAuthJson({ anthropic: { type: "api_key", key: "$TEST_AUTH_STORAGE_KEY" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "environment-key" });
} finally {
if (original === undefined) delete process.env.TEST_AUTH_STORAGE_KEY;
else process.env.TEST_AUTH_STORAGE_KEY = original;
}
});
describe("API key resolution", () => {
test("literal API key is returned directly", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "sk-ant-literal-key" },
});
test("resolves command-backed API-key credentials", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "!printf 'command-key'" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "command-key" });
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
test("returns OAuth credentials unchanged", async () => {
const credential = {
type: "oauth" as const,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
};
const storage = AuthStorage.inMemory({ anthropic: credential });
expect(await storage.read("anthropic")).toEqual(credential);
});
expect(apiKey).toBe("sk-ant-literal-key");
test("credential-scoped env takes precedence and remains inspectable", async () => {
writeAuthJson({
anthropic: {
type: "api_key",
key: "$SCOPED_KEY",
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
},
});
const storage = AuthStorage.create(authJsonPath);
expect(await storage.read("anthropic")).toMatchObject({
key: "scoped-value",
env: { SCOPED_KEY: "scoped-value", REGION: "test-region" },
});
});
test("modify persists a credential while preserving unrelated external edits", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "old" } });
const storage = AuthStorage.create(authJsonPath);
writeAuthJson({
anthropic: { type: "api_key", key: "old" },
openai: { type: "api_key", key: "external" },
});
test("apiKey with ! prefix executes command and uses stdout", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo test-api-key-from-command" },
});
await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" }));
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "new" },
openai: { type: "api_key", key: "external" },
});
});
expect(apiKey).toBe("test-api-key-from-command");
test("modify with undefined leaves the current credential unchanged", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
expect(await storage.modify("anthropic", async () => undefined)).toEqual({ type: "api_key", key: "stored" });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored" });
});
test("serializes concurrent modifications", async () => {
writeAuthJson({});
const first = AuthStorage.create(authJsonPath);
const second = AuthStorage.create(authJsonPath);
await Promise.all([
first.modify("anthropic", async () => ({ type: "api_key", key: "anthropic-key" })),
second.modify("openai", async () => ({ type: "api_key", key: "openai-key" })),
]);
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
});
});
test("delete removes one credential while preserving others", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
});
const storage = AuthStorage.create(authJsonPath);
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
google: { type: "api_key", key: "external-key" },
});
await storage.delete("anthropic");
await expect(storage.list()).resolves.toEqual([
{ providerId: "openai", type: "api_key" },
{ providerId: "google", type: "api_key" },
]);
expect(await storage.read("anthropic")).toBeUndefined();
expect(await storage.read("openai")).toEqual({ type: "api_key", key: "openai-key" });
expect(await storage.read("google")).toEqual({ type: "api_key", key: "external-key" });
});
test("in-memory storage implements the same credential-store behavior", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "initial" } });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "initial" });
await storage.modify("anthropic", async () => ({ type: "api_key", key: "updated" }));
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "updated" });
await storage.delete("anthropic");
await expect(storage.list()).resolves.toEqual([]);
});
test("does not write after lock acquisition failure and recovers on retry", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("lock unavailable"));
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow(
"lock unavailable",
);
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "stored" },
});
test("apiKey with ! prefix trims whitespace from command output", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo ' spaced-key '" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("spaced-key");
lockSpy.mockRestore();
await storage.modify("openai", async () => ({ type: "api_key", key: "new" }));
expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({
anthropic: { type: "api_key", key: "stored" },
openai: { type: "api_key", key: "new" },
});
});
test("apiKey with ! prefix handles multiline output (uses trimmed result)", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!printf 'line1\\nline2'" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("line1\nline2");
test("surfaces a compromised OAuth refresh lock and allows a later retry", async () => {
const providerId = "oauth-provider";
writeAuthJson({
[providerId]: {
type: "oauth",
access: "expired-access",
refresh: "refresh-token",
expires: 0,
},
});
test("apiKey with ! prefix returns undefined on command failure", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!exit 1" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBeUndefined();
});
test("apiKey with ! prefix returns undefined on nonexistent command", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!nonexistent-command-12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBeUndefined();
});
test("apiKey with ! prefix returns undefined on empty output", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!printf ''" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBeUndefined();
});
test("apiKey with $ prefix resolves to env value", async () => {
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
try {
writeAuthJson({
anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_API_KEY_12345;
} else {
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey env bag takes precedence over process.env", async () => {
const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345;
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value";
try {
writeAuthJson({
anthropic: {
type: "api_key",
key: "$TEST_AUTH_SCOPED_API_KEY_12345",
env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" },
const storage = AuthStorage.create(authJsonPath);
const provider: Provider = {
id: providerId,
name: "OAuth Provider",
auth: {
oauth: {
name: "OAuth",
login: async () => {
throw new Error("not used");
},
});
authStorage = AuthStorage.create(authJsonPath);
expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value");
expect(authStorage.getProviderEnv("anthropic")).toEqual({
TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value",
});
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_SCOPED_API_KEY_12345;
} else {
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345;
process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value";
const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}";
try {
writeAuthJson({
anthropic: { type: "api_key", key: bracedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("braced-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_BRACED_API_KEY_12345;
} else {
process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey interpolates braced env references inside literals", async () => {
const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left";
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right";
const interpolatedKey = [
"$",
"{TEST_AUTH_INTERPOLATED_PART_A_12345}_$",
"{TEST_AUTH_INTERPOLATED_PART_B_12345}",
].join("");
try {
writeAuthJson({
anthropic: { type: "api_key", key: interpolatedKey },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("left_right");
} finally {
if (originalPartA === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA;
}
if (originalPartB === undefined) {
delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345;
} else {
process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB;
}
}
});
test("apiKey with $$ prefix escapes a leading dollar", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345");
});
test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => {
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
try {
writeAuthJson({
anthropic: { type: "api_key", key: "$!literal-$TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("!literal-env-api-key-value");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_API_KEY_12345;
} else {
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
}
}
});
test("plain API key is used directly even when it matches an env var", async () => {
const originalEnv = process.env.TEST_AUTH_API_KEY_12345;
process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value";
try {
writeAuthJson({
anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("TEST_AUTH_API_KEY_12345");
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_API_KEY_12345;
} else {
process.env.TEST_AUTH_API_KEY_12345 = originalEnv;
}
}
});
test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => {
const originalPublic = process.env.PUBLIC;
process.env.PUBLIC = "C:\\Users\\Public";
try {
writeAuthJson({
opencode: { type: "api_key", key: "public" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("opencode");
expect(apiKey).toBe("public");
} finally {
if (originalPublic === undefined) {
delete process.env.PUBLIC;
} else {
process.env.PUBLIC = originalPublic;
}
}
});
test("apiKey as literal value is used directly when not an env var", async () => {
// Make sure this isn't an env var
delete process.env.literal_api_key_value;
writeAuthJson({
anthropic: { type: "api_key", key: "literal_api_key_value" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("literal_api_key_value");
});
test("apiKey command can use shell features like pipes", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo 'hello world' | tr ' ' '-'" },
});
authStorage = AuthStorage.create(authJsonPath);
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("hello-world");
});
test("command config uses stdin when configured shell requires it", () => {
if (process.platform === "win32") return;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: "/bin/bash",
args: ["-s"],
commandTransport: "stdin",
});
try {
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const nameExpansion = "$" + "{name}";
expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!");
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
describe("caching", () => {
test("command is only executed once per process", async () => {
// Use a command that writes to a file to count invocations
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
authStorage = AuthStorage.create(authJsonPath);
// Call multiple times
await authStorage.getApiKey("anthropic");
await authStorage.getApiKey("anthropic");
await authStorage.getApiKey("anthropic");
// Command should have only run once
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
expect(count).toBe(1);
});
test("cache persists across AuthStorage instances", async () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
// Create multiple AuthStorage instances
const storage1 = AuthStorage.create(authJsonPath);
await storage1.getApiKey("anthropic");
const storage2 = AuthStorage.create(authJsonPath);
await storage2.getApiKey("anthropic");
// Command should still have only run once
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
expect(count).toBe(1);
});
test("clearConfigValueCache allows command to run again", async () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
authStorage = AuthStorage.create(authJsonPath);
await authStorage.getApiKey("anthropic");
// Clear cache and call again
clearConfigValueCache();
await authStorage.getApiKey("anthropic");
// Command should have run twice
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
expect(count).toBe(2);
});
test("different commands are cached separately", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo key-anthropic" },
openai: { type: "api_key", key: "!echo key-openai" },
});
authStorage = AuthStorage.create(authJsonPath);
const keyA = await authStorage.getApiKey("anthropic");
const keyB = await authStorage.getApiKey("openai");
expect(keyA).toBe("key-anthropic");
expect(keyB).toBe("key-openai");
});
test("failed commands are cached (not retried)", async () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const counterPath = toShPath(counterFile);
const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; exit 1'`;
writeAuthJson({
anthropic: { type: "api_key", key: command },
});
authStorage = AuthStorage.create(authJsonPath);
// Call multiple times - all should return undefined
const key1 = await authStorage.getApiKey("anthropic");
const key2 = await authStorage.getApiKey("anthropic");
expect(key1).toBeUndefined();
expect(key2).toBeUndefined();
// Command should have only run once despite failures
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
expect(count).toBe(1);
});
test("environment variables are not cached (changes are picked up)", async () => {
const envVarName = "TEST_AUTH_KEY_CACHE_TEST_98765";
const originalEnv = process.env[envVarName];
try {
process.env[envVarName] = "first-value";
writeAuthJson({
anthropic: { type: "api_key", key: `$${envVarName}` },
});
authStorage = AuthStorage.create(authJsonPath);
const key1 = await authStorage.getApiKey("anthropic");
expect(key1).toBe("first-value");
// Change env var
process.env[envVarName] = "second-value";
const key2 = await authStorage.getApiKey("anthropic");
expect(key2).toBe("second-value");
} finally {
if (originalEnv === undefined) {
delete process.env[envVarName];
} else {
process.env[envVarName] = originalEnv;
}
}
});
});
});
describe("oauth lock compromise handling", () => {
test("returns undefined on compromised lock and allows a later retry", async () => {
const providerId = `test-oauth-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`;
registerOAuthProvider({
id: providerId,
name: "Test OAuth Provider",
async login() {
throw new Error("Not used in this test");
},
async refreshToken(credentials) {
return {
...credentials,
access: "refreshed-access-token",
refresh: async (credential) => ({
...credential,
access: "refreshed-access",
expires: Date.now() + 60_000,
};
}),
toAuth: async (credential) => ({ apiKey: credential.access }),
},
getApiKey(credentials) {
return `Bearer ${credentials.access}`;
},
});
},
getModels: () => [],
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
};
const models = createModels({ credentials: storage });
models.setProvider(provider);
writeAuthJson({
[providerId]: {
type: "oauth",
refresh: "refresh-token",
access: "expired-access-token",
expires: Date.now() - 10_000,
},
});
authStorage = AuthStorage.create(authJsonPath);
const realLock = lockfile.lock.bind(lockfile);
const lockSpy = vi.spyOn(lockfile, "lock");
lockSpy.mockImplementationOnce(async (file, options) => {
options?.onCompromised?.(new Error("Unable to update lock within the stale threshold"));
return realLock(file, options);
});
const firstTry = await authStorage.getApiKey(providerId);
expect(firstTry).toBeUndefined();
lockSpy.mockRestore();
const secondTry = await authStorage.getApiKey(providerId);
expect(secondTry).toBe("Bearer refreshed-access-token");
const realLock = lockfile.lock.bind(lockfile);
const lockSpy = vi.spyOn(lockfile, "lock").mockImplementationOnce(async (file, options) => {
options?.onCompromised?.(new Error("lock compromised"));
return realLock(file, options);
});
await expect(models.getAuth(providerId)).rejects.toMatchObject({ code: "auth" });
lockSpy.mockRestore();
await expect(models.getAuth(providerId)).resolves.toMatchObject({ auth: { apiKey: "refreshed-access" } });
});
describe("persistence semantics", () => {
test("set preserves unrelated external edits", () => {
writeAuthJson({
anthropic: { type: "api_key", key: "old-anthropic" },
openai: { type: "api_key", key: "openai-key" },
});
authStorage = AuthStorage.create(authJsonPath);
// Simulate external edit while process is running
writeAuthJson({
anthropic: { type: "api_key", key: "old-anthropic" },
openai: { type: "api_key", key: "openai-key" },
google: { type: "api_key", key: "google-key" },
});
authStorage.set("anthropic", { type: "api_key", key: "new-anthropic" });
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
expect(updated.anthropic.key).toBe("new-anthropic");
expect(updated.openai.key).toBe("openai-key");
expect(updated.google.key).toBe("google-key");
});
test("remove preserves unrelated external edits", () => {
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
});
authStorage = AuthStorage.create(authJsonPath);
// Simulate external edit while process is running
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
openai: { type: "api_key", key: "openai-key" },
google: { type: "api_key", key: "google-key" },
});
authStorage.remove("anthropic");
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
expect(updated.anthropic).toBeUndefined();
expect(updated.openai.key).toBe("openai-key");
expect(updated.google.key).toBe("google-key");
});
test("throws and does not overwrite malformed auth file after load error", () => {
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
});
authStorage = AuthStorage.create(authJsonPath);
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
authStorage.reload();
expect(() => authStorage.set("openai", { type: "api_key", key: "openai-key" })).toThrow(
"Cannot update auth storage because it could not be loaded",
);
const raw = readFileSync(authJsonPath, "utf-8");
expect(raw).toBe("{invalid-json");
expect(authStorage.has("openai")).toBe(false);
});
test("throws when a stale auth lock prevents persistence", () => {
writeAuthJson({});
writeFileSync(`${authJsonPath}.lock`, "", "utf-8");
authStorage = AuthStorage.create(authJsonPath);
expect(() => authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" })).toThrow(
"Cannot update auth storage because it could not be loaded",
);
expect(readFileSync(authJsonPath, "utf-8")).toBe("{}");
expect(authStorage.has("github-copilot")).toBe(false);
});
test("recovers from an earlier load error before persisting", () => {
writeAuthJson({});
const lockPath = `${authJsonPath}.lock`;
writeFileSync(lockPath, "", "utf-8");
authStorage = AuthStorage.create(authJsonPath);
rmSync(lockPath);
authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" });
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
expect(updated["github-copilot"].key).toBe("copilot-key");
expect(authStorage.has("github-copilot")).toBe(true);
});
test("reload records parse errors and drainErrors clears buffer", () => {
writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" },
});
authStorage = AuthStorage.create(authJsonPath);
writeFileSync(authJsonPath, "{invalid-json", "utf-8");
authStorage.reload();
// Keeps previous in-memory data on reload failure
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "anthropic-key" });
const firstDrain = authStorage.drainErrors();
expect(firstDrain.length).toBeGreaterThan(0);
expect(firstDrain[0]).toBeInstanceOf(Error);
const secondDrain = authStorage.drainErrors();
expect(secondDrain).toHaveLength(0);
});
});
describe("auth status", () => {
test("does not expose stored API keys or OAuth tokens", () => {
authStorage = AuthStorage.inMemory({
anthropic: { type: "api_key", key: "secret-api-key" },
openai: {
type: "oauth",
access: "secret-access-token",
refresh: "secret-refresh-token",
expires: Date.now() + 1000,
},
});
expect(authStorage.getAuthStatus("anthropic")).toEqual({ configured: true, source: "stored" });
expect(authStorage.getAuthStatus("openai")).toEqual({ configured: true, source: "stored" });
expect(JSON.stringify(authStorage.getAuthStatus("anthropic"))).not.toContain("secret-api-key");
expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-access-token");
expect(JSON.stringify(authStorage.getAuthStatus("openai"))).not.toContain("secret-refresh-token");
});
});
describe("runtime overrides", () => {
test("runtime override takes priority over auth.json", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo stored-key" },
});
authStorage = AuthStorage.create(authJsonPath);
authStorage.setRuntimeApiKey("anthropic", "runtime-key");
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("runtime-key");
});
test("removing runtime override falls back to auth.json", async () => {
writeAuthJson({
anthropic: { type: "api_key", key: "!echo stored-key" },
});
authStorage = AuthStorage.create(authJsonPath);
authStorage.setRuntimeApiKey("anthropic", "runtime-key");
authStorage.removeRuntimeApiKey("anthropic");
const apiKey = await authStorage.getApiKey("anthropic");
expect(apiKey).toBe("stored-key");
});
test("does not overwrite malformed auth files", async () => {
writeAuthJson({ anthropic: { type: "api_key", key: "stored" } });
const storage = AuthStorage.create(authJsonPath);
writeFileSync(authJsonPath, "{invalid-json", "utf8");
await expect(storage.modify("openai", async () => ({ type: "api_key", key: "new" }))).rejects.toThrow();
expect(readFileSync(authJsonPath, "utf8")).toBe("{invalid-json");
});
});
@@ -12,7 +12,7 @@ const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
const models: ModelPriceSource = {
// $/million tokens; used as cache-read price fallback on full-miss turns
find: () => ({ cost: { cacheRead: 0.3 } }),
getModel: () => ({ cost: { cacheRead: 0.3 } }),
};
function assistant(options: {
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* Tests for compaction extension events (before_compact / compact).
*/
@@ -17,7 +18,6 @@ import {
type SessionCompactEvent,
type SessionEvent,
} from "../src/core/extensions/index.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createSyntheticSourceInfo } from "../src/core/source-info.ts";
@@ -31,7 +31,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
let tempDir: string;
let capturedEvents: SessionEvent[];
beforeEach(() => {
beforeEach(async () => {
tempDir = join(tmpdir(), `pi-compaction-extensions-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
capturedEvents = [];
@@ -85,7 +85,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
};
}
function createSession(extensions: Extension[]) {
async function createSession(extensions: Extension[]) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
@@ -100,7 +100,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
const runtime = createExtensionRuntime();
const resourceLoader = {
@@ -113,7 +113,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader,
});
@@ -122,7 +122,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
it("should emit before_compact and compact events", async () => {
const extension = createExtension();
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -158,7 +158,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
it("should allow extensions to cancel compaction", async () => {
const extension = createExtension(() => ({ cancel: true }));
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -184,7 +184,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
}
return undefined;
});
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -208,7 +208,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
it("should include entries in compact event after compaction is saved", async () => {
const extension = createExtension();
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -259,7 +259,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
shortcuts: new Map(),
};
createSession([throwingExtension]);
await createSession([throwingExtension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -339,7 +339,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
shortcuts: new Map(),
};
createSession([extension1, extension2]);
await createSession([extension1, extension2]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -356,7 +356,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
capturedBeforeEvent = event;
return undefined;
});
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -378,10 +378,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
expect(Array.isArray(event.branchEntries)).toBe(true);
// sessionManager, modelRegistry, and model are now on ctx, not event
// Verify they're accessible via session
// sessionManager and model runtime remain available on the session.
expect(typeof session.sessionManager.getEntries).toBe("function");
expect(typeof session.modelRegistry.getApiKeyAndHeaders).toBe("function");
expect(typeof session.modelRuntime.getAuth).toBe("function");
const entries = session.sessionManager.getEntries();
expect(Array.isArray(entries)).toBe(true);
@@ -403,7 +402,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
}
return undefined;
});
createSession([extension]);
await createSession([extension]);
await session.prompt("What is 2+2? Reply with just the number.");
await session.agent.waitForIdle();
@@ -4,9 +4,10 @@ import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { runMigrations } from "../src/migrations.ts";
import { createModelRegistry } from "./model-runtime-test-utils.ts";
describe("config value env var syntax migration", () => {
const tempDirs: string[] = [];
@@ -71,7 +72,7 @@ describe("config value env var syntax migration", () => {
it.each([
["malformed", '{\n "providers": {\n'],
["blank", ""],
])("does not throw on %s models.json during migrations", (_name, content) => {
])("does not throw on %s models.json during migrations", async (_name, content) => {
const agentDir = createAgentDir();
const modelsPath = path.join(agentDir, "models.json");
fs.writeFileSync(modelsPath, content, "utf-8");
@@ -79,7 +80,7 @@ describe("config value env var syntax migration", () => {
withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow());
expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content);
const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
const registry = await createModelRegistry(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
const loadError = registry.getError();
expect(loadError).toContain("Failed to parse models.json");
expect(loadError).toContain(`File: ${modelsPath}`);
@@ -148,7 +149,7 @@ describe("config value env var syntax migration", () => {
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY");
expect(logSpy).not.toHaveBeenCalled();
const registry = ModelRegistry.create(
const registry = await createModelRegistry(
AuthStorage.create(path.join(agentDir, "auth.json")),
path.join(agentDir, "models.json"),
);
@@ -51,6 +51,42 @@ describe("extensions discovery", () => {
expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]);
});
it("loads the coding-agent entrypoint without rewriting pi-ai provider subpaths", async () => {
fs.writeFileSync(
path.join(extensionsDir, "coding-agent-import.ts"),
`
import { getAgentDir } from "@earendil-works/pi-coding-agent";
void getAgentDir;
export default function(pi) {
pi.registerCommand("test", { handler: async () => {} });
}
`,
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
});
it("keeps the type-only pi-ai OAuth compatibility barrel resolvable", async () => {
fs.writeFileSync(
path.join(extensionsDir, "oauth-import.ts"),
`
import * as oauth from "@earendil-works/pi-ai/oauth";
void oauth;
export default function(pi) {
pi.registerCommand("test", { handler: async () => {} });
}
`,
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toEqual([]);
expect(result.extensions).toHaveLength(1);
});
it("discovers direct .js files in extensions/", async () => {
fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode);
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { createModelRegistry } from "./model-runtime-test-utils.ts";
describe("Input Event", () => {
let tempDir: string;
let extensionsDir: string;
@@ -29,7 +30,7 @@ describe("Input Event", () => {
for (let i = 0; i < extensions.length; i++) fs.writeFileSync(path.join(extensionsDir, `e${i}.ts`), extensions[i]);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const sm = SessionManager.inMemory();
const mr = ModelRegistry.create(AuthStorage.create(path.join(tempDir, "auth.json")));
const mr = await createModelRegistry(AuthStorage.create(path.join(tempDir, "auth.json")));
return new ExtensionRunner(result.extensions, result.runtime, tempDir, sm, mr);
}
@@ -1,3 +1,4 @@
import { createModelRegistry } from "./model-runtime-test-utils.ts";
/**
* Tests for ExtensionRunner - conflict detection, error handling, tool wrapping.
*/
@@ -16,7 +17,7 @@ import type {
ProviderConfig,
} from "../src/core/extensions/types.ts";
import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import type { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
describe("ExtensionRunner", () => {
@@ -26,13 +27,13 @@ describe("ExtensionRunner", () => {
let modelRegistry: ModelRegistry;
const defaultKeybindings = new KeybindingsManager().getEffectiveConfig();
beforeEach(() => {
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-"));
extensionsDir = path.join(tempDir, "extensions");
fs.mkdirSync(extensionsDir);
sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.create(path.join(tempDir, "auth.json"));
modelRegistry = ModelRegistry.create(authStorage);
modelRegistry = await createModelRegistry(authStorage);
});
afterEach(() => {
@@ -817,7 +818,7 @@ describe("ExtensionRunner", () => {
});
describe("provider registration", () => {
it("bindCore ignores invalid queued registrations and reports extension error", () => {
it("bindCore ignores invalid queued registrations and reports extension error", async () => {
const runtime = createExtensionRuntime();
runtime.registerProvider(
"broken-provider",
@@ -837,7 +838,7 @@ describe("ExtensionRunner", () => {
expect(errors).toEqual([
'/tmp/broken-extension.ts: Provider broken-provider: "api" is required when registering streamSimple.',
]);
expect(() => modelRegistry.refresh()).not.toThrow();
await expect(modelRegistry.refresh()).resolves.toBeUndefined();
});
it("pre-bind unregister removes all queued registrations for a provider", () => {
@@ -52,7 +52,7 @@ function createSession(options: {
getCwd: () => "/tmp/project",
},
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
modelRegistry: {
modelRuntime: {
isUsingOAuth: () => false,
},
};
@@ -7,19 +7,20 @@ function createSettingsManager(warnings: { anthropicExtraUsage?: boolean } = {})
};
}
function createModelRuntime(credential: { type: "oauth" } | undefined, apiKey?: string) {
return {
checkAuth: vi.fn().mockResolvedValue(credential),
getAuth: vi.fn().mockResolvedValue(apiKey ? { auth: { apiKey } } : undefined),
};
}
describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
test("warns once when Anthropic subscription auth is detected", async () => {
const modelRuntime = createModelRuntime(undefined, "sk-ant-oat01-test");
const fakeThis: any = {
anthropicSubscriptionWarningShown: false,
settingsManager: createSettingsManager(),
session: {
modelRegistry: {
authStorage: {
get: vi.fn().mockReturnValue(undefined),
},
getApiKeyForProvider: vi.fn().mockResolvedValue("sk-ant-oat01-test"),
},
},
session: { modelRuntime },
showWarning: vi.fn(),
};
@@ -31,21 +32,15 @@ describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
});
expect(fakeThis.showWarning).toHaveBeenCalledTimes(1);
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).toHaveBeenCalledTimes(1);
expect(modelRuntime.getAuth).toHaveBeenCalledTimes(1);
});
test("warns when Anthropic OAuth is stored even if token refresh lookup would fail", async () => {
const modelRuntime = createModelRuntime({ type: "oauth" });
const fakeThis: any = {
anthropicSubscriptionWarningShown: false,
settingsManager: createSettingsManager(),
session: {
modelRegistry: {
authStorage: {
get: vi.fn().mockReturnValue({ type: "oauth" }),
},
getApiKeyForProvider: vi.fn().mockResolvedValue(undefined),
},
},
session: { modelRuntime },
showWarning: vi.fn(),
};
@@ -54,21 +49,15 @@ describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
});
expect(fakeThis.showWarning).toHaveBeenCalledTimes(1);
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled();
expect(modelRuntime.getAuth).not.toHaveBeenCalled();
});
test("does not warn for non-Anthropic models", async () => {
const modelRuntime = createModelRuntime(undefined);
const fakeThis: any = {
anthropicSubscriptionWarningShown: false,
settingsManager: createSettingsManager(),
session: {
modelRegistry: {
authStorage: {
get: vi.fn(),
},
getApiKeyForProvider: vi.fn(),
},
},
session: { modelRuntime },
showWarning: vi.fn(),
};
@@ -77,21 +66,15 @@ describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
});
expect(fakeThis.showWarning).not.toHaveBeenCalled();
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled();
expect(modelRuntime.getAuth).not.toHaveBeenCalled();
});
test("does not warn when Anthropic extra usage warning is disabled", async () => {
const modelRuntime = createModelRuntime(undefined);
const fakeThis: any = {
anthropicSubscriptionWarningShown: false,
settingsManager: createSettingsManager({ anthropicExtraUsage: false }),
session: {
modelRegistry: {
authStorage: {
get: vi.fn(),
},
getApiKeyForProvider: vi.fn(),
},
},
session: { modelRuntime },
showWarning: vi.fn(),
};
@@ -100,7 +83,7 @@ describe("InteractiveMode.maybeWarnAboutAnthropicSubscriptionAuth", () => {
});
expect(fakeThis.showWarning).not.toHaveBeenCalled();
expect(fakeThis.session.modelRegistry.authStorage.get).not.toHaveBeenCalled();
expect(fakeThis.session.modelRegistry.getApiKeyForProvider).not.toHaveBeenCalled();
expect(modelRuntime.checkAuth).not.toHaveBeenCalled();
expect(modelRuntime.getAuth).not.toHaveBeenCalled();
});
});
@@ -379,7 +379,7 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
type FakeInteractiveMode = {
session: {
scopedModels: Array<{ model: TestModel }>;
modelRegistry: { getAvailable: () => TestModel[] };
modelRuntime: { getAvailable: () => TestModel[] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
@@ -402,7 +402,7 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRegistry: { getAvailable: () => models },
modelRuntime: { getAvailable: () => models },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
@@ -429,7 +429,7 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
type FakeInteractiveMode = {
session: {
scopedModels: [];
modelRegistry: { getAvailable: () => [] };
modelRuntime: { getAvailable: () => [] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
@@ -449,7 +449,7 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRegistry: { getAvailable: () => [] },
modelRuntime: { getAvailable: () => [] },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
File diff suppressed because it is too large Load Diff
@@ -260,12 +260,12 @@ describe("resolveModelScopeWithDiagnostics", () => {
describe("resolveCliModel", () => {
test("resolves --model provider/id without --provider", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -275,13 +275,13 @@ describe("resolveCliModel", () => {
test("resolves fuzzy patterns within an explicit provider", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "4o",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -291,12 +291,12 @@ describe("resolveCliModel", () => {
test("supports --model <pattern>:<thinking> (without explicit --thinking)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "sonnet:high",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -306,12 +306,12 @@ describe("resolveCliModel", () => {
test("prefers exact model id match over provider inference (OpenRouter-style ids)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o:extended",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -321,13 +321,13 @@ describe("resolveCliModel", () => {
test("does not strip invalid :suffix as thinking level in --model (treat as raw id)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "gpt-4o:extended",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -337,13 +337,13 @@ describe("resolveCliModel", () => {
test("allows custom model ids for explicit providers without double prefixing", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "openrouter",
cliModel: "openrouter/openai/ghost-model",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -353,13 +353,13 @@ describe("resolveCliModel", () => {
test("returns a clear error when there are no models", () => {
const registry = {
getAll: () => [],
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => [],
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "gpt-4o",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.model).toBeUndefined();
@@ -394,13 +394,13 @@ describe("resolveCliModel", () => {
maxTokens: 8192,
};
const registry = {
getAll: () => [...allModels, zaiModel, gatewayModel],
getModels: () => [...allModels, zaiModel, gatewayModel],
hasConfiguredAuth: () => true,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "zai/glm-5",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -434,13 +434,13 @@ describe("resolveCliModel", () => {
maxTokens: 8192,
};
const registry = {
getAll: () => [...allModels, commandcodeModel, xiaomiModel],
hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "commandcode",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => [...allModels, commandcodeModel, xiaomiModel],
hasConfiguredAuth: (provider: string) => provider === "commandcode",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "xiaomi/mimo-v2.5-pro",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -450,12 +450,12 @@ describe("resolveCliModel", () => {
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "openrouter/qwen",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -483,12 +483,12 @@ describe("resolveCliModel", () => {
test("strips :thinking suffix from custom model id in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -501,12 +501,12 @@ describe("resolveCliModel", () => {
test("custom model without thinking suffix works normally in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -517,13 +517,13 @@ describe("resolveCliModel", () => {
test("all valid thinking levels work in fallback path", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) {
const result = resolveCliModel({
cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`,
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -534,12 +534,12 @@ describe("resolveCliModel", () => {
test("invalid thinking suffix on custom model is treated as part of model id", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:banana",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -551,13 +551,13 @@ describe("resolveCliModel", () => {
test("explicit --provider with custom model:thinking strips suffix correctly", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliProvider: "neuralwatt",
cliModel: "zai-org/GLM-5.1-FP8:high",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -568,13 +568,13 @@ describe("resolveCliModel", () => {
test("with explicit --thinking, :suffix is kept as part of model id", () => {
const registry = {
getAll: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
getModels: () => modelsWithNeuralwatt,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRuntime"];
const result = resolveCliModel({
cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high",
cliThinking: "medium",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.error).toBeUndefined();
@@ -606,15 +606,15 @@ describe("default model selection", () => {
test("findInitialModel accepts explicit provider custom model ids", async () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
getModels: () => allModels,
} as unknown as Parameters<typeof findInitialModel>[0]["modelRuntime"];
const result = await findInitialModel({
cliProvider: "openrouter",
cliModel: "openrouter/openai/ghost-model",
scopedModels: [],
isContinuing: false,
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.model?.provider).toBe("openrouter");
@@ -637,12 +637,12 @@ describe("default model selection", () => {
const registry = {
getAvailable: async () => [aiGatewayModel],
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
} as unknown as Parameters<typeof findInitialModel>[0]["modelRuntime"];
const result = await findInitialModel({
scopedModels: [],
isContinuing: false,
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.model?.provider).toBe("vercel-ai-gateway");
@@ -668,20 +668,20 @@ describe("default model selection", () => {
baseUrl: "http://spark-two:8000/v1",
};
const registry = {
find: (provider: string, modelId: string) =>
getModel: (provider: string, modelId: string) =>
provider === savedDeepSeekModel.provider && modelId === savedDeepSeekModel.id
? savedDeepSeekModel
: undefined,
hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "spark-two",
hasConfiguredAuth: (provider: string) => provider === "spark-two",
getAvailable: async () => [localDeepSeekModel],
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
} as unknown as Parameters<typeof findInitialModel>[0]["modelRuntime"];
const result = await findInitialModel({
scopedModels: [],
isContinuing: false,
defaultProvider: "deepseek",
defaultModelId: "deepseek-v4-flash",
modelRegistry: registry,
modelRuntime: registry,
});
expect(result.model?.provider).toBe("spark-two");
@@ -0,0 +1,256 @@
import { type AuthType, type CredentialStore, InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
function authOptions(runtime: ModelRuntime, type?: AuthType) {
return runtime
.getProviders()
.flatMap((provider) => [
...(!type || type === "oauth"
? provider.auth.oauth
? [{ type: "oauth" as const, provider, method: provider.auth.oauth }]
: []
: []),
...(!type || type === "api_key"
? provider.auth.apiKey
? [{ type: "api_key" as const, provider, method: provider.auth.apiKey }]
: []
: []),
]);
}
function testModel(id: string) {
return {
id,
name: id,
reasoning: false,
input: ["text"] as ("text" | "image")[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10000,
maxTokens: 1000,
};
}
describe("ModelRuntime auth options", () => {
it("accepts a pi-ai CredentialStore", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("anthropic", async () => ({ type: "api_key", key: "stored-key" }));
const runtime = await ModelRuntime.create({ credentials, modelsPath: null });
expect((await runtime.getAuth("anthropic"))?.auth.apiKey).toBe("stored-key");
});
it("scopes provider availability reads and records refresh failures", async () => {
const base = new InMemoryCredentialStore();
const reads: string[] = [];
let failReads = false;
const credentials: CredentialStore = {
read: async (providerId) => {
reads.push(providerId);
if (failReads) throw new Error(`read failed for ${providerId}`);
return base.read(providerId);
},
list: () => base.list(),
modify: (providerId, fn) => base.modify(providerId, fn),
delete: (providerId) => base.delete(providerId),
};
const runtime = await ModelRuntime.create({ credentials, modelsPath: null });
reads.length = 0;
await runtime.getAvailable("anthropic");
expect(new Set(reads)).toEqual(new Set(["anthropic"]));
failReads = true;
await expect(runtime.getAvailable("anthropic")).rejects.toThrow("Credential store read failed for anthropic");
expect(runtime.getError()).toContain("Availability refresh: Credential store read failed for anthropic");
failReads = false;
await runtime.getAvailable();
expect(runtime.getError()).toBeUndefined();
});
it("projects provider-owned methods, names, and status", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
const options = authOptions(runtime);
expect(options).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "api_key",
provider: expect.objectContaining({ id: "amazon-bedrock", name: "Amazon Bedrock" }),
method: expect.objectContaining({ name: "AWS credentials or bearer token" }),
}),
expect.objectContaining({
type: "api_key",
provider: expect.objectContaining({ id: "google-vertex", name: "Google Vertex AI" }),
method: expect.objectContaining({ name: "Google Cloud credentials" }),
}),
expect.objectContaining({
type: "oauth",
provider: expect.objectContaining({ id: "anthropic", name: "Anthropic" }),
}),
expect.objectContaining({
type: "api_key",
provider: expect.objectContaining({ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway" }),
}),
expect.objectContaining({
type: "api_key",
provider: expect.objectContaining({ id: "cloudflare-workers-ai", name: "Cloudflare Workers AI" }),
}),
]),
);
expect(authOptions(runtime, "api_key").every((option) => option.type === "api_key")).toBe(true);
expect(authOptions(runtime, "oauth").every((option) => option.type === "oauth")).toBe(true);
expect(options.some((option) => option.provider.id === "openai-codex" && option.type === "api_key")).toBe(false);
});
it("attaches the provider's active auth status to every method option", async () => {
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
anthropic: {
type: "oauth",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
},
}),
modelsPath: null,
});
const options = authOptions(runtime).filter((option) => option.provider.id === "anthropic");
expect(options).toHaveLength(2);
expect(await runtime.checkAuth("anthropic")).toMatchObject({ type: "oauth" });
});
it("constructs an API key method for an extension API-key provider", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
runtime.registerProvider("extension-api-key", {
name: "Extension API Key",
baseUrl: "https://example.test/v1",
apiKey: "$EXTENSION_TEST_API_KEY",
api: "openai-completions",
models: [testModel("extension-model")],
});
const options = authOptions(runtime).filter((option) => option.provider.id === "extension-api-key");
expect(options).toHaveLength(1);
expect(options[0]).toMatchObject({
type: "api_key",
provider: { id: "extension-api-key", name: "Extension API Key" },
method: { name: "API key" },
});
expect(options[0]?.method.login).toBeTypeOf("function");
});
it("resolves configured auth from request-scoped environment overrides", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
runtime.registerProvider("request-env-provider", {
baseUrl: "https://example.test/v1",
apiKey: "$REQUEST_SCOPED_API_KEY",
headers: { "x-request-value": "$REQUEST_SCOPED_HEADER" },
api: "openai-completions",
models: [testModel("request-env-model")],
});
const auth = await runtime.getAuth("request-env-provider", {
env: { REQUEST_SCOPED_API_KEY: "request-key", REQUEST_SCOPED_HEADER: "request-header" },
});
expect(auth?.auth).toEqual({ apiKey: "request-key", headers: { "x-request-value": "request-header" } });
});
it("lets an explicit Authorization header override authHeader case-insensitively", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
let capturedHeaders: Record<string, string | null> | undefined;
runtime.registerProvider("auth-header-provider", {
baseUrl: "https://example.test/v1",
apiKey: "generated-key",
authHeader: true,
api: "openai-completions",
streamSimple: (_model, _context, options) => {
capturedHeaders = options?.headers;
throw new Error("captured");
},
models: [testModel("auth-header-model")],
});
const model = runtime.getModel("auth-header-provider", "auth-header-model");
expect(model).toBeDefined();
await runtime.completeSimple(model!, { messages: [] }, { headers: { authorization: "Explicit token" } });
expect(capturedHeaders).toEqual({ authorization: "Explicit token" });
});
it("transforms fully assembled headers once without forwarding the transform", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
let capturedHeaders: Record<string, string | null> | undefined;
let transforms = 0;
runtime.registerProvider("header-provider", {
baseUrl: "https://example.test/v1",
apiKey: "generated-key",
authHeader: true,
headers: { "x-provider": "provider" },
api: "openai-completions",
streamSimple: (_model, _context, options) => {
expect(options).not.toHaveProperty("transformHeaders");
capturedHeaders = options?.headers;
throw new Error("captured");
},
models: [{ ...testModel("header-model"), headers: { "x-model": "model" } }],
});
const model = runtime.getModel("header-provider", "header-model");
expect(model).toBeDefined();
await runtime.completeSimple(
model!,
{ messages: [] },
{
headers: { "x-explicit": "explicit" },
transformHeaders: async (headers) => {
transforms++;
expect(headers).toEqual({
Authorization: "Bearer generated-key",
"x-provider": "provider",
"x-model": "model",
"x-explicit": "explicit",
});
return { ...headers, "x-transformed": "yes" };
},
},
);
expect(transforms).toBe(1);
expect(capturedHeaders).toEqual({
Authorization: "Bearer generated-key",
"x-provider": "provider",
"x-model": "model",
"x-explicit": "explicit",
"x-transformed": "yes",
});
});
it("does not fabricate an API key method for an extension OAuth-only provider", async () => {
const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null });
runtime.registerProvider("extension-oauth", {
name: "Extension OAuth",
baseUrl: "https://example.test/v1",
api: "openai-completions",
oauth: {
name: "Extension subscription",
login: async () => ({ access: "access", refresh: "refresh", expires: Date.now() + 60_000 }),
refreshToken: async (credentials) => credentials,
getApiKey: (credentials) => credentials.access,
},
models: [testModel("extension-model")],
});
const options = authOptions(runtime).filter((option) => option.provider.id === "extension-oauth");
expect(options).toHaveLength(1);
expect(options[0]).toMatchObject({
type: "oauth",
provider: { id: "extension-oauth", name: "Extension OAuth" },
method: { name: "Extension subscription" },
});
});
});
@@ -0,0 +1,95 @@
import { complete, resetApiProviders } from "@earendil-works/pi-ai/compat";
import { describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
const openAIState = vi.hoisted(() => ({ clientOptions: undefined as unknown }));
vi.mock("openai", () => {
class FakeOpenAI {
constructor(options: unknown) {
openAIState.clientOptions = options;
}
chat = {
completions: {
create: () => {
const stream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
},
};
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
withResponse(): Promise<{
data: typeof stream;
response: { status: number; headers: Headers };
}>;
};
promise.withResponse = async () => ({
data: stream,
response: { status: 200, headers: new Headers() },
});
return promise;
},
},
};
}
return { default: FakeOpenAI };
});
async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; modelRegistry: ModelRegistry }> {
const authStorage = AuthStorage.inMemory();
await authStorage.modify("cloudflare-ai-gateway", async () => ({
type: "api_key",
key: "test-token",
env: {
CLOUDFLARE_ACCOUNT_ID: "test-account",
CLOUDFLARE_GATEWAY_ID: "test-gateway",
},
}));
const modelRuntime = await ModelRuntime.create({ credentials: authStorage, modelsPath: null });
return { modelRuntime, modelRegistry: new ModelRegistry(modelRuntime) };
}
describe("ModelRegistry Cloudflare compat streaming", () => {
it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => {
const { modelRuntime } = await createCloudflareRuntime();
const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5");
expect(model).toBeDefined();
resetApiProviders();
await modelRuntime.completeSimple(model!, { messages: [] });
const clientOptions = openAIState.clientOptions as {
baseURL?: string;
defaultHeaders?: Record<string, unknown>;
};
expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat");
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test-token");
});
it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => {
const { modelRegistry } = await createCloudflareRuntime();
const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5");
expect(model).toBeDefined();
resetApiProviders();
const auth = await modelRegistry.getApiKeyAndHeaders(model!);
expect(auth.ok).toBe(true);
if (!auth.ok) throw new Error(auth.error);
await complete(model!, { messages: [] }, auth);
const clientOptions = openAIState.clientOptions as {
baseURL?: string;
defaultHeaders?: Record<string, unknown>;
};
expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat");
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test-token");
});
});
@@ -0,0 +1,25 @@
import type { CredentialStore } from "@earendil-works/pi-ai";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
const runtimes = new WeakMap<ModelRegistry, ModelRuntime>();
function wrap(runtime: ModelRuntime): ModelRegistry {
const registry = new ModelRegistry(runtime);
runtimes.set(registry, runtime);
return registry;
}
export async function createModelRegistry(credentials: CredentialStore, modelsPath?: string): Promise<ModelRegistry> {
return wrap(await ModelRuntime.create({ credentials, modelsPath }));
}
export async function createInMemoryModelRegistry(credentials: CredentialStore): Promise<ModelRegistry> {
return wrap(await ModelRuntime.create({ credentials, modelsPath: null }));
}
export function getModelRuntime(modelRegistry: ModelRegistry): ModelRuntime {
const runtime = runtimes.get(modelRegistry);
if (!runtime) throw new Error("ModelRegistry was not created by the test helper");
return runtime;
}
@@ -1,15 +1,11 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../src/core/provider-display-names.ts";
import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.ts";
import { isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
describe("OAuthSelectorComponent", () => {
beforeAll(() => {
initTheme("dark");
@@ -19,119 +15,133 @@ describe("OAuthSelectorComponent", () => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
if (originalOpenAiApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
}
});
it("keeps built-in API key providers separate from OAuth-only providers", () => {
const oauthProviderIds = new Set(["anthropic", "github-copilot", "custom-oauth"]);
const builtInProviderIds = new Set(["anthropic", "github-copilot", "amazon-bedrock", "openai"]);
expect(isApiKeyLoginProvider("anthropic", oauthProviderIds, builtInProviderIds)).toBe(true);
expect(BUILT_IN_PROVIDER_DISPLAY_NAMES.anthropic).toBe("Anthropic");
expect(isApiKeyLoginProvider("openai", oauthProviderIds, builtInProviderIds)).toBe(true);
expect(isApiKeyLoginProvider("github-copilot", oauthProviderIds, builtInProviderIds)).toBe(false);
expect(isApiKeyLoginProvider("amazon-bedrock", oauthProviderIds, builtInProviderIds)).toBe(true);
expect(isApiKeyLoginProvider("custom-oauth", oauthProviderIds, builtInProviderIds)).toBe(false);
expect(isApiKeyLoginProvider("custom-api", oauthProviderIds, builtInProviderIds)).toBe(true);
});
it("shows stored OAuth auth distinctly in the API key selector", () => {
const authStorage = AuthStorage.inMemory({
anthropic: {
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
it("projects provider-owned auth options without provider-specific filtering", () => {
const getLoginProviderOptions = (
InteractiveMode as unknown as {
prototype: {
getLoginProviderOptions(
this: object,
authType?: "oauth" | "api_key",
): Array<{ id: string; name: string; authType: string; method?: { name: string; login?: unknown } }>;
};
}
).prototype.getLoginProviderOptions;
const providers = [
{
id: "anthropic",
name: "Anthropic",
auth: {
oauth: { name: "Anthropic (Claude Pro/Max)", login: async () => ({}) },
apiKey: { name: "Anthropic API key", login: async () => ({}) },
},
},
});
{
id: "google-vertex",
name: "Google Vertex AI",
auth: { apiKey: { name: "Google Cloud credentials" } },
},
];
const fakeThis = {
session: {
modelRuntime: {
getProviders: () => providers,
getProviderAuthStatus: () => ({ configured: false }),
isUsingOAuth: () => false,
},
},
};
const apiKeyOptions = getLoginProviderOptions.call(fakeThis, "api_key");
expect(apiKeyOptions).toMatchObject([
{
id: "anthropic",
name: "Anthropic",
authType: "api_key",
method: { name: "Anthropic API key" },
},
{
id: "google-vertex",
name: "Google Vertex AI",
authType: "api_key",
method: { name: "Google Cloud credentials" },
},
]);
expect(getLoginProviderOptions.call(fakeThis, "oauth")).toMatchObject([
{ id: "anthropic", name: "Anthropic", authType: "oauth" },
]);
});
it("renders an option without compiled auth status as unconfigured", () => {
const selector = new OAuthSelectorComponent(
"login",
authStorage,
[{ id: "anthropic", name: "Anthropic", authType: "api_key" }],
[{ id: "google", name: "Google", authType: "api_key", status: undefined }],
() => {},
() => {},
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("unconfigured");
expect(output).not.toContain("✓ configured");
});
expect(output).toContain("Anthropic");
it("shows OAuth auth distinctly in the API key selector", () => {
const selector = new OAuthSelectorComponent(
"login",
[{ id: "anthropic", name: "Anthropic", authType: "api_key", status: { type: "oauth", source: "OAuth" } }],
() => {},
() => {},
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("subscription configured");
});
it("shows environment API key auth as configured", () => {
process.env.OPENAI_API_KEY = "test-openai-key";
const authStorage = AuthStorage.inMemory();
const selector = new OAuthSelectorComponent(
"login",
authStorage,
[{ id: "openai", name: "OpenAI", authType: "api_key" }],
[{ id: "openai", name: "OpenAI", authType: "api_key", status: { type: "api_key", source: "OPENAI_API_KEY" } }],
() => {},
() => {},
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("OpenAI");
expect(output).toContain("✓ env: OPENAI_API_KEY");
expect(output).not.toContain("unconfigured");
});
it("shows custom provider environment API key auth from status resolver", () => {
const authStorage = AuthStorage.inMemory();
const selector = new OAuthSelectorComponent(
"login",
authStorage,
[{ id: "ollama", name: "ollama", authType: "api_key" }],
() => {},
() => {},
() => ({ configured: true, source: "environment", label: "OLLAMA_API_KEY" }),
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("ollama");
expect(output).toContain("✓ env: OLLAMA_API_KEY");
expect(output).not.toContain("unconfigured");
});
it("shows models.json API key auth as configured", () => {
const authStorage = AuthStorage.inMemory();
const selector = new OAuthSelectorComponent(
"login",
authStorage,
[{ id: "local-proxy", name: "local-proxy", authType: "api_key" }],
[
{
id: "local-proxy",
name: "local-proxy",
authType: "api_key",
status: { type: "api_key", source: "key in models.json" },
},
],
() => {},
() => {},
() => ({ configured: true, source: "models_json_key" }),
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("local-proxy");
expect(output).toContain("✓ key in models.json");
expect(output).not.toContain("unconfigured");
expect(stripAnsi(selector.render(120).join("\n"))).toContain("✓ key in models.json");
});
it("shows models.json command auth as configured", () => {
const authStorage = AuthStorage.inMemory();
const selector = new OAuthSelectorComponent(
"login",
authStorage,
[{ id: "op-proxy", name: "op-proxy", authType: "api_key" }],
[
{
id: "op-proxy",
name: "op-proxy",
authType: "api_key",
status: { type: "api_key", source: "command in models.json" },
},
],
() => {},
() => {},
() => ({ configured: true, source: "models_json_command" }),
);
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("op-proxy");
expect(output).toContain("✓ command in models.json");
expect(output).not.toContain("unconfigured");
expect(stripAnsi(selector.render(120).join("\n"))).toContain("✓ command in models.json");
});
});
@@ -0,0 +1,121 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
clearConfigValueCache,
resolveConfigValue,
resolveConfigValueUncached,
} from "../src/core/resolve-config-value.ts";
import * as shellModule from "../src/utils/shell.ts";
describe("resolveConfigValue", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-config-value-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
clearConfigValueCache();
});
afterEach(() => {
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
clearConfigValueCache();
vi.restoreAllMocks();
});
test("resolves literals, environment templates, and escapes", () => {
process.env.TEST_CONFIG_LEFT = "left";
process.env.TEST_CONFIG_RIGHT = "right";
try {
expect(resolveConfigValue("literal-key")).toBe("literal-key");
expect(resolveConfigValue("$TEST_CONFIG_LEFT")).toBe("left");
expect(resolveConfigValue("$" + "{TEST_CONFIG_LEFT}_$TEST_CONFIG_RIGHT")).toBe("left_right");
expect(resolveConfigValue("$$TEST_CONFIG_LEFT")).toBe("$TEST_CONFIG_LEFT");
expect(resolveConfigValue("$!literal-$TEST_CONFIG_RIGHT")).toBe("!literal-right");
} finally {
delete process.env.TEST_CONFIG_LEFT;
delete process.env.TEST_CONFIG_RIGHT;
}
});
test("uses credential-scoped environment before process.env", () => {
process.env.TEST_CONFIG_SCOPED = "process";
try {
expect(resolveConfigValue("$TEST_CONFIG_SCOPED", { TEST_CONFIG_SCOPED: "credential" })).toBe("credential");
} finally {
delete process.env.TEST_CONFIG_SCOPED;
}
});
test("executes shell commands and trims their output", () => {
expect(resolveConfigValue("!echo ' spaced-key '")).toBe("spaced-key");
expect(resolveConfigValue("!printf 'line1\\nline2'")).toBe("line1\nline2");
expect(resolveConfigValue("!echo 'hello world' | tr ' ' '-'")).toBe("hello-world");
});
test.each(["!exit 1", "!nonexistent-command-12345", "!printf ''"])(
"returns undefined when command resolution fails: %s",
(command) => {
expect(resolveConfigValue(command)).toBeUndefined();
},
);
test("caches successful and failed commands until explicitly cleared", () => {
const counterFile = join(tempDir, "counter");
writeFileSync(counterFile, "0");
const escapedPath = counterFile.replace(/\\/g, "/").replace(/"/g, '\\"');
const success = `!sh -c 'count=$(cat "${escapedPath}"); echo $((count + 1)) > "${escapedPath}"; echo value'`;
expect(resolveConfigValue(success)).toBe("value");
expect(resolveConfigValue(success)).toBe("value");
expect(readFileSync(counterFile, "utf-8").trim()).toBe("1");
clearConfigValueCache();
expect(resolveConfigValue(success)).toBe("value");
expect(readFileSync(counterFile, "utf-8").trim()).toBe("2");
const failure = `!sh -c 'count=$(cat "${escapedPath}"); echo $((count + 1)) > "${escapedPath}"; exit 1'`;
expect(resolveConfigValue(failure)).toBeUndefined();
expect(resolveConfigValue(failure)).toBeUndefined();
expect(readFileSync(counterFile, "utf-8").trim()).toBe("3");
});
test("does not cache environment values", () => {
process.env.TEST_CONFIG_DYNAMIC = "first";
try {
expect(resolveConfigValue("$TEST_CONFIG_DYNAMIC")).toBe("first");
process.env.TEST_CONFIG_DYNAMIC = "second";
expect(resolveConfigValue("$TEST_CONFIG_DYNAMIC")).toBe("second");
} finally {
delete process.env.TEST_CONFIG_DYNAMIC;
}
});
test("uncached resolution executes a command on every call", () => {
const counterFile = join(tempDir, "uncached-counter");
writeFileSync(counterFile, "0");
const escapedPath = counterFile.replace(/\\/g, "/").replace(/"/g, '\\"');
const command = `!sh -c 'count=$(cat "${escapedPath}"); echo $((count + 1)) > "${escapedPath}"; echo value'`;
expect(resolveConfigValueUncached(command)).toBe("value");
expect(resolveConfigValueUncached(command)).toBe("value");
expect(readFileSync(counterFile, "utf-8").trim()).toBe("2");
});
test("uses stdin when the configured Windows shell requires it", () => {
if (process.platform === "win32") return;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: "/bin/bash",
args: ["-s"],
commandTransport: "stdin",
});
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
const expansion = "$" + "{name}";
expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${expansion}!"`)).toBe("Hello, World!");
} finally {
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
});
});
@@ -5,13 +5,14 @@ import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import type { Skill } from "../src/core/skills.ts";
import { createSyntheticSourceInfo } from "../src/core/source-info.ts";
import { createModelRegistry } from "./model-runtime-test-utils.ts";
describe("DefaultResourceLoader", () => {
let tempDir: string;
let agentDir: string;
@@ -277,7 +278,7 @@ export default function(pi) {
const sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
const runner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
@@ -721,7 +722,7 @@ export default function(pi: ExtensionAPI) {
const sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.create(join(tempDir, "auth-explicit.json"));
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
const runner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
@@ -13,10 +13,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { runRpcMode } from "../src/modes/rpc/rpc-mode.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
const rpcIo = vi.hoisted(() => ({
@@ -95,10 +95,10 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number; model?: Model<any> }): {
async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number; model?: Model<any> }): Promise<{
runtimeHost: AgentSessionRuntime;
cleanup: () => Promise<void>;
} {
}> {
const tempDir = join(tmpdir(), `pi-rpc-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
@@ -129,9 +129,9 @@ function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
const modelRegistry = await createModelRegistry(authStorage, tempDir);
if (options.withAuth) {
authStorage.setRuntimeApiKey("anthropic", "test-key");
await authStorage.modify("anthropic", async () => ({ type: "api_key", key: "test-key" }));
}
const session = new AgentSession({
@@ -139,7 +139,7 @@ function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
@@ -177,7 +177,7 @@ async function startRpcMode(options: { withAuth: boolean; responseDelayMs: numbe
rpcIo.outputLines = [];
rpcIo.lineHandler = undefined;
const { runtimeHost, cleanup } = createRuntimeHost(options);
const { runtimeHost, cleanup } = await createRuntimeHost(options);
void runRpcMode(runtimeHost);
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { RuntimeCredentials } from "../src/core/runtime-credentials.ts";
describe("RuntimeCredentials", () => {
test("runtime overrides mask stored credentials without persisting", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "stored-key" } });
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "runtime-key" });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored-key" });
credentials.removeRuntimeApiKey("anthropic");
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "stored-key" });
});
test("enumeration merges overrides without exposing keys", async () => {
const storage = AuthStorage.inMemory({
anthropic: { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 60_000 },
});
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
credentials.setRuntimeApiKey("openai", "other-runtime-key");
expect(await credentials.list()).toEqual([
{ providerId: "anthropic", type: "api_key" },
{ providerId: "openai", type: "api_key" },
]);
});
test("delete clears both the override and persisted credential", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "stored-key" } });
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
await credentials.delete("anthropic");
expect(await credentials.read("anthropic")).toBeUndefined();
expect(await credentials.list()).toEqual([]);
});
});
@@ -28,11 +28,11 @@ import {
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createExtensionRuntime } from "../src/core/extensions/loader.ts";
import type { ToolDefinition } from "../src/core/extensions/types.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import type { ResourceLoader } from "../src/core/resource-loader.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
@@ -275,7 +275,7 @@ async function main(): Promise<void> {
mkdirSync(dirname(args.sessionPath), { recursive: true });
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const modelRegistry = await createModelRegistry(authStorage);
const model = getModel("openai-codex", "gpt-5.5");
if (!model) {
@@ -296,6 +296,7 @@ async function main(): Promise<void> {
models: [baseModel],
});
const modelRuntime = getModelRuntime(modelRegistry);
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: false },
@@ -315,8 +316,7 @@ async function main(): Promise<void> {
resourceLoader,
sessionManager: SessionManager.open(args.sessionPath),
settingsManager,
authStorage,
modelRegistry,
modelRuntime,
});
session.setActiveToolsByName(["deterministic_probe"]);
@@ -11,11 +11,12 @@ import {
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
describe("createAgentSession provider attribution headers", () => {
let tempDir: string;
let cwd: string;
@@ -96,24 +97,20 @@ describe("createAgentSession provider attribution headers", () => {
}
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const registeredProviders = ["capture-provider"];
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "test-api-key" }));
const modelRegistry = await createModelRegistry(authStorage, join(agentDir, "models.json"));
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider("capture-provider", {
api: "openai-completions",
modelRegistry.registerProvider(model.provider, {
api: model.api,
headers: options.providerHeaders,
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream();
},
});
if (options.providerHeaders) {
modelRegistry.registerProvider(model.provider, { headers: options.providerHeaders });
registeredProviders.push(model.provider);
}
const modelRuntime = getModelRuntime(modelRegistry);
const sessionManager = SessionManager.inMemory(cwd);
if (options.sessionId) {
sessionManager.newSession({ id: options.sessionId });
@@ -123,14 +120,13 @@ describe("createAgentSession provider attribution headers", () => {
cwd,
agentDir,
model,
authStorage,
modelRegistry,
modelRuntime,
settingsManager,
sessionManager,
});
try {
await session.agent.streamFn(
const stream = await session.agent.streamFn(
model,
{ messages: [] },
{
@@ -138,12 +134,11 @@ describe("createAgentSession provider attribution headers", () => {
...(options.requestHeaders ? { headers: options.requestHeaders } : {}),
},
);
await stream.result();
return capturedOptions?.headers;
} finally {
session.dispose();
for (const provider of registeredProviders.reverse()) {
modelRegistry.unregisterProvider(provider);
}
modelRegistry.unregisterProvider(model.provider);
}
}
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -10,11 +10,12 @@ import {
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
describe("createAgentSession stream options", () => {
let tempDir: string;
let cwd: string;
@@ -46,6 +47,7 @@ describe("createAgentSession stream options", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
headers: { "x-model": "model" },
};
}
@@ -76,36 +78,44 @@ describe("createAgentSession stream options", () => {
api: Api,
settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number },
requestOptions: SimpleStreamOptions = {},
extensionSource?: string,
): Promise<SimpleStreamOptions | undefined> {
const model = createModel(api);
const settingsManager = SettingsManager.inMemory(settings);
if (extensionSource) {
const extensionsDir = join(agentDir, "extensions");
mkdirSync(extensionsDir, { recursive: true });
writeFileSync(join(extensionsDir, "headers.ts"), extensionSource);
}
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "test-api-key" }));
const modelRegistry = await createModelRegistry(authStorage, join(agentDir, "models.json"));
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider(model.provider, {
api,
headers: { "x-provider": "provider" },
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream(api);
},
});
const modelRuntime = getModelRuntime(modelRegistry);
const sessionManager = SessionManager.inMemory(cwd);
const { session } = await createAgentSession({
cwd,
agentDir,
model,
authStorage,
modelRegistry,
modelRuntime,
settingsManager,
sessionManager,
});
try {
await session.agent.streamFn(model, { messages: [] }, requestOptions);
const stream = await session.agent.streamFn(model, { messages: [] }, requestOptions);
await stream.result();
return capturedOptions;
} finally {
session.dispose();
@@ -150,4 +160,29 @@ describe("createAgentSession stream options", () => {
expect(options?.websocketConnectTimeoutMs).toBe(0);
});
it("runs before_provider_headers on assembled headers without forwarding the transform", async () => {
const options = await captureStreamOptions(
"openai-completions",
{},
{ headers: { "x-explicit": "explicit" } },
`export default function (pi) {
pi.on("before_provider_headers", (event) => {
event.headers["x-hook"] = [
event.headers["x-provider"],
event.headers["x-model"],
event.headers["x-explicit"],
].join(":");
});
}`,
);
expect(options?.headers).toMatchObject({
"x-provider": "provider",
"x-model": "model",
"x-explicit": "explicit",
"x-hook": "provider:model:explicit",
});
expect(options).not.toHaveProperty("transformHeaders");
});
});
@@ -52,7 +52,7 @@ describe("AgentSessionRuntime characterization", () => {
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const runtimeOptions = {
agentDir: tempDir,
@@ -343,7 +343,7 @@ describe("AgentSessionRuntime characterization", () => {
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const runtimeOptions = {
agentDir: tempDir,
@@ -454,7 +454,7 @@ describe("AgentSessionRuntime characterization", () => {
mkdirSync(secondDir, { recursive: true });
const { runtime, faux, tempDir } = await createRuntimeForTest(() => {}, { cwd: firstDir });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await otherAuthStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
@@ -527,7 +527,7 @@ describe("AgentSessionRuntime characterization", () => {
const otherDir = join(tempDir, "other");
mkdirSync(otherDir, { recursive: true });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await otherAuthStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
+4 -4
View File
@@ -1,3 +1,4 @@
import { createInMemoryModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts";
/**
* Local test harness for the new coding-agent test suite.
*/
@@ -18,7 +19,6 @@ import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-sessi
import { AuthStorage } from "../../src/core/auth-storage.ts";
import type { ExtensionRunner } from "../../src/core/extensions/index.ts";
import { convertToLlm } from "../../src/core/messages.ts";
import { ModelRegistry } from "../../src/core/model-registry.ts";
import { SessionManager } from "../../src/core/session-manager.ts";
import type { Settings } from "../../src/core/settings-manager.ts";
import { SettingsManager } from "../../src/core/settings-manager.ts";
@@ -113,9 +113,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
const authStorage = AuthStorage.inMemory();
if (withConfiguredAuth) {
authStorage.setRuntimeApiKey(model.provider, "faux-key");
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
}
const modelRegistry = ModelRegistry.inMemory(authStorage);
const modelRegistry = await createInMemoryModelRegistry(authStorage);
if (withConfiguredAuth) {
modelRegistry.registerProvider(model.provider, {
baseUrl: model.baseUrl,
@@ -178,7 +178,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader,
baseToolsOverride: toolMap,
initialActiveToolNames: options.initialActiveToolNames,
@@ -10,6 +10,7 @@ import {
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
describe("issue #2753 reload stale resource settings", () => {
@@ -32,13 +33,17 @@ describe("issue #2753 reload stale resource settings", () => {
models: [{ id: "faux-1", reasoning: false }],
});
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(agentDir, "models.json"),
});
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
modelRuntime,
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
@@ -11,6 +11,7 @@ import {
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "../../../src/index.ts";
@@ -45,13 +46,17 @@ describe("regression #2860: replaced session callbacks", () => {
faux.setResponses(responses.map((response) => fauxAssistantMessage(response)));
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(tempDir, "models.json"),
});
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir: tempDir,
authStorage,
modelRuntime,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
@@ -1,5 +1,5 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
@@ -13,10 +13,6 @@ function createFakeTui(): TUI {
} as unknown as TUI;
}
async function waitForAsyncRender(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("issue #3217 scoped model ordering", () => {
const harnesses: Harness[] = [];
@@ -83,13 +79,15 @@ describe("issue #3217 scoped model ordering", () => {
createFakeTui(),
modelOne,
harness.settingsManager,
harness.session.modelRegistry,
harness.session.modelRuntime,
[{ model: modelTwo }, { model: modelOne }, { model: modelThree }],
() => {},
() => {},
);
await waitForAsyncRender();
await vi.waitFor(() => {
expect(stripAnsi(selector.render(120).join("\n"))).toContain(`[${modelOne.provider}]`);
});
const renderedLines = stripAnsi(selector.render(120).join("\n"))
.split("\n")
@@ -70,6 +70,20 @@ describe("LoginDialogComponent OAuth prompts", () => {
expect(output).toContain("First prompt:");
});
test("preserves neutral information and links when showing a prompt", () => {
const dialog = createDialog();
dialog.showInfo("Configure credentials outside pi.", [
{ label: "Provider documentation", url: "https://example.invalid/docs" },
]);
dialog.showPrompt("Press Enter to continue:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("Configure credentials outside pi.");
expect(output).toContain("Provider documentation: https://example.invalid/docs");
expect(output).toContain("Press Enter to continue:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
@@ -7,10 +7,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { AgentSession } from "../../../src/core/agent-session.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { convertToLlm } from "../../../src/core/messages.ts";
import { ModelRegistry } from "../../../src/core/model-registry.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { createInMemoryModelRegistry, getModelRuntime } from "../../model-runtime-test-utils.ts";
import { createTestResourceLoader } from "../../utilities.ts";
describe("regression #5596: missing configured theme export", () => {
@@ -32,8 +32,8 @@ describe("regression #5596: missing configured theme export", () => {
const model = faux.getModel();
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(model.provider, "faux-key");
const modelRegistry = ModelRegistry.inMemory(authStorage);
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRegistry = await createInMemoryModelRegistry(authStorage);
modelRegistry.registerProvider(model.provider, {
baseUrl: model.baseUrl,
apiKey: "faux-key",
@@ -67,7 +67,7 @@ describe("regression #5596: missing configured theme export", () => {
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
cleanups.push(() => {
@@ -3,8 +3,8 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ENV_AGENT_DIR } from "../../../src/config.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRegistry } from "../../../src/core/model-registry.ts";
import { runMigrations } from "../../../src/migrations.ts";
import { createModelRegistry } from "../../model-runtime-test-utils.ts";
import { createHarness } from "../harness.ts";
describe("regression #5661: uppercase models.json header values", () => {
@@ -79,7 +79,7 @@ describe("regression #5661: uppercase models.json header values", () => {
expect(migrated.providers["my-provider"]?.apiKey).toBe("CUSTOM_API_KEY");
expect(migrated.providers["my-provider"]?.headers?.Authorization).toBe("BEARER");
const registry = ModelRegistry.create(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath);
const registry = await createModelRegistry(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath);
const model = registry.find("my-provider", "my-model");
expect(model).toBeDefined();
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
+14 -14
View File
@@ -17,7 +17,7 @@ describe("test harness", () => {
});
it("simple text response", async () => {
harness = createHarness({ responses: ["hello world"] });
harness = await createHarness({ responses: ["hello world"] });
await harness.session.prompt("hi");
@@ -32,7 +32,7 @@ describe("test harness", () => {
});
it("response sequence", async () => {
harness = createHarness({ responses: ["first", "second", "third"] });
harness = await createHarness({ responses: ["first", "second", "third"] });
await harness.session.prompt("a");
await harness.session.prompt("b");
@@ -60,7 +60,7 @@ describe("test harness", () => {
},
};
harness = createHarness({
harness = await createHarness({
responses: [{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, "done after tool"],
tools: [echoTool],
baseToolsOverride: { echo: echoTool },
@@ -76,7 +76,7 @@ describe("test harness", () => {
});
it("error response", async () => {
harness = createHarness({
harness = await createHarness({
responses: [{ error: "something broke" }],
});
@@ -89,7 +89,7 @@ describe("test harness", () => {
});
it("retry on transient error", async () => {
harness = createHarness({
harness = await createHarness({
responses: [{ error: "overloaded_error" }, "recovered"],
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
});
@@ -107,7 +107,7 @@ describe("test harness", () => {
});
it("custom usage numbers", async () => {
harness = createHarness({
harness = await createHarness({
responses: [{ text: "big response", usage: { input: 100000, output: 5000 } }],
});
@@ -119,7 +119,7 @@ describe("test harness", () => {
});
it("event capture", async () => {
harness = createHarness({ responses: ["hello"] });
harness = await createHarness({ responses: ["hello"] });
await harness.session.prompt("hi");
@@ -134,7 +134,7 @@ describe("test harness", () => {
});
it("context capture", async () => {
harness = createHarness({ responses: ["reply"] });
harness = await createHarness({ responses: ["reply"] });
await harness.session.prompt("my question");
@@ -145,7 +145,7 @@ describe("test harness", () => {
});
it("wraps around when more calls than responses", async () => {
harness = createHarness({ responses: ["a", "b"] });
harness = await createHarness({ responses: ["a", "b"] });
await harness.session.prompt("1");
await harness.session.prompt("2");
@@ -161,7 +161,7 @@ describe("test harness", () => {
});
it("streams text deltas", async () => {
harness = createHarness({ responses: ["hello world"] });
harness = await createHarness({ responses: ["hello world"] });
await harness.session.prompt("hi");
@@ -175,7 +175,7 @@ describe("test harness", () => {
});
it("streams thinking deltas", async () => {
harness = createHarness({
harness = await createHarness({
responses: [{ thinking: "let me think about this", text: "answer" }],
});
@@ -203,7 +203,7 @@ describe("test harness", () => {
execute: async () => ({ content: [{ type: "text", text: "echoed" }], details: {} }),
};
harness = createHarness({
harness = await createHarness({
responses: [{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, "done"],
tools: [echoTool],
baseToolsOverride: { echo: echoTool },
@@ -230,7 +230,7 @@ describe("test harness", () => {
execute: async () => ({ content: [{ type: "text", text: "echoed" }], details: {} }),
};
harness = createHarness({
harness = await createHarness({
responses: [
{
thinking: "hmm",
@@ -310,7 +310,7 @@ describe("test harness", () => {
});
it("session persistence works", async () => {
harness = createHarness({ responses: ["persisted"] });
harness = await createHarness({ responses: ["persisted"] });
await harness.session.prompt("hi");
+26 -9
View File
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* Test harness for AgentSession runtime testing.
*
@@ -28,7 +29,6 @@ import type {
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import type { Settings } from "../src/core/settings-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
@@ -361,11 +361,11 @@ function createTempDir(): string {
return tempDir;
}
function createHarnessWithResourceLoader(
async function createHarnessWithResourceLoader(
options: HarnessOptions,
resourceLoader: ResourceLoader,
tempDir: string,
): Harness {
): Promise<Harness> {
const baseModel = options.model ?? fauxModel;
const model: Model<any> = options.contextWindow ? { ...baseModel, contextWindow: options.contextWindow } : baseModel;
@@ -389,15 +389,32 @@ function createHarnessWithResourceLoader(
}
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey(model.provider, "faux-key");
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRegistry = await createModelRegistry(authStorage, tempDir);
modelRegistry.registerProvider(model.provider, {
baseUrl: model.baseUrl,
api: model.api,
models: [
{
id: model.id,
name: model.name,
api: model.api,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
baseUrl: model.baseUrl,
},
],
});
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader,
baseToolsOverride: options.baseToolsOverride,
});
@@ -429,18 +446,18 @@ function createHarnessWithResourceLoader(
};
}
export function createHarness(options: HarnessOptions = {}): Harness {
export async function createHarness(options: HarnessOptions = {}): Promise<Harness> {
if (options.extensionFactories?.length) {
throw new Error("createHarness does not support extensionFactories. Use createHarnessWithExtensions().");
}
const tempDir = createTempDir();
return createHarnessWithResourceLoader(options, options.resourceLoader ?? createTestResourceLoader(), tempDir);
return await createHarnessWithResourceLoader(options, options.resourceLoader ?? createTestResourceLoader(), tempDir);
}
export async function createHarnessWithExtensions(options: HarnessOptions = {}): Promise<Harness> {
const tempDir = createTempDir();
const extensionsResult = await createTestExtensionsResult(options.extensionFactories ?? [], tempDir);
const resourceLoader = options.resourceLoader ?? createTestResourceLoader({ extensionsResult });
return createHarnessWithResourceLoader(options, resourceLoader, tempDir);
return await createHarnessWithResourceLoader(options, resourceLoader, tempDir);
}
+15 -22
View File
@@ -1,3 +1,4 @@
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
/**
* Shared test utilities for coding-agent tests.
*/
@@ -6,8 +7,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import { getModel, type OAuthCredentials, type OAuthProvider } from "@earendil-works/pi-ai/compat";
import { getOAuthApiKey } from "@earendil-works/pi-ai/oauth";
import type { OAuthCredentials } from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai/compat";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createEventBus } from "../src/core/event-bus.ts";
@@ -18,7 +20,6 @@ import type {
LoadExtensionsResult,
} from "../src/core/extensions/index.ts";
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import type { ResourceLoader } from "../src/core/resource-loader.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
@@ -88,23 +89,15 @@ export async function resolveApiKey(provider: string): Promise<string | undefine
}
if (entry.type === "oauth") {
// Build OAuthCredentials record for getOAuthApiKey
const oauthCredentials: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(storage)) {
if (value.type === "oauth") {
const { type: _, ...creds } = value;
oauthCredentials[key] = creds;
}
const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
if (!oauth) return undefined;
let credential = entry;
if (Date.now() >= credential.expires) {
credential = await oauth.refresh(credential);
storage[provider] = credential;
saveAuthStorage(storage);
}
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials);
if (!result) return undefined;
// Save refreshed credentials back to auth.json
storage[provider] = { type: "oauth", ...result.newCredentials };
saveAuthStorage(storage);
return result.apiKey;
return (await oauth.toAuth(credential)).apiKey;
}
return undefined;
@@ -241,7 +234,7 @@ export function createTestResourceLoader(options: CreateTestResourceLoaderOption
* Create an AgentSession for testing with proper setup and cleanup.
* Use this for e2e tests that need real LLM calls.
*/
export function createTestSession(options: TestSessionOptions = {}): TestSessionContext {
export async function createTestSession(options: TestSessionOptions = {}): Promise<TestSessionContext> {
const tempDir = join(tmpdir(), `pi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
@@ -263,14 +256,14 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
}
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
const modelRegistry = await createModelRegistry(authStorage, tempDir);
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
+2
View File
@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url));
const aiSrcProviders = fileURLToPath(new URL("../ai/src/providers", import.meta.url));
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url));
const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url));
@@ -25,6 +26,7 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@earendil-works\/pi-ai\/providers\/(.+)$/, replacement: `${aiSrcProviders}/$1.ts` },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex },
{ find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex },