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
@@ -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.