/** * Model resolution: exact match ("provider/modelId") with fuzzy fallback. */ export interface ModelEntry { id: string; name: string; provider: string; contextWindow?: number; reasoning?: boolean; inputTypes?: string[]; } export interface ModelRegistry { find(provider: string, modelId: string): any; getAll(): any[]; getAvailable?(): any[]; } /** * Resolve a model string to a Model instance. * Tries exact match first ("provider/modelId"), then fuzzy match against all available models. * Returns the Model on success, or an error message string on failure. */ export function resolveModel( input: string, registry: ModelRegistry, ): any | string { // Available models (those with auth configured) const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[]; const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase())); // 1. Exact match: "provider/modelId" — only if available (has auth) const slashIdx = input.indexOf("/"); if (slashIdx !== -1) { const provider = input.slice(0, slashIdx); const modelId = input.slice(slashIdx + 1); if (availableSet.has(input.toLowerCase())) { const found = registry.find(provider, modelId); if (found) return found; } } // 2. Fuzzy match against available models const query = input.toLowerCase(); // Score each model: prefer exact id match > id contains > name contains > provider+id contains let bestMatch: ModelEntry | undefined; let bestScore = 0; for (const m of all) { const id = m.id.toLowerCase(); const name = m.name.toLowerCase(); const full = `${m.provider}/${m.id}`.toLowerCase(); let score = 0; if (id === query || full === query) { score = 100; // exact } else if (id.includes(query) || full.includes(query)) { score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches } else if (name.includes(query)) { score = 40 + (query.length / name.length) * 20; } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) { score = 20; // all parts present somewhere } if (score > bestScore) { bestScore = score; bestMatch = m; } } if (bestMatch && bestScore >= 20) { const found = registry.find(bestMatch.provider, bestMatch.id); if (found) return found; } // 3. No match — list available models const modelList = all .map(m => ` ${m.provider}/${m.id}`) .sort() .join("\n"); return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`; } // ---- Model Picker Types ---- export interface ModelPickerSection { label: string; models: ModelPickerItem[]; } export interface ModelPickerItem { value: string; label: string; description: string; } /** * Read ~/.pi/agent/models.json and return providers grouped with their model entries. * Returns empty Map if the file is missing or malformed. */ export function scanModelsConfig(): Map { try { const { readFileSync, existsSync } = require("node:fs"); const { homedir } = require("node:os"); const { join } = require("node:path"); const configPath = join(homedir(), ".pi/agent/models.json"); if (!existsSync(configPath)) { return new Map(); } const raw = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(raw); // Validate top-level is an object if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { console.warn("models.json: top-level value must be an object, got", typeof parsed); return new Map(); } const result = new Map(); for (const [provider, models] of Object.entries(parsed)) { if (!Array.isArray(models)) { console.warn(`models.json: provider "${provider}" value is not an array, skipping`); continue; } const entries: ModelEntry[] = []; for (const m of models) { if (!m || typeof m !== "object") { console.warn(`models.json: skipping invalid entry in "${provider}"`); continue; } const id = typeof m.id === "string" ? m.id : ""; const name = typeof m.name === "string" ? m.name : ""; if (!id && !name) { console.warn(`models.json: skipping entry in "${provider}" without id or name`); continue; } if (m.contextWindow !== undefined && (typeof m.contextWindow !== "number" || !Number.isFinite(m.contextWindow))) { console.warn(`models.json: skipping entry "${id || name}" in "${provider}" — invalid contextWindow`); continue; } if (m.reasoning !== undefined && typeof m.reasoning !== "boolean") { console.warn(`models.json: skipping entry "${id || name}" in "${provider}" — invalid reasoning (expected boolean)`); continue; } if (m.inputTypes !== undefined && !Array.isArray(m.inputTypes)) { console.warn(`models.json: skipping entry "${id || name}" in "${provider}" — invalid inputTypes (expected array)`); continue; } entries.push({ id: id || name, name: name || id, provider, contextWindow: m.contextWindow, reasoning: m.reasoning, inputTypes: m.inputTypes, }); } if (entries.length > 0) { result.set(provider, entries); } } return result; } catch (err) { console.warn("Failed to read models.json:", err); return new Map(); } } /** * Build picker sections from scanModelsConfig(), prepending an "inherit" option * and appending a "Custom..." option. */ export function getModelPickerSections(): ModelPickerSection[] { const config = scanModelsConfig(); const sections: ModelPickerSection[] = []; // Inherit option sections.push({ label: "General", models: [ { value: "inherit", label: "Inherit from parent", description: "Use the parent session's model" }, ], }); // Provider sections for (const [provider, models] of config) { if (models.length === 0) continue; const items: ModelPickerItem[] = models.map(m => { const parts: string[] = []; if (m.contextWindow) parts.push(`${m.contextWindow.toLocaleString()} ctx`); if (m.reasoning) parts.push("reasoning"); const description = parts.length > 0 ? parts.join(" · ") : ""; return { value: `${m.provider}/${m.id}`, label: m.name, description, }; }); sections.push({ label: provider, models: items }); } // Custom option sections.push({ label: "", models: [ { value: "__custom__", label: "Custom...", description: "Type a provider/modelId manually" }, ], }); return sections; } /** * Resolve a "provider/modelId" string to the human-readable name from models.json. * Falls back to returning modelId as-is if not found. */ export function getModelLabel(modelId: string): string { if (!modelId || modelId === "inherit") return "inherit"; const config = scanModelsConfig(); for (const [, models] of config) { for (const m of models) { if (`${m.provider}/${m.id}` === modelId || m.id === modelId) { return m.name || modelId; } } } return modelId; }