feat: v0.7.0 — registre de providers LLM : am providers list/show/add/remove/default, settings.providers + default_provider (closes #88)

This commit is contained in:
2026-08-19 15:32:45 -04:00
parent 1948e3bc79
commit d38cd7352f
18 changed files with 823 additions and 13 deletions
+13 -6
View File
@@ -114,14 +114,21 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
/// exists. Used by the REPL 'theme <name>' 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<PathBuf> {
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<PathBuf> {
}
/// Ensure the user config file exists (creating a template when needed).
fn user_config_path(app: &App) -> Result<PathBuf> {
pub(crate) fn user_config_path(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
+2
View File
@@ -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<i32> {
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 {
+421
View File
@@ -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<i32> {
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<i32> {
let providers = providers::all(&app.config);
let default_name = providers::default_name(&app.config);
if app.json() {
let rows: Vec<serde_json::Value> = 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 <nom> --base-url <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<i32> {
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<i32> {
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 <nom> --base-url <url> [--model <m>] [--models m1,m2]"));
}
let models_list: Vec<String> = 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<i32> {
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<i32> {
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.<name>)
// ---------------------------------------------------------------------------
/// 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<serde_yaml::Value> {
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());
}
}
+6
View File
@@ -323,6 +323,12 @@ pub static SECTIONS: &[TipSection] = &[
options: &[],
example: "secret set API_KEY --agent pi --value sk-…",
},
TipEntry {
usage: "providers add <nom> --base-url <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",