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.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
//! 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()
|
||||
}
|
||||
Reference in New Issue
Block a user