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:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+1
-1
@@ -497,7 +497,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés
|
|||||||
|
|
||||||
| # | Issue | Effort | Dépend de |
|
| # | 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 |
|
| [#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 |
|
| [#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 |
|
| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 |
|
||||||
|
|||||||
+14
@@ -43,6 +43,20 @@ settings:
|
|||||||
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
|
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
|
||||||
# sync_repo: https://git.dracodev.net/bruno/am-state.git
|
# sync_repo: https://git.dracodev.net/bruno/am-state.git
|
||||||
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
|
# 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 ----------------------------------------------------------
|
# --- Command aliases ----------------------------------------------------------
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.ie \n(.g .ds Aq \(aq
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 0.6.0"
|
.TH am 1 "am 0.7.0"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -121,6 +121,9 @@ List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
|||||||
am\-catalog(1)
|
am\-catalog(1)
|
||||||
Manage remote catalogs: update the official one, add external ones
|
Manage remote catalogs: update the official one, add external ones
|
||||||
.TP
|
.TP
|
||||||
|
am\-providers(1)
|
||||||
|
Manage the LLM provider registry: base URLs, models, default provider
|
||||||
|
.TP
|
||||||
am\-suggest(1)
|
am\-suggest(1)
|
||||||
Suggest agents matching a query, boosted by real usage (issue #61)
|
Suggest agents matching a query, boosted by real usage (issue #61)
|
||||||
.TP
|
.TP
|
||||||
@@ -232,4 +235,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v0.6.0
|
v0.7.0
|
||||||
|
|||||||
+45
@@ -192,6 +192,8 @@ pub enum Command {
|
|||||||
/// Manage remote catalogs: update the official one, add external ones
|
/// Manage remote catalogs: update the official one, add external ones
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Catalog(CatalogCmd),
|
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 agents matching a query, boosted by real usage (issue #61)
|
||||||
Suggest {
|
Suggest {
|
||||||
/// Free-form request ("un agent pour du Python")
|
/// Free-form request ("un agent pour du Python")
|
||||||
@@ -634,6 +636,49 @@ pub enum CatalogCmd {
|
|||||||
List,
|
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).
|
/// Environment profile subcommands (issue #40).
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
pub enum ProfileCmd {
|
pub enum ProfileCmd {
|
||||||
|
|||||||
@@ -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
|
/// exists. Used by the REPL 'theme <name>' command so a switched theme
|
||||||
/// survives a restart.
|
/// survives a restart.
|
||||||
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
|
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
|
||||||
let path = match &app.config_origin {
|
let path = active_config_path(app)?;
|
||||||
Some(p) => p.clone(),
|
|
||||||
None => user_config_path(app)?,
|
|
||||||
};
|
|
||||||
write_dotted_key(&path, key, value)?;
|
write_dotted_key(&path, key, value)?;
|
||||||
reload_and_validate(&path)
|
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.
|
/// 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<()> {
|
fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(path)
|
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.
|
/// 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()]);
|
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
|
||||||
config::load(&cli).map_err(|e| {
|
config::load(&cli).map_err(|e| {
|
||||||
anyhow!("the new value makes the configuration invalid: {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).
|
/// 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
|
let dir = app
|
||||||
.paths
|
.paths
|
||||||
.config_dir
|
.config_dir
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub mod suggest_cmd;
|
|||||||
pub mod sync_cmd;
|
pub mod sync_cmd;
|
||||||
pub mod playbook_cmd;
|
pub mod playbook_cmd;
|
||||||
pub mod plugins_cmd;
|
pub mod plugins_cmd;
|
||||||
|
pub mod providers_cmd;
|
||||||
pub mod theme_cmd;
|
pub mod theme_cmd;
|
||||||
pub mod timeline_cmd;
|
pub mod timeline_cmd;
|
||||||
pub mod tip_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::Secret(sub) => secret_cmd::run(app, sub),
|
||||||
Command::Models(args) => models_cmd::run(app, args),
|
Command::Models(args) => models_cmd::run(app, args),
|
||||||
Command::Catalog(sub) => catalog_cmd::run(app, sub),
|
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::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||||
Command::Start(a) => run_cmd::start(app, a),
|
Command::Start(a) => run_cmd::start(app, a),
|
||||||
Command::Stop {
|
Command::Stop {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -323,6 +323,12 @@ pub static SECTIONS: &[TipSection] = &[
|
|||||||
options: &[],
|
options: &[],
|
||||||
example: "secret set API_KEY --agent pi --value sk-…",
|
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 {
|
TipEntry {
|
||||||
usage: "favorite · note · tag",
|
usage: "favorite · note · tag",
|
||||||
about: "annotez vos agents : étoile, note libre, tags personnels",
|
about: "annotez vos agents : étoile, note libre, tags personnels",
|
||||||
|
|||||||
@@ -139,6 +139,30 @@ pub struct Settings {
|
|||||||
/// Plugin scripts (issue #75): default timeout and enable list.
|
/// Plugin scripts (issue #75): default timeout and enable list.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub plugins: Option<PluginSettings>,
|
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
|
/// Plugin scripts settings (issue #75): how long one plugin may run and
|
||||||
@@ -248,6 +272,24 @@ impl Settings {
|
|||||||
self.stop_timeout_secs
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -769,6 +811,21 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
|||||||
if o.plugins.is_some() {
|
if o.plugins.is_some() {
|
||||||
s.plugins = o.plugins;
|
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 {
|
for (k, v) in o.hooks {
|
||||||
s.hooks.insert(k, v);
|
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));
|
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
|
problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub enum EventKind {
|
|||||||
Alert,
|
Alert,
|
||||||
/// One benchmark run of 'am lab' (issue #62).
|
/// One benchmark run of 'am lab' (issue #62).
|
||||||
Lab,
|
Lab,
|
||||||
|
/// LLM provider registry change: add, remove, default (issue #88).
|
||||||
|
Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventKind {
|
impl EventKind {
|
||||||
@@ -73,6 +75,7 @@ impl EventKind {
|
|||||||
EventKind::Cost => "cost",
|
EventKind::Cost => "cost",
|
||||||
EventKind::Alert => "alert",
|
EventKind::Alert => "alert",
|
||||||
EventKind::Lab => "lab",
|
EventKind::Lab => "lab",
|
||||||
|
EventKind::Provider => "provider",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -678,6 +678,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
|
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 {
|
HelpSpec {
|
||||||
name: "history",
|
name: "history",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
|
|||||||
+12
@@ -160,6 +160,18 @@ pub const CATALOG: &[(&str, &str)] = &[
|
|||||||
("début", "start"),
|
("début", "start"),
|
||||||
("fin", "end"),
|
("fin", "end"),
|
||||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
|
("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) — 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"),
|
("{} 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"),
|
("vouliez-vous dire", "did you mean"),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub mod models;
|
|||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod providers;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod ps;
|
pub mod ps;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@@ -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
@@ -58,6 +58,8 @@ pub struct AmCompleter {
|
|||||||
profile_sub: Vec<&'static str>,
|
profile_sub: Vec<&'static str>,
|
||||||
/// 'catalog' subcommands (issues #60 #67).
|
/// 'catalog' subcommands (issues #60 #67).
|
||||||
catalog_sub: Vec<&'static str>,
|
catalog_sub: Vec<&'static str>,
|
||||||
|
/// 'providers' subcommands (issue #88).
|
||||||
|
providers_sub: Vec<&'static str>,
|
||||||
/// Short descriptions shown next to candidates in the Tab menu
|
/// Short descriptions shown next to candidates in the Tab menu
|
||||||
/// (Nushell-style).
|
/// (Nushell-style).
|
||||||
descriptions: BTreeMap<String, String>,
|
descriptions: BTreeMap<String, String>,
|
||||||
@@ -108,6 +110,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
|||||||
("man", "readable man page in the terminal"),
|
("man", "readable man page in the terminal"),
|
||||||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||||||
("catalog", "manage remote catalogs"),
|
("catalog", "manage remote catalogs"),
|
||||||
|
("providers", "manage the LLM provider registry (base URLs, models, default)"),
|
||||||
("suggest", "recommend an agent for a request"),
|
("suggest", "recommend an agent for a request"),
|
||||||
("audit", "who changed what, when (config checksums)"),
|
("audit", "who changed what, when (config checksums)"),
|
||||||
("service", "register an agent as a system service (autostart)"),
|
("service", "register an agent as a system service (autostart)"),
|
||||||
@@ -233,13 +236,14 @@ impl AmCompleter {
|
|||||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
"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",
|
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
|
||||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||||
],
|
],
|
||||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||||
profile_sub: vec!["list", "show"],
|
profile_sub: vec!["list", "show"],
|
||||||
catalog_sub: vec!["update", "add", "list"],
|
catalog_sub: vec!["update", "add", "list"],
|
||||||
|
providers_sub: vec!["list", "show", "add", "remove", "default"],
|
||||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||||
gateway_shells: vec![
|
gateway_shells: vec![
|
||||||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
||||||
@@ -286,6 +290,9 @@ impl AmCompleter {
|
|||||||
"catalog" => {
|
"catalog" => {
|
||||||
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
|
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
|
||||||
}
|
}
|
||||||
|
"providers" => {
|
||||||
|
pool = self.providers_sub.iter().map(|s| s.to_string()).collect();
|
||||||
|
}
|
||||||
"completion" => {
|
"completion" => {
|
||||||
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
||||||
}
|
}
|
||||||
@@ -782,7 +789,8 @@ pub fn banner_box(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
rows.push(inner(
|
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(
|
rows.push(inner(
|
||||||
" models models · models --prune · catalog · suggest · audit".to_string(),
|
" models models · models --prune · catalog · suggest · audit".to_string(),
|
||||||
@@ -1489,6 +1497,38 @@ fn handle_line(
|
|||||||
return Ok(false);
|
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" => {
|
"suggest" => {
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
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"
|
| "tip"
|
||||||
| "models"
|
| "models"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
|
| "providers"
|
||||||
| "suggest"
|
| "suggest"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "service"
|
| "service"
|
||||||
|
|||||||
Reference in New Issue
Block a user