feat: v0.7.0 — am run/start --provider/--model : résolution cloud via le registre (base_url + keyring + modèle), fallback cloud quand le modèle local est inconnu, canal model_env/model_arg/env_map, jamais de token en clair (closes #92)

- apply_provider : providers::resolve_for (flag > agent > défaut) + apply_env_map_resolved (api_key = @secret, base_url/model du provider résolu)
- start/run/restart : --provider et --model dans le CLI, le REPL et la complétion ; specs help + man pages
- Tests unitaires : résolution, cascade, canal déclaré, erreur provider inconnu (liste), absence de fuite (env sans valeur sk-), fallback embarqué
This commit is contained in:
2026-08-19 21:05:39 -04:00
parent 68eb170f57
commit 6d51ce9504
12 changed files with 320 additions and 16 deletions
+1 -1
View File
@@ -501,7 +501,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés
| [#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 |
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | ✅ `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
Critère de sortie du jalon : un agent installé est opérationnel sans
configuration manuelle (tokens au keyring, provider/modèle par défaut).
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Restart an agent: stop, then start with the same options
.SH OPTIONS
@@ -30,6 +30,9 @@ Apply an environment profile (env + args, defined in the config)
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
.SH DESCRIPTION
Run the agent command directly with the given arguments (no process management)
.SH OPTIONS
@@ -12,6 +12,9 @@ Run the agent command directly with the given arguments (no process management)
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
.TP
\fB\-\-container\fR
Run the agent inside a container (issue #58)
.TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background)
.SH OPTIONS
@@ -30,6 +30,9 @@ Apply an environment profile (env + args, defined in the config)
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
.TP
\fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57)
.TP
+26 -3
View File
@@ -63,23 +63,46 @@ pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
/// model/base_url/provider -> values of the resolved provider. Explicit
/// values already present in agent.env are never overwritten.
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
apply_env_map_resolved(env, agent, config, None, None);
}
/// Same as `apply_env_map` but with the provider and model fixed by the
/// caller (issue #92: `am run/start --provider/--model`): the flags win
/// over the agent declaration and the settings default.
pub fn apply_env_map_resolved(
env: &mut BTreeMap<String, String>,
agent: &AgentDef,
config: &Config,
provider_override: Option<&str>,
model_override: Option<&str>,
) {
let Some(cfg) = &agent.config else {
return;
};
if cfg.env_map.is_empty() {
return;
}
let Some(pname) = provider_pref(agent).or_else(|| crate::providers::default_name(config)) else {
let Some(pname) = provider_override
.filter(|p| !p.is_empty())
.or_else(|| provider_pref(agent))
.or_else(|| crate::providers::default_name(config))
else {
return;
};
let Some(def) = crate::providers::get(config, pname) else {
return;
};
let model = agent.model.as_deref().or(def.default_model.as_deref());
let model = model_override
.filter(|m| !m.is_empty())
.or(agent.model.as_deref())
.or(def.default_model.as_deref());
for (slot, var) in &cfg.env_map {
let value = match slot.as_str() {
"api_key" => "@secret".to_string(),
"model" => model.unwrap_or_default().to_string(),
"model" => {
let Some(m) = model else { continue };
m.to_string()
}
"base_url" => def.base_url.clone(),
"provider" => pname.to_string(),
_ => continue, // unknown slots are ignored
+6
View File
@@ -495,6 +495,9 @@ pub enum Command {
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
#[arg(long, value_name = "PROVIDER")]
provider: Option<String>,
/// Run the agent inside a container (issue #58)
#[arg(long)]
container: bool,
@@ -566,6 +569,9 @@ pub struct StartArgs {
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
#[arg(long, value_name = "PROVIDER")]
pub provider: Option<String>,
/// Start every member of a group simultaneously (issue #57)
#[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool,
+9 -2
View File
@@ -226,8 +226,15 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, container, args } => {
run_cmd::run(app, agent, model.as_deref(), *container, args)
Command::Run { agent, model, provider, container, args } => {
run_cmd::run(
app,
agent,
model.as_deref(),
provider.as_deref(),
*container,
args,
)
}
Command::Service(svc) => match svc {
crate::cli::ServiceCmd::Install { agent, autostart } => {
+242 -4
View File
@@ -71,13 +71,30 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
// --provider (issue #92): resolve from the LLM provider registry.
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(m) = opts.model.as_deref() {
if let Some(p) = opts.provider.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())?
.expect("a provider flag was given: apply_provider always resolves or errors");
extra_args.extend(a);
extra_env.extend(e);
} else if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
match apply_model(app, agent, m) {
Ok((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
Some((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
None => return Err(local_err),
},
}
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
@@ -320,6 +337,7 @@ pub fn run(
app: &App,
target: &str,
model: Option<&str>,
provider: Option<&str>,
container: bool,
extra: &[OsString],
) -> Result<i32> {
@@ -333,10 +351,28 @@ pub fn run(
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
// Issue #92: --provider resolves from the LLM provider registry (cloud);
// --model alone keeps the local-runtimes-first behavior (issue #71) with
// a cloud fallback when the model is unknown locally.
if let Some(p) = provider {
let (a, e) = apply_provider(app, agent, Some(p), model)?
.expect("a provider flag was given: apply_provider always resolves or errors");
extra_args.extend(a);
extra_env.extend(e);
} else if let Some(m) = model {
match apply_model(app, agent, m) {
Ok((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
Some((a, e)) => {
extra_args.extend(a);
extra_env.extend(e);
}
None => return Err(local_err),
},
}
}
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
@@ -474,6 +510,70 @@ pub fn apply_model(
Ok((args, env))
}
/// Issue #92: resolve `--provider`/`--model` against the LLM provider
/// registry (cloud) and transmit through the agent's declared channels:
/// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91).
/// The token travels as the `@secret` reference resolved by
/// resolve_env_secrets — never on the command line. Returns None when no
/// provider is configured at all (the caller falls back to the
/// local-runtime path).
fn apply_provider(
app: &App,
agent: &AgentDef,
provider: Option<&str>,
model: Option<&str>,
) -> Result<Option<(Vec<String>, BTreeMap<String, String>)>> {
let Some((pname, _def, resolved_model)) = crate::providers::resolve_for(
&app.config,
crate::agent_config::provider_pref(agent),
agent.model.as_deref(),
provider,
model,
) else {
if provider.is_some() {
let known = crate::providers::names(&app.config);
let hint = if known.is_empty() {
"aucun (am providers add <nom> --base-url <url>)".to_string()
} else {
known.join(", ")
};
bail!(crate::tr_fmt!(
"provider '{}' inconnu — providers enregistrés: {}",
provider.unwrap_or(""),
hint
));
}
return Ok(None);
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = BTreeMap::new();
// env_map resolved against THIS provider (api_key stays @secret).
crate::agent_config::apply_env_map_resolved(
&mut env,
agent,
&app.config,
Some(pname),
resolved_model,
);
// The model through the agent's declared channel (model_env > model_arg);
// otherwise the env_map "model" slot already carried it.
if let Some(m) = resolved_model {
if let Some(var) = &agent.model_env {
env.insert(var.clone(), m.to_string());
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(m.to_string());
}
}
app.log.info(&crate::tr_fmt!(
"provider {} (modèle {}) au lancement de {}",
pname,
resolved_model.unwrap_or("-"),
agent.name
));
Ok(Some((args, env)))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
@@ -483,3 +583,141 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
.num_seconds();
Some(secs.max(0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AgentConfig, AgentDef, Config};
use clap::Parser;
fn test_app(providers_yaml: &str) -> App {
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,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
crate::app::App::from_cli(cli).unwrap()
}
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
let mut env_map = BTreeMap::new();
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
env_map.insert("model".to_string(), "MY_MODEL".to_string());
env_map.insert("base_url".to_string(), "MY_URL".to_string());
AgentDef {
name: "demo".to_string(),
display_name: None,
description: None,
category: None,
website: None,
install: None,
dependencies: vec![],
run: Some("demo".to_string()),
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: model_env.then(|| "MY_MODEL".to_string()),
model_arg: model_arg.then(|| "--model".to_string()),
provider: None,
model: None,
config: Some(AgentConfig {
env_map,
files: vec![],
provider_default: None,
}),
tags: vec![],
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
const OPENAI: &str = " default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n";
#[test]
fn apply_provider_resolves_and_never_leaks_the_token() {
let app = test_app(OPENAI);
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
.unwrap()
.unwrap();
// The token slot is the @secret reference — never a value.
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
assert!(args.is_empty());
}
#[test]
fn apply_provider_uses_the_declared_model_channel() {
let app = test_app(OPENAI);
// model_env wins over the env_map slot.
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
.unwrap()
.unwrap();
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert!(args.is_empty());
// model_arg is pushed on the command line.
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
.unwrap()
.unwrap();
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
}
#[test]
fn apply_provider_unknown_is_an_error_listing_the_registry() {
let app = test_app(OPENAI);
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nope"), "{msg}");
assert!(msg.contains("openai"), "registry list missing: {msg}");
}
#[test]
fn apply_provider_falls_back_to_the_embedded_default_provider() {
// The embedded default catalog ships providers (anthropic★ since
// issue #90): even an empty user config resolves through the cloud.
let app = test_app("");
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
.unwrap()
.expect("the embedded default provider resolves");
// Model "x" is unknown: the env_map model slot is skipped, but the
// provider slot is wired.
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
}
#[test]
fn model_flag_falls_back_to_the_cloud_registry() {
let app = test_app(OPENAI);
// apply_model bails (no local runtime in the test env) → the caller
// falls back to apply_provider with the default provider.
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
assert!(local.to_string().contains("not found on any local runtime"));
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
.unwrap()
.unwrap();
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
}
#[test]
fn config_parses_the_provider_registry_and_agents() {
let _cfg: Config = serde_yaml::from_str(&format!(
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
))
.unwrap();
}
}
+15 -1
View File
@@ -156,6 +156,18 @@ const START_FLAGS: &[HelpFlag] = &[
value: "",
desc: "Run inside the agent's container profile (issue #58)",
},
HelpFlag {
short: "",
long: "--model",
value: "MODEL",
desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)",
},
HelpFlag {
short: "",
long: "--provider",
value: "PROVIDER",
desc: "Provider of the registry to use (base_url + keyring token + model) (issue #92)",
},
];
pub static HELP_SPECS: &[HelpSpec] = &[
@@ -911,7 +923,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
about: "Run the agent command directly with the given arguments (no process management).",
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
flags: &[
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)" },
HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider of the registry to use (base_url + keyring token + model) (issue #92)" },
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
],
subcommands: &[],
@@ -922,6 +935,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
io: None,
examples: &[
HelpExample { desc: "Run an agent command directly.", code: "run claude-code" },
HelpExample { desc: "Use a provider of the registry.", code: "run claude-code --provider anthropic --model claude-sonnet-4" },
HelpExample { desc: "Pass --help through to the agent (after --).", code: "run claude-code -- --help" },
],
},
+1
View File
@@ -185,6 +185,7 @@ pub const CATALOG: &[(&str, &str)] = &[
("aucun provider configuré — voir: am providers add <nom> --base-url <url>", "no provider configured — see: am providers add <name> --base-url <url>"),
("modèle '{}' absent de la liste du provider '{}' — modèles: {}", "model '{}' is not in provider '{}' model list — models: {}"),
("configuré avec le provider {} (modèle {})", "configured with provider {} (model {})"),
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
+6
View File
@@ -254,6 +254,7 @@ impl AmCompleter {
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
"--profile", "--installed", "--tag", "--output", "--random",
"--model", "--provider", "--no-config",
],
agents,
catalog_agents,
@@ -1732,6 +1733,8 @@ fn handle_line(
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
model: opt_value("--model"),
provider: opt_value("--provider"),
..Default::default()
}),
"stop" => Command::Stop {
@@ -1744,6 +1747,8 @@ fn handle_line(
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
model: opt_value("--model"),
provider: opt_value("--provider"),
..Default::default()
},
force: flag("--force"),
@@ -1839,6 +1844,7 @@ fn handle_line(
Command::Run {
agent,
model: opt_value("--model"),
provider: opt_value("--provider"),
container: flag("--container"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test]
fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, None, false, &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
#[test]
fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, None, false, &[]).unwrap();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());