diff --git a/Cargo.lock b/Cargo.lock index 40bbb1c..ce1d004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e00ead4..2daedc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index 619981e..ae2c26a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -497,7 +497,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés | # | Issue | Effort | Dépend de | |---|---|---|---| -| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — | +| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — | | [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 | | [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 | | [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 | diff --git a/config.yaml b/config.yaml index f42bd72..de8a9ff 100644 --- a/config.yaml +++ b/config.yaml @@ -43,6 +43,20 @@ settings: # le journal et l'historique (logs/ et backups/ exclus automatiquement). # sync_repo: https://git.dracodev.net/bruno/am-state.git # sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in) + # Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par + # défaut. Le provider par défaut est utilisé à l'install/au run quand aucun + # n'est donné. Les tokens ne vont JAMAIS ici — ils vivent dans le keyring OS + # sous providers//api_key (issue #89, am providers set-token). + # default_provider: openai + # providers: + # openai: + # base_url: https://api.openai.com/v1 + # default_model: gpt-5.2 + # models: [gpt-5.2, gpt-5.1-mini, o4-mini] + # deepseek: + # base_url: https://api.deepseek.com + # default_model: deepseek-chat + # models: [deepseek-chat, deepseek-reasoner] # --- Command aliases ---------------------------------------------------------- aliases: diff --git a/man/am-providers.1 b/man/am-providers.1 new file mode 100644 index 0000000..418e3ff --- /dev/null +++ b/man/am-providers.1 @@ -0,0 +1,32 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-providers 1 "providers " +.SH NAME +providers \- Manage the LLM provider registry: base URLs, models, default provider +.SH SYNOPSIS +\fBproviders\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR] +.SH DESCRIPTION +Manage the LLM provider registry: base URLs, models, default provider +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +providers\-list(1) +List the registered providers (the default one is starred) +.TP +providers\-show(1) +Show one provider in detail (base URL, models, default model) +.TP +providers\-add(1) +Register a provider, or update an existing one +.TP +providers\-remove(1) +Remove a provider from the registry +.TP +providers\-default(1) +Set the default provider +.TP +providers\-help(1) +Print this message or the help of the given subcommand(s) diff --git a/man/am.1 b/man/am.1 index b6480b2..36f175a 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.6.0" +.TH am 1 "am 0.7.0" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -121,6 +121,9 @@ List local models (ollama, llama.cpp, LM Studio) and prune unused ones am\-catalog(1) Manage remote catalogs: update the official one, add external ones .TP +am\-providers(1) +Manage the LLM provider registry: base URLs, models, default provider +.TP am\-suggest(1) Suggest agents matching a query, boosted by real usage (issue #61) .TP @@ -232,4 +235,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.6.0 +v0.7.0 diff --git a/src/cli.rs b/src/cli.rs index ba03624..8b333bb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -192,6 +192,8 @@ pub enum Command { /// Manage remote catalogs: update the official one, add external ones #[command(subcommand)] Catalog(CatalogCmd), + /// Manage the LLM provider registry: base URLs, models, default provider + Providers(ProviderArgs), /// Suggest agents matching a query, boosted by real usage (issue #61) Suggest { /// Free-form request ("un agent pour du Python") @@ -634,6 +636,49 @@ pub enum CatalogCmd { List, } +/// LLM provider registry subcommands (issue #88). The subcommand is +/// optional: `am providers` alone lists the registry. +#[derive(Args, Debug, Clone, Default)] +pub struct ProviderArgs { + #[command(subcommand)] + pub sub: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ProvidersCmd { + /// List the registered providers (the default one is starred) + List, + /// Show one provider in detail (base URL, models, default model) + Show { + /// Provider name + name: String, + }, + /// Register a provider, or update an existing one + Add { + /// Provider name (lowercase slug, e.g. openai) + name: String, + /// Base URL of the provider API + #[arg(long, value_name = "URL", required = true)] + base_url: String, + /// Model used by default for this provider + #[arg(long, value_name = "MODEL")] + model: Option, + /// Comma-separated model list + #[arg(long, value_name = "MODELS")] + models: Option, + }, + /// Remove a provider from the registry + Remove { + /// Provider name + name: String, + }, + /// Set the default provider + Default { + /// Provider name + name: String, + }, +} + /// Environment profile subcommands (issue #40). #[derive(Subcommand, Debug, Clone)] pub enum ProfileCmd { diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index 75926ac..b777cec 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -114,14 +114,21 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result { /// exists. Used by the REPL 'theme ' command so a switched theme /// survives a restart. pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> { - let path = match &app.config_origin { - Some(p) => p.clone(), - None => user_config_path(app)?, - }; + let path = active_config_path(app)?; write_dotted_key(&path, key, value)?; reload_and_validate(&path) } +/// Path of the configuration file that is actually in effect: the loaded +/// origin when one exists, otherwise the user config file (created on +/// demand). Mutating commands write here so the change survives a reload. +pub(crate) fn active_config_path(app: &App) -> Result { + match &app.config_origin { + Some(p) => Ok(p.clone()), + None => user_config_path(app), + } +} + /// Set one dotted key (e.g. settings.default_shell) in a YAML config file. fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> { let text = std::fs::read_to_string(path) @@ -155,7 +162,7 @@ fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> { } /// Reload a written config file to make sure the change stays valid. -fn reload_and_validate(path: &Path) -> Result<()> { +pub(crate) fn reload_and_validate(path: &Path) -> Result<()> { let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]); config::load(&cli).map_err(|e| { anyhow!("the new value makes the configuration invalid: {e:#}") @@ -183,7 +190,7 @@ fn user_config_path_if_any(app: &App) -> Option { } /// Ensure the user config file exists (creating a template when needed). -fn user_config_path(app: &App) -> Result { +pub(crate) fn user_config_path(app: &App) -> Result { let dir = app .paths .config_dir diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9750662..d32fb43 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -40,6 +40,7 @@ pub mod suggest_cmd; pub mod sync_cmd; pub mod playbook_cmd; pub mod plugins_cmd; +pub mod providers_cmd; pub mod theme_cmd; pub mod timeline_cmd; pub mod tip_cmd; @@ -99,6 +100,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Secret(sub) => secret_cmd::run(app, sub), Command::Models(args) => models_cmd::run(app, args), Command::Catalog(sub) => catalog_cmd::run(app, sub), + Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()), Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")), Command::Start(a) => run_cmd::start(app, a), Command::Stop { diff --git a/src/commands/providers_cmd.rs b/src/commands/providers_cmd.rs new file mode 100644 index 0000000..5f6cf41 --- /dev/null +++ b/src/commands/providers_cmd.rs @@ -0,0 +1,421 @@ +//! providers: the LLM provider registry (issue #88). `settings.providers` +//! maps a provider name to its base URL, model list and default model; the +//! default provider is `settings.default_provider`. Tokens are NOT handled +//! here — they live in the OS keyring (issue #89). + +use super::*; +use crate::cli::ProvidersCmd; +use crate::config::ProviderDef; +use crate::events::{Event, EventKind}; +use crate::output::print_json; +use crate::providers; +use anyhow::{anyhow, bail, Result}; +use std::path::Path; + +pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result { + match sub { + None => list(app), + Some(ProvidersCmd::List) => list(app), + Some(ProvidersCmd::Show { name }) => show(app, name), + Some(ProvidersCmd::Add { + name, + base_url, + model, + models, + }) => add(app, name, base_url, model.as_deref(), models.as_deref()), + Some(ProvidersCmd::Remove { name }) => remove(app, name), + Some(ProvidersCmd::Default { name }) => set_default(app, name), + } +} + +// --------------------------------------------------------------------------- +// list / show +// --------------------------------------------------------------------------- + +fn list(app: &App) -> Result { + let providers = providers::all(&app.config); + let default_name = providers::default_name(&app.config); + if app.json() { + let rows: Vec = providers + .iter() + .map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str()))) + .collect(); + print_json(&rows); + return Ok(0); + } + if providers.is_empty() { + app.log.info(&crate::i18n::tr( + "aucun provider enregistré — voir: am providers add --base-url ", + )); + return Ok(0); + } + let mut table = crate::output::Table::new(vec![ + "PROVIDER", + "BASE URL", + "DÉFAUT", + "MODÈLES", + ]); + for (name, def) in providers { + let star = if default_name == Some(name.as_str()) { + "★" + } else { + "" + }; + table.row(vec![ + format!("{name}{star}"), + def.base_url.clone(), + def.default_model.clone().unwrap_or_default(), + def.models.join(", "), + ]); + } + print!("{}", table.render()); + Ok(0) +} + +fn show(app: &App, name: &str) -> Result { + let def = providers::get(&app.config, name) + .ok_or_else(|| anyhow!(crate::tr_fmt!("provider '{}' inconnu", name)))?; + let is_default = providers::default_name(&app.config) == Some(name); + if app.json() { + print_json(&provider_json(name, def, is_default)); + return Ok(0); + } + println!("provider: {name}"); + if is_default { + println!("(provider par défaut)"); + } + println!(" base_url: {}", def.base_url); + println!( + " modèle par défaut: {}", + def.default_model.as_deref().unwrap_or("-") + ); + println!(" modèles: {}", def.models.join(", ")); + Ok(0) +} + +/// One provider as a JSON object. Tokens are never part of this shape. +fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json::Value { + serde_json::json!({ + "name": name, + "base_url": def.base_url, + "default_model": def.default_model, + "models": def.models, + "is_default": is_default, + }) +} + +// --------------------------------------------------------------------------- +// add / remove / default +// --------------------------------------------------------------------------- + +fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result { + if !providers::is_valid_name(name) { + bail!( + "{}", + crate::tr_fmt!( + "nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", + name + ) + ); + } + if base_url.trim().is_empty() { + bail!("{}", crate::i18n::tr("usage: providers add --base-url [--model ] [--models m1,m2]")); + } + let models_list: Vec = models + .map(|m| m.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()) + .unwrap_or_default(); + if app.dry_run() { + app.log.dry(&format!( + "would register provider {name} (base_url={base_url}, default_model={})", + model.unwrap_or("-") + )); + return Ok(0); + } + let existed = providers::get(&app.config, name).is_some(); + let def = ProviderDef { + base_url: base_url.trim().to_string(), + default_model: model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()), + models: models_list, + }; + write_provider(app, name, &def)?; + app.emit( + &Event::now(EventKind::Provider) + .with_args(vec![format!("action={}", if existed { "update" } else { "add" }), format!("provider={name}")]), + ); + if existed { + app.log.success(&crate::tr_fmt!("provider '{}' mis à jour", name)); + } else { + app.log.success(&crate::tr_fmt!("provider '{}' ajouté", name)); + app.log.info(&format!( + "{}", + crate::tr_fmt!( + "positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", + name + ) + )); + } + Ok(0) +} + +fn remove(app: &App, name: &str) -> Result { + if providers::get(&app.config, name).is_none() { + bail!(crate::tr_fmt!("provider '{}' inconnu", name)); + } + if providers::default_name(&app.config) == Some(name) { + bail!(crate::tr_fmt!( + "le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", + name + )); + } + if app.dry_run() { + app.log.dry(&format!("would remove provider {name}")); + return Ok(0); + } + let path = crate::commands::config_cmd::active_config_path(app)?; + let mut root = read_yaml(&path)?; + remove_path(&mut root, &["settings", "providers", name])?; + write_yaml(&path, &root)?; + crate::commands::config_cmd::reload_and_validate(&path)?; + app.emit( + &Event::now(EventKind::Provider) + .with_args(vec!["action=remove".to_string(), format!("provider={name}")]), + ); + app.log.success(&crate::tr_fmt!("provider '{}' supprimé", name)); + Ok(0) +} + +fn set_default(app: &App, name: &str) -> Result { + if providers::get(&app.config, name).is_none() { + bail!(crate::tr_fmt!("provider '{}' inconnu", name)); + } + if app.dry_run() { + app.log.dry(&format!("would set the default provider to {name}")); + return Ok(0); + } + crate::commands::config_cmd::persist_setting(app, "settings.default_provider", name)?; + app.emit( + &Event::now(EventKind::Provider) + .with_args(vec!["action=default".to_string(), format!("provider={name}")]), + ); + app.log.success(&crate::tr_fmt!("provider par défaut : {}", name)); + Ok(0) +} + +// --------------------------------------------------------------------------- +// Config file edition (settings.providers.) +// --------------------------------------------------------------------------- + +/// Write (or update) one provider entry in the active configuration file. +fn write_provider(app: &App, name: &str, def: &ProviderDef) -> Result<()> { + let path = crate::commands::config_cmd::active_config_path(app)?; + let mut root = read_yaml(&path)?; + let base = [ + "settings", + "providers", + name, + "base_url", + ]; + set_path(&mut root, &base, serde_yaml::Value::String(def.base_url.clone()))?; + if let Some(m) = &def.default_model { + set_path( + &mut root, + &["settings", "providers", name, "default_model"], + serde_yaml::Value::String(m.clone()), + )?; + } + if !def.models.is_empty() { + let seq = serde_yaml::Value::Sequence( + def.models.iter().map(|m| serde_yaml::Value::String(m.clone())).collect(), + ); + set_path(&mut root, &["settings", "providers", name, "models"], seq)?; + } + write_yaml(&path, &root)?; + crate::commands::config_cmd::reload_and_validate(&path) +} + +fn read_yaml(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?; + serde_yaml::from_str(&text).map_err(|e| anyhow!("{} is not valid YAML: {e}", path.display())) +} + +fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> { + std::fs::write(path, serde_yaml::to_string(root)?) + .map_err(|e| anyhow!("cannot write {}: {e}", path.display())) +} + +/// Set a value at a dotted path, creating intermediate mappings. +fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> { + let mut node = root + .as_mapping_mut() + .ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?; + for part in &parts[..parts.len() - 1] { + let entry = node + .entry(serde_yaml::Value::String(part.to_string())) + .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())); + node = entry + .as_mapping_mut() + .ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?; + } + node.insert( + serde_yaml::Value::String(parts.last().unwrap().to_string()), + value, + ); + Ok(()) +} + +/// Remove the key at a dotted path (missing keys are a no-op). +fn remove_path(root: &mut serde_yaml::Value, parts: &[&str]) -> Result<()> { + let mut node = root + .as_mapping_mut() + .ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?; + for part in &parts[..parts.len() - 1] { + let Some(entry) = node.get_mut(serde_yaml::Value::String(part.to_string())) else { + return Ok(()); + }; + node = entry + .as_mapping_mut() + .ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?; + } + node.remove(serde_yaml::Value::String(parts.last().unwrap().to_string())); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use clap::Parser; + use std::path::PathBuf; + + /// App whose active config is a file inside a leaked TempDir (same + /// pattern as config_cmd's tests). + fn app_with_config(text: &str) -> (App, PathBuf) { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write(&cfg, text).unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + (app, cfg) + } + + const BASE: &str = "version: \"1.0\"\nagents: []\n"; + + #[test] + fn set_path_descends_and_writes_sequences() { + let mut root: serde_yaml::Value = serde_yaml::from_str(BASE).unwrap(); + set_path( + &mut root, + &["settings", "providers", "deepseek", "base_url"], + serde_yaml::Value::String("https://api.deepseek.com".into()), + ) + .unwrap(); + set_path( + &mut root, + &["settings", "providers", "deepseek", "models"], + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("deepseek-chat".into())]), + ) + .unwrap(); + assert_eq!(root["settings"]["providers"]["deepseek"]["base_url"], "https://api.deepseek.com"); + assert_eq!(root["settings"]["providers"]["deepseek"]["models"][0], "deepseek-chat"); + } + + #[test] + fn remove_path_deletes_the_leaf() { + let mut root: serde_yaml::Value = + serde_yaml::from_str("version: \"1.0\"\nsettings:\n providers:\n openai:\n base_url: x\nagents: []\n") + .unwrap(); + remove_path(&mut root, &["settings", "providers", "openai"]).unwrap(); + assert!(root["settings"]["providers"].get("openai").is_none()); + } + + #[test] + fn add_then_reload_exposes_the_provider() { + let (app, cfg) = app_with_config(BASE); + add( + &app, + "deepseek", + "https://api.deepseek.com", + Some("deepseek-chat"), + Some("deepseek-chat, deepseek-reasoner"), + ) + .unwrap(); + // A fresh load of the same config sees the provider. + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli).unwrap(); + let def = providers::get(&reloaded.config, "deepseek").expect("provider persisted"); + assert_eq!(def.base_url, "https://api.deepseek.com"); + assert_eq!(def.default_model.as_deref(), Some("deepseek-chat")); + assert_eq!(def.models, vec!["deepseek-chat", "deepseek-reasoner"]); + } + + #[test] + fn add_rejects_an_invalid_name_or_missing_url() { + let (app, _cfg) = app_with_config(BASE); + assert!(add(&app, "OpenAI", "https://x", None, None).is_err()); + assert!(add(&app, "openai", " ", None, None).is_err()); + } + + #[test] + fn remove_and_set_default_flow() { + // Config mutations apply on the next process (same convention as + // alias/config set): reload between operations like a real CLI user. + let (app, cfg) = app_with_config(BASE); + add(&app, "openai", "https://api.openai.com/v1", Some("gpt-5.2"), None).unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli).unwrap(); + set_default(&reloaded, "openai").unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli).unwrap(); + assert_eq!(providers::default_name(&reloaded.config), Some("openai")); + // Cannot remove the default provider. + assert!(remove(&reloaded, "openai").is_err()); + // Removing an unknown provider is an error too. + assert!(remove(&reloaded, "nope").is_err()); + // After changing the default, removal works. + add(&reloaded, "deepseek", "https://api.deepseek.com", None, None).unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli).unwrap(); + set_default(&reloaded, "deepseek").unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli).unwrap(); + assert!(remove(&reloaded, "openai").is_ok()); + let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded2 = App::from_cli(cli2).unwrap(); + assert!(providers::get(&reloaded2.config, "openai").is_none()); + assert_eq!(providers::default_name(&reloaded2.config), Some("deepseek")); + } + + #[test] + fn dry_run_writes_nothing() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write(&cfg, BASE).unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--dry-run", "--config", cfg.to_str().unwrap()]); + let mut app = App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + add(&app, "openai", "https://api.openai.com/v1", None, None).unwrap(); + let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let reloaded = App::from_cli(cli2).unwrap(); + assert!(providers::get(&reloaded.config, "openai").is_none()); + } +} diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index c309d4c..cc09785 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -323,6 +323,12 @@ pub static SECTIONS: &[TipSection] = &[ options: &[], example: "secret set API_KEY --agent pi --value sk-…", }, + TipEntry { + usage: "providers add --base-url ", + about: "registre centralisé des providers LLM (modèles, modèle par défaut)", + options: &[], + example: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat", + }, TipEntry { usage: "favorite · note · tag", about: "annotez vos agents : étoile, note libre, tags personnels", diff --git a/src/config.rs b/src/config.rs index d7a5a44..2a21bf7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -139,6 +139,30 @@ pub struct Settings { /// Plugin scripts (issue #75): default timeout and enable list. #[serde(default)] pub plugins: Option, + /// Name of the provider used by default (issue #88) — the provider + /// whose default model is applied at install/run when none is given. + #[serde(default)] + pub default_provider: Option, + /// LLM provider registry (issue #88): provider name -> base URL, + /// available models and default model. API tokens are NOT stored here — + /// they live in the OS keyring under `providers//api_key` + /// (issue #89). + #[serde(default)] + pub providers: Option>, +} + +/// One entry of the LLM provider registry (issue #88). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ProviderDef { + /// Base URL of the provider API (e.g. https://api.openai.com/v1). + pub base_url: String, + /// Model used by default for this provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + /// Models offered by this provider. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, } /// Plugin scripts settings (issue #75): how long one plugin may run and @@ -248,6 +272,24 @@ impl Settings { self.stop_timeout_secs } } + + /// Look up one provider of the registry (issue #88). + pub fn provider(&self, name: &str) -> Option<&ProviderDef> { + self.providers.as_ref().and_then(|m| m.get(name)) + } + + /// Name of the default provider (issue #88). + pub fn default_provider_name(&self) -> Option<&str> { + self.default_provider.as_deref() + } + + /// The default provider entry, when both the name and the definition + /// exist (issue #88). + pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> { + let name = self.default_provider.as_ref()?; + let def = self.provider(name)?; + Some((name, def)) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -769,6 +811,21 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.plugins.is_some() { s.plugins = o.plugins; } + if o.default_provider.is_some() { + s.default_provider = o.default_provider; + } + // Providers merge key by key (issue #88): an overlay adds or replaces + // one provider without wiping the others. + if let Some(overlay_providers) = o.providers { + match s.providers.as_mut() { + Some(map) => { + for (k, v) in overlay_providers { + map.insert(k, v); + } + } + None => s.providers = Some(overlay_providers), + } + } for (k, v) in o.hooks { s.hooks.insert(k, v); } @@ -834,6 +891,28 @@ pub fn validate(config: &Config) -> Vec { problems.push(format!("agent '{}': dependency without a name", agent.name)); } } + if let Some(d) = &config.settings.default_provider { + if config.settings.provider(d).is_none() { + problems.push(format!( + "settings.default_provider '{d}' is not defined in settings.providers" + )); + } + } + if let Some(providers) = &config.settings.providers { + for (name, p) in providers { + if p.base_url.trim().is_empty() { + problems.push(format!("provider '{name}': 'base_url' is required")); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + problems.push(format!( + "provider name '{name}' must be a lowercase slug ([a-z0-9_-])" + )); + } + } + } } problems } diff --git a/src/events.rs b/src/events.rs index f0fa950..a28e536 100644 --- a/src/events.rs +++ b/src/events.rs @@ -48,6 +48,8 @@ pub enum EventKind { Alert, /// One benchmark run of 'am lab' (issue #62). Lab, + /// LLM provider registry change: add, remove, default (issue #88). + Provider, } impl EventKind { @@ -73,6 +75,7 @@ impl EventKind { EventKind::Cost => "cost", EventKind::Alert => "alert", EventKind::Lab => "lab", + EventKind::Provider => "provider", } } } diff --git a/src/help.rs b/src/help.rs index 3030e6f..a61a897 100644 --- a/src/help.rs +++ b/src/help.rs @@ -678,6 +678,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" }, ], }, + HelpSpec { + name: "providers", + category: "Commands", + usage: "providers {flags} ", + about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring (issue #89).", + search_terms: &["provider", "token", "registry", "llm", "model", "api"], + flags: &[ + HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" }, + HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model used by default for this provider (add)" }, + HelpFlag { short: "", long: "--models", value: "MODELS", desc: "Comma-separated model list (add)" }, + ], + subcommands: &[ + ("list", "List the registered providers (default starred)"), + ("show", "Show one provider in detail"), + ("add", "Register or update a provider"), + ("remove", "Remove a provider"), + ("default", "Set the default provider"), + ], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "List the providers.", code: "providers" }, + HelpExample { desc: "Register DeepSeek.", code: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat --models deepseek-chat,deepseek-reasoner" }, + HelpExample { desc: "Make it the default.", code: "providers default deepseek" }, + HelpExample { desc: "Machine-readable list.", code: "providers --json" }, + ], + }, HelpSpec { name: "history", category: "Commands", diff --git a/src/i18n.rs b/src/i18n.rs index cadaf7c..4f3eb81 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -160,6 +160,18 @@ pub const CATALOG: &[(&str, &str)] = &[ ("début", "start"), ("fin", "end"), ("usage: suggest — ex: suggest un agent pour du Python", "usage: suggest — e.g. suggest an agent for Python"), + // ---- providers (#88) --------------------------------------------------- + ("aucun provider enregistré — voir: am providers add --base-url ", "no providers registered — see: am providers add --base-url "), + ("provider '{}' inconnu", "unknown provider '{}'"), + ("nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", "invalid provider name '{}' — expected a slug [a-z0-9_-]"), + ("usage: providers add --base-url [--model ] [--models m1,m2]", "usage: providers add --base-url [--model ] [--models m1,m2]"), + ("provider '{}' mis à jour", "provider '{}' updated"), + ("provider '{}' ajouté", "provider '{}' added"), + ("positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", "store the token in the keyring: am providers set-token {} (issue #89, coming)"), + ("provider '{}' supprimé", "provider '{}' removed"), + ("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"), + ("provider par défaut : {}", "default provider: {}"), + ("usage: providers list | show | add --base-url | remove | default ", "usage: providers list | show | add --base-url | remove | default "), ("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"), ("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"), ("vouliez-vous dire", "did you mean"), diff --git a/src/lib.rs b/src/lib.rs index 0c70bc3..9f1ab5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub mod models; pub mod nav; pub mod output; pub mod probe; +pub mod providers; pub mod projects; pub mod ps; pub mod process; diff --git a/src/providers.rs b/src/providers.rs new file mode 100644 index 0000000..d6e6431 --- /dev/null +++ b/src/providers.rs @@ -0,0 +1,117 @@ +//! LLM provider registry (issue #88): resolution helpers over the +//! `settings.providers` map — the default provider, its default model and +//! name validation. Tokens are deliberately out of scope here: they live in +//! the OS keyring under `providers//api_key` (issue #89). +//! +//! The install-time configuration (issues #90, #92) will resolve the +//! provider/model of an agent through these helpers. + +use crate::config::{Config, ProviderDef}; +use std::collections::BTreeMap; + +/// Empty provider map shared by the helpers below (never mutated). +static EMPTY: BTreeMap = BTreeMap::new(); + +/// Every provider of the effective configuration (empty when unset). +pub fn all(config: &Config) -> &BTreeMap { + config.settings.providers.as_ref().unwrap_or(&EMPTY) +} + +/// Look up one provider by name. +pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> { + config.settings.provider(name) +} + +/// Name of the default provider, when set and defined. +pub fn default_name(config: &Config) -> Option<&str> { + let (name, _) = config.settings.default_provider()?; + Some(name.as_str()) +} + +/// The default provider entry (name + definition). +pub fn default(config: &Config) -> Option<(&String, &ProviderDef)> { + config.settings.default_provider() +} + +/// Model to use for a provider: the explicit model when given, otherwise +/// the provider's default model. +pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Option<&'a str> { + explicit + .filter(|m| !m.is_empty()) + .or(provider.default_model.as_deref()) +} + +/// Provider names accept the same slug rule as agent names: [a-z0-9_-]. +pub fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, ProviderDef}; + + fn config_with() -> Config { + let mut c = Config::default(); + c.settings.default_provider = Some("openai".to_string()); + let mut providers = BTreeMap::new(); + providers.insert( + "openai".to_string(), + ProviderDef { + base_url: "https://api.openai.com/v1".to_string(), + default_model: Some("gpt-5.2".to_string()), + models: vec!["gpt-5.2".to_string(), "gpt-5.1-mini".to_string()], + }, + ); + providers.insert( + "deepseek".to_string(), + ProviderDef { + base_url: "https://api.deepseek.com".to_string(), + default_model: None, + models: vec!["deepseek-chat".to_string()], + }, + ); + c.settings.providers = Some(providers); + c + } + + #[test] + fn resolves_the_default_provider() { + let c = config_with(); + let (name, def) = default(&c).expect("default provider"); + assert_eq!(name, "openai"); + assert_eq!(def.base_url, "https://api.openai.com/v1"); + assert_eq!(default_name(&c), Some("openai")); + } + + #[test] + fn explicit_model_wins_over_the_default() { + let c = config_with(); + let def = get(&c, "deepseek").unwrap(); + assert_eq!(model_for(def, None), None); + assert_eq!(model_for(def, Some("deepseek-reasoner")), Some("deepseek-reasoner")); + let openai = get(&c, "openai").unwrap(); + assert_eq!(model_for(openai, None), Some("gpt-5.2")); + assert_eq!(model_for(openai, Some("gpt-5.1-mini")), Some("gpt-5.1-mini")); + } + + #[test] + fn default_missing_provider_is_none() { + let mut c = config_with(); + c.settings.default_provider = Some("nope".to_string()); + assert!(default(&c).is_none()); + assert!(default_name(&c).is_none()); + } + + #[test] + fn name_validation_follows_the_slug_rule() { + assert!(is_valid_name("openai")); + assert!(is_valid_name("deep-seek_2")); + assert!(!is_valid_name("OpenAI")); + assert!(!is_valid_name("")); + assert!(!is_valid_name("has space")); + } +} diff --git a/src/repl.rs b/src/repl.rs index 3b5be6f..51bf903 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -58,6 +58,8 @@ pub struct AmCompleter { profile_sub: Vec<&'static str>, /// 'catalog' subcommands (issues #60 #67). catalog_sub: Vec<&'static str>, + /// 'providers' subcommands (issue #88). + providers_sub: Vec<&'static str>, /// Short descriptions shown next to candidates in the Tab menu /// (Nushell-style). descriptions: BTreeMap, @@ -108,6 +110,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("man", "readable man page in the terminal"), ("models", "list local models (ollama, llama.cpp, LM Studio)"), ("catalog", "manage remote catalogs"), + ("providers", "manage the LLM provider registry (base URLs, models, default)"), ("suggest", "recommend an agent for a request"), ("audit", "who changed what, when (config checksums)"), ("service", "register an agent as a system service (autostart)"), @@ -233,13 +236,14 @@ impl AmCompleter { "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "lang", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", - "profile", "man", "models", "catalog", "suggest", "audit", + "profile", "man", "models", "catalog", "providers", "suggest", "audit", "service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], profile_sub: vec!["list", "show"], catalog_sub: vec!["update", "add", "list"], + providers_sub: vec!["list", "show", "add", "remove", "default"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], gateway_shells: vec![ "list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu", @@ -286,6 +290,9 @@ impl AmCompleter { "catalog" => { pool = self.catalog_sub.iter().map(|s| s.to_string()).collect(); } + "providers" => { + pool = self.providers_sub.iter().map(|s| s.to_string()).collect(); + } "completion" => { pool = self.shells.iter().map(|s| s.to_string()).collect(); } @@ -782,7 +789,8 @@ pub fn banner_box( .to_string(), )); rows.push(inner( - " config alias · secret · profile · config · doctor · completion · man · tip".to_string(), + " config alias · secret · profile · providers · config · doctor · completion · man · tip" + .to_string(), )); rows.push(inner( " models models · models --prune · catalog · suggest · audit".to_string(), @@ -1489,6 +1497,38 @@ fn handle_line( return Ok(false); } }, + "providers" => match rest.first().map(|s| s.as_str()) { + Some("show") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::Show { + name: rest[1].clone(), + }), + }), + Some("add") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::Add { + name: rest[1].clone(), + base_url: opt_value("--base-url").unwrap_or_default(), + model: opt_value("--model"), + models: opt_value("--models"), + }), + }), + Some("remove") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::Remove { + name: rest[1].clone(), + }), + }), + Some("default") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::Default { + name: rest[1].clone(), + }), + }), + Some("list") | None => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::List), + }), + _ => { + app.log.error(crate::i18n::tr("usage: providers list | show | add --base-url | remove | default ")); + return Ok(false); + } + }, "suggest" => { if rest.is_empty() { app.log.error(crate::i18n::tr("usage: suggest — ex: suggest un agent pour du Python")); @@ -1894,6 +1934,7 @@ pub fn is_am_command(word: &str) -> bool { | "tip" | "models" | "catalog" + | "providers" | "suggest" | "audit" | "service"