/** * Model resolution, scoping, and initial selection */ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { isValidThinkingLevel } from "../cli/args.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; import type { SettingsManager } from "./settings-manager.js"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { anthropic: "claude-sonnet-4-5", openai: "gpt-5.1-codex", google: "gemini-2.5-pro", "github-copilot": "gpt-4o", openrouter: "openai/gpt-5.1-codex", xai: "grok-4-fast-non-reasoning", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.6", zai: "glm-4.6", mistral: "devstral-medium-latest", }; export interface ScopedModel { model: Model; thinkingLevel: ThinkingLevel; } /** * Resolve model patterns to actual Model objects with optional thinking levels * Format: "pattern:level" where :level is optional * For each pattern, finds all matching models and picks the best version: * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929) * 2. If no alias, pick the latest dated version */ export async function resolveModelScope(patterns: string[]): Promise { const { models: availableModels, error } = await getAvailableModels(); if (error) { console.warn(chalk.yellow(`Warning: Error loading models: ${error}`)); return []; } const scopedModels: ScopedModel[] = []; for (const pattern of patterns) { // Parse pattern:level format const parts = pattern.split(":"); const modelPattern = parts[0]; let thinkingLevel: ThinkingLevel = "off"; if (parts.length > 1) { const level = parts[1]; if (isValidThinkingLevel(level)) { thinkingLevel = level; } else { console.warn( chalk.yellow(`Warning: Invalid thinking level "${level}" in pattern "${pattern}". Using "off" instead.`), ); } } // Check for provider/modelId format (provider is everything before the first /) const slashIndex = modelPattern.indexOf("/"); if (slashIndex !== -1) { const provider = modelPattern.substring(0, slashIndex); const modelId = modelPattern.substring(slashIndex + 1); const providerMatch = availableModels.find( (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(), ); if (providerMatch) { if ( !scopedModels.find( (sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider, ) ) { scopedModels.push({ model: providerMatch, thinkingLevel }); } continue; } // No exact provider/model match - fall through to other matching } // Check for exact ID match (case-insensitive) const exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase()); if (exactMatch) { // Exact match found - use it directly if (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) { scopedModels.push({ model: exactMatch, thinkingLevel }); } continue; } // No exact match - fall back to partial matching const matches = availableModels.filter( (m) => m.id.toLowerCase().includes(modelPattern.toLowerCase()) || m.name?.toLowerCase().includes(modelPattern.toLowerCase()), ); if (matches.length === 0) { console.warn(chalk.yellow(`Warning: No models match pattern "${modelPattern}"`)); continue; } // Helper to check if a model ID looks like an alias (no date suffix) // Dates are typically in format: -20241022 or -20250929 const isAlias = (id: string): boolean => { // Check if ID ends with -latest if (id.endsWith("-latest")) return true; // Check if ID ends with a date pattern (-YYYYMMDD) const datePattern = /-\d{8}$/; return !datePattern.test(id); }; // Separate into aliases and dated versions const aliases = matches.filter((m) => isAlias(m.id)); const datedVersions = matches.filter((m) => !isAlias(m.id)); let bestMatch: Model; if (aliases.length > 0) { // Prefer alias - if multiple aliases, pick the one that sorts highest aliases.sort((a, b) => b.id.localeCompare(a.id)); bestMatch = aliases[0]; } else { // No alias found, pick latest dated version datedVersions.sort((a, b) => b.id.localeCompare(a.id)); bestMatch = datedVersions[0]; } // Avoid duplicates if (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) { scopedModels.push({ model: bestMatch, thinkingLevel }); } } return scopedModels; } export interface InitialModelResult { model: Model | null; thinkingLevel: ThinkingLevel; fallbackMessage: string | null; } /** * Find the initial model to use based on priority: * 1. CLI args (provider + model) * 2. First model from scoped models (if not continuing/resuming) * 3. Restored from session (if continuing/resuming) * 4. Saved default from settings * 5. First available model with valid API key */ export async function findInitialModel(options: { cliProvider?: string; cliModel?: string; scopedModels: ScopedModel[]; isContinuing: boolean; settingsManager: SettingsManager; }): Promise { const { cliProvider, cliModel, scopedModels, isContinuing, settingsManager } = options; let model: Model | null = null; let thinkingLevel: ThinkingLevel = "off"; // 1. CLI args take priority if (cliProvider && cliModel) { const { model: found, error } = findModel(cliProvider, cliModel); if (error) { console.error(chalk.red(error)); process.exit(1); } if (!found) { console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`)); process.exit(1); } return { model: found, thinkingLevel: "off", fallbackMessage: null }; } // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { return { model: scopedModels[0].model, thinkingLevel: scopedModels[0].thinkingLevel, fallbackMessage: null, }; } // 3. Try saved default from settings const defaultProvider = settingsManager.getDefaultProvider(); const defaultModelId = settingsManager.getDefaultModel(); if (defaultProvider && defaultModelId) { const { model: found, error } = findModel(defaultProvider, defaultModelId); if (error) { console.error(chalk.red(error)); process.exit(1); } if (found) { model = found; // Also load saved thinking level const savedThinking = settingsManager.getDefaultThinkingLevel(); if (savedThinking) { thinkingLevel = savedThinking; } return { model, thinkingLevel, fallbackMessage: null }; } } // 4. Try first available model with valid API key const { models: availableModels, error } = await getAvailableModels(); if (error) { console.error(chalk.red(error)); process.exit(1); } if (availableModels.length > 0) { // Try to find a default model from known providers for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); if (match) { return { model: match, thinkingLevel: "off", fallbackMessage: null }; } } // If no default found, use first available return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null }; } // 5. No model found return { model: null, thinkingLevel: "off", fallbackMessage: null }; } /** * Restore model from session, with fallback to available models */ export async function restoreModelFromSession( savedProvider: string, savedModelId: string, currentModel: Model | null, shouldPrintMessages: boolean, ): Promise<{ model: Model | null; fallbackMessage: string | null }> { const { model: restoredModel, error } = findModel(savedProvider, savedModelId); if (error) { console.error(chalk.red(error)); process.exit(1); } // Check if restored model exists and has a valid API key const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false; if (restoredModel && hasApiKey) { if (shouldPrintMessages) { console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`)); } return { model: restoredModel, fallbackMessage: null }; } // Model not found or no API key - fall back const reason = !restoredModel ? "model no longer exists" : "no API key available"; if (shouldPrintMessages) { console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`)); } // If we already have a model, use it as fallback if (currentModel) { if (shouldPrintMessages) { console.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`)); } return { model: currentModel, fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`, }; } // Try to find any available model const { models: availableModels, error: availableError } = await getAvailableModels(); if (availableError) { console.error(chalk.red(availableError)); process.exit(1); } if (availableModels.length > 0) { // Try to find a default model from known providers let fallbackModel: Model | null = null; for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); if (match) { fallbackModel = match; break; } } // If no default found, use first available if (!fallbackModel) { fallbackModel = availableModels[0]; } if (shouldPrintMessages) { console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`)); } return { model: fallbackModel, fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`, }; } // No models available return { model: null, fallbackMessage: null }; }