Files
oh-my-pi/packages/coding-agent/src/cli/list-models.ts
T
can1357 e18b901a8d refactor(packages/coding-agent): reorganized canonical model selection
- Replaced canonical-row resolution with getCanonicalModelSelections in model lists and selector flow.
- Hydrated model selector state from registry on construction and kept cached selections during refresh.
- Preserved highlighted and cached model selection when offline refresh completed or reordered models.
- Added parity checks between getCanonicalModelSelections and resolveCanonicalModel via registry tests.
2026-06-09 20:46:08 +02:00

194 lines
6.2 KiB
TypeScript

/**
* List available models with optional fuzzy search
*/
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
import { fuzzyFilter } from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
import { EventBus } from "../utils/event-bus";
interface ProviderRow {
provider: string;
model: string;
context: string;
maxOut: string;
thinking: string;
images: string;
}
interface CanonicalRow {
canonical: string;
selected: string;
variants: string;
context: string;
maxOut: string;
}
function writeLine(line = ""): void {
process.stdout.write(`${line}\n`);
}
function renderTable<T extends Record<string, string>>(rows: T[], headers: T): void {
const widths = Object.fromEntries(
Object.keys(headers).map(key => [key, Math.max(headers[key]!.length, ...rows.map(row => row[key]!.length))]),
) as Record<keyof T, number>;
const headerLine = Object.keys(headers)
.map(key => headers[key as keyof T]!.padEnd(widths[key as keyof T]))
.join(" ");
writeLine(headerLine);
for (const row of rows) {
const line = Object.keys(headers)
.map(key => row[key as keyof T]!.padEnd(widths[key as keyof T]))
.join(" ");
writeLine(line);
}
}
/**
* List available models, optionally filtered by search pattern
*/
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
const models = modelRegistry.getAvailable();
if (models.length === 0) {
writeLine("No models available. Set API keys in environment variables.");
return;
}
let filteredModels: Model<Api>[] = models;
if (searchPattern) {
filteredModels = fuzzyFilter(models, searchPattern, model => `${model.provider} ${model.id}`);
}
const filteredCanonical = modelRegistry
.getCanonicalModelSelections({ availableOnly: true, candidates: filteredModels })
.map(
({ record, model: selected }): CanonicalRow => ({
canonical: record.id,
selected: `${selected.provider}/${selected.id}`,
variants: String(record.variants.length),
context: formatNumber(selected.contextWindow),
maxOut: formatNumber(selected.maxTokens),
}),
)
.sort((left, right) => left.canonical.localeCompare(right.canonical));
if (filteredModels.length === 0 && filteredCanonical.length === 0) {
writeLine(`No models matching "${searchPattern}"`);
return;
}
filteredModels.sort((left, right) => {
const providerCmp = left.provider.localeCompare(right.provider);
if (providerCmp !== 0) return providerCmp;
return left.id.localeCompare(right.id);
});
const providerRows = filteredModels.map(model => ({
provider: model.provider,
model: model.id,
context: formatNumber(model.contextWindow),
maxOut: formatNumber(model.maxTokens),
thinking: model.thinking ? getSupportedEfforts(model).join(",") : model.reasoning ? "yes" : "-",
images: model.input.includes("image") ? "yes" : "no",
})) satisfies ProviderRow[];
if (filteredCanonical.length > 0) {
writeLine("Canonical models");
renderTable(filteredCanonical, {
canonical: "canonical",
selected: "selected",
variants: "variants",
context: "context",
maxOut: "max-out",
});
if (providerRows.length > 0) {
writeLine();
}
}
if (providerRows.length > 0) {
writeLine("Provider models");
renderTable(providerRows, {
provider: "provider",
model: "model",
context: "context",
maxOut: "max-out",
thinking: "thinking",
images: "images",
});
}
}
/**
* Options for the `--list-models` command entry point.
*/
export interface RunListModelsOptions {
modelRegistry: ModelRegistry;
cwd: string;
/** CLI-supplied extension paths (e.g. from `-e <path>`). */
additionalExtensionPaths?: string[];
/** Extension paths configured under `extensions:` in user settings. */
settingsExtensions?: string[];
/** Disabled extension ids from settings (`disabledExtensions`). */
disabledExtensionIds?: string[];
/** When true, skip discovery and only load `additionalExtensionPaths`. */
disableExtensionDiscovery?: boolean;
searchPattern?: string;
}
/**
* Loads extensions (CLI `-e` paths and `settings.extensions`) and surfaces
* any provider/model registrations on the supplied `modelRegistry` before
* delegating to {@link listModels}. This is the single entry point used by
* `--list-models` and exists to ensure extension-contributed providers are
* visible in the listing (issue #905). The load is intentionally narrow:
* no agent loop, no MCP servers, no custom-tool registration.
*/
export async function runListModelsCommand(options: RunListModelsOptions): Promise<void> {
const {
modelRegistry,
cwd,
additionalExtensionPaths = [],
settingsExtensions = [],
disabledExtensionIds = [],
disableExtensionDiscovery = false,
searchPattern,
} = options;
const eventBus = new EventBus();
const extensionsResult = disableExtensionDiscovery
? await loadExtensions(additionalExtensionPaths, cwd, eventBus)
: await discoverAndLoadExtensions(
[...additionalExtensionPaths, ...settingsExtensions],
cwd,
eventBus,
disabledExtensionIds,
);
for (const { path: extPath, error } of extensionsResult.errors) {
process.stderr.write(`Failed to load extension: ${extPath}: ${error}\n`);
}
// Mirror sdk.ts: drain pending provider registrations into the registry.
const activeSources = extensionsResult.extensions.map(extension => extension.path);
modelRegistry.syncExtensionSources(activeSources);
for (const sourceId of new Set(activeSources)) {
modelRegistry.clearSourceRegistrations(sourceId);
}
for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
modelRegistry.registerProvider(name, config, sourceId);
}
extensionsResult.runtime.pendingProviderRegistrations = [];
// Discover runtime (extension) provider catalogs now that they are registered.
// The full refresh in main.ts ran before extensions loaded, so this is the only
// point where extension-contributed dynamic providers get discovered. Cache-aware
// so it reuses the shared 24 h model cache instead of refetching every invocation.
await modelRegistry.refreshRuntimeProviders("online-if-uncached");
await listModels(modelRegistry, searchPattern);
}