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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user