Axe 11 (modèles) : - am models : inventaire des runtimes locaux (ollama list --format json, llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale - am run <agent> --model <m> : résolution dans l'inventaire, transmission via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session - am models --prune : candidats inutilisés depuis settings.models_prune_days (défaut 30), :latest toujours protégé, dry-run + confirmation + événement EventKind::Model journalisé Axes 7/9 (catalogue) : - am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/ modifiés), confirmation, enregistrement en include - am catalog add <url> : validation du format (parse + validate), cache catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé - include par URL (http/https/file) dans config.yaml, résolu au chargement avec cache + TTL ; override local toujours prioritaire (issue #67) - am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par usage réel (lancements + durée depuis le journal), top 5 justifié, --json REPL : commandes models/catalog/suggest (parse, complétion, descriptions), help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles). Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
199 lines
6.0 KiB
Rust
199 lines
6.0 KiB
Rust
//! 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<u64>,
|
|
quantization: Option<String>,
|
|
last_used: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RuntimeRow {
|
|
runtime: String,
|
|
detected: bool,
|
|
note: Option<String>,
|
|
models: usize,
|
|
}
|
|
|
|
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
|
|
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<Vec<String>> = 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<i32> {
|
|
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<ModelRow> = 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<u64>) -> 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<String, u64> {
|
|
BTreeMap::new()
|
|
}
|