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