//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio) //! and pruning of unused models (issues #70 #73, axe 11). use super::*; use crate::cli::ModelsArgs; use crate::events::{Event, EventKind}; use crate::models::{self, RuntimeModel}; use crate::output::print_json; use anyhow::Result; use serde::Serialize; use std::collections::BTreeMap; #[derive(Serialize)] struct ModelRow { runtime: String, name: String, size_bytes: Option, quantization: Option, last_used: Option, } #[derive(Serialize)] struct RuntimeRow { runtime: String, detected: bool, note: Option, models: usize, } pub fn run(app: &App, args: &ModelsArgs) -> Result { if args.prune { return prune(app, args); } let inventory = models::detect(app); if app.json() { print_json(&serde_json::json!({ "runtimes": inventory.runtimes, "models": inventory.models, })); return Ok(0); } let theme = app.theme(); let color = app.color(); // Runtime status lines. for r in &inventory.runtimes { let icon = if r.detected { "✓" } else { "✗" }; let note = r.note.as_deref().unwrap_or(""); println!(" {icon} {} — {note}", r.runtime); } if !inventory.any_runtime() { app.log.info( "no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API", ); return Ok(0); } if inventory.models.is_empty() { app.log.info("runtimes detected but no models found"); return Ok(0); } // Table: runtime | model | size | quant | last used. let rows: Vec> = inventory .models .iter() .map(|m| { vec![ m.runtime.clone(), m.name.clone(), human_size(m.size_bytes), m.quantization.clone().unwrap_or_else(|| "—".to_string()), m.last_used.clone().unwrap_or_else(|| "—".to_string()), ] }) .collect(); let _ = (theme, color); print!( "{}", crate::output::boxed_table( vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"], rows, app.theme(), app.color(), ) ); println!( "\n {} model(s) — 'am models --prune --dry-run' lists unused ones", inventory.models.len() ); Ok(0) } /// prune: list unused models, then delete them after confirmation. fn prune(app: &App, args: &ModelsArgs) -> Result { let days = args.days.unwrap_or_else(|| { app.config .settings .models_prune_days .unwrap_or(models::DEFAULT_PRUNE_DAYS) }); let inventory = models::detect(app); let candidates = models::prune_candidates(&inventory.models, days); let _ = &inventory; if app.json() { let rows: Vec = candidates .iter() .map(|m| ModelRow { runtime: m.runtime.clone(), name: m.name.clone(), size_bytes: m.size_bytes, quantization: m.quantization.clone(), last_used: m.last_used.clone(), }) .collect(); print_json(&rows); return Ok(if rows.is_empty() { 0 } else { 1 }); } if candidates.is_empty() { app.log.success(&format!( "no unused model found (threshold: {days} days; ':latest' models are always kept)" )); return Ok(0); } println!( "{} model(s) unused for more than {days} days (':latest' models are protected):", candidates.len() ); for m in &candidates { let size = human_size(m.size_bytes); let last = m.last_used.as_deref().unwrap_or("unknown"); println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name); } if args.dry_run { app.log.info("dry-run: nothing was deleted — remove --dry-run to prune"); return Ok(0); } let asked = app .confirm(&format!("delete these {} model(s)?", candidates.len()))?; if !asked { app.log.info("prune cancelled"); return Ok(0); } let mut removed = 0; let mut errors = 0; for m in &candidates { match models::remove_model(m) { Ok(()) => { app.emit( &Event::now(EventKind::Model) .with_agent(m.runtime.clone()) .with_args(vec![m.name.clone()]) .with_reason("prune"), ); app.log.success(&format!("removed {} ({})", m.name, m.runtime)); removed += 1; } Err(e) => { app.log.error(&format!("{}: {e:#}", m.name)); errors += 1; } } } // The inventory cache is stale after a prune: drop it so the next run // rescans the runtimes. let cache_path = app.events_dir().join("models-cache.json"); let _ = std::fs::remove_file(&cache_path); if errors > 0 { app.log .error(&format!("{errors} model(s) could not be removed")); return Ok(1); } app.log .success(&format!("{removed} model(s) removed from the local runtimes")); Ok(0) } /// Human-readable size ("1.2 GB", "450 MB", "—" when unknown). fn human_size(bytes: Option) -> String { match bytes { None => "—".to_string(), Some(b) => { let kb = b as f64 / 1024.0; let mb = kb / 1024.0; let gb = mb / 1024.0; if gb >= 1.0 { format!("{gb:.1} GB") } else if mb >= 1.0 { format!("{mb:.0} MB") } else { format!("{kb:.0} KB") } } } } /// Usage of each model name across sessions (for --json consumers). #[allow(dead_code)] fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap { BTreeMap::new() }