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
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 |
+14
View File
@@ -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/<nom>/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:
+32
View File
@@ -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)
+5 -2
View File
@@ -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
+45
View File
@@ -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<ProvidersCmd>,
}
#[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<String>,
/// Comma-separated model list
#[arg(long, value_name = "MODELS")]
models: Option<String>,
},
/// 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 {
+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",
+79
View File
@@ -139,6 +139,30 @@ pub struct Settings {
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
/// 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<String>,
/// 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/<name>/api_key`
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, ProviderDef>>,
}
/// 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<String>,
/// Models offered by this provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// 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<String> {
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
}
+3
View File
@@ -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",
}
}
}
+27
View File
@@ -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} <list|show|add|remove|default>",
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",
+12
View File
@@ -160,6 +160,18 @@ pub const CATALOG: &[(&str, &str)] = &[
("début", "start"),
("fin", "end"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
// ---- providers (#88) ---------------------------------------------------
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <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 <nom> --base-url <url> [--model <m>] [--models m1,m2]", "usage: providers add <name> --base-url <url> [--model <m>] [--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 <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
("{} 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"),
+1
View File
@@ -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;
+117
View File
@@ -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/<name>/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<String, ProviderDef> = BTreeMap::new();
/// Every provider of the effective configuration (empty when unset).
pub fn all(config: &Config) -> &BTreeMap<String, ProviderDef> {
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"));
}
}
+43 -2
View File
@@ -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<String, String>,
@@ -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 <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>"));
return Ok(false);
}
},
"suggest" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: suggest <requête> — 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"