Files
agent-manager/src/commands/models_cmd.rs
T
bruno 22bc529818 feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)
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.
2026-08-18 09:06:16 -04:00

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()
}