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:
+1
-1
@@ -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 |
|
| [#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 |
|
||||||
| [#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
|
Critère de sortie du jalon : un agent installé est opérationnel sans
|
||||||
configuration manuelle (tokens au keyring, provider/modèle par défaut).
|
configuration manuelle (tokens au keyring, provider/modèle par défaut).
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@
|
|||||||
.SH NAME
|
.SH NAME
|
||||||
restart \- Restart an agent: stop, then start with the same options
|
restart \- Restart an agent: stop, then start with the same options
|
||||||
.SH SYNOPSIS
|
.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
|
.SH DESCRIPTION
|
||||||
Restart an agent: stop, then start with the same options
|
Restart an agent: stop, then start with the same options
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
@@ -30,6 +30,9 @@ Apply an environment profile (env + args, defined in the config)
|
|||||||
\fB\-\-model\fR \fI<MODEL>\fR
|
\fB\-\-model\fR \fI<MODEL>\fR
|
||||||
Local model to use for this run (validated against the local runtimes)
|
Local model to use for this run (validated against the local runtimes)
|
||||||
.TP
|
.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
|
\fB\-\-parallel\fR
|
||||||
Start every member of a group simultaneously (issue #57)
|
Start every member of a group simultaneously (issue #57)
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@
|
|||||||
.SH NAME
|
.SH NAME
|
||||||
run \- Run the agent command directly with the given arguments (no process management)
|
run \- Run the agent command directly with the given arguments (no process management)
|
||||||
.SH SYNOPSIS
|
.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
|
.SH DESCRIPTION
|
||||||
Run the agent command directly with the given arguments (no process management)
|
Run the agent command directly with the given arguments (no process management)
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
@@ -12,6 +12,9 @@ Run the agent command directly with the given arguments (no process management)
|
|||||||
\fB\-\-model\fR \fI<MODEL>\fR
|
\fB\-\-model\fR \fI<MODEL>\fR
|
||||||
Local model to use for this run (validated against the local runtimes)
|
Local model to use for this run (validated against the local runtimes)
|
||||||
.TP
|
.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
|
\fB\-\-container\fR
|
||||||
Run the agent inside a container (issue #58)
|
Run the agent inside a container (issue #58)
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@
|
|||||||
.SH NAME
|
.SH NAME
|
||||||
start \- Start an agent (foreground by default, or detached with \-\-background)
|
start \- Start an agent (foreground by default, or detached with \-\-background)
|
||||||
.SH SYNOPSIS
|
.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
|
.SH DESCRIPTION
|
||||||
Start an agent (foreground by default, or detached with \-\-background)
|
Start an agent (foreground by default, or detached with \-\-background)
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
@@ -30,6 +30,9 @@ Apply an environment profile (env + args, defined in the config)
|
|||||||
\fB\-\-model\fR \fI<MODEL>\fR
|
\fB\-\-model\fR \fI<MODEL>\fR
|
||||||
Local model to use for this run (validated against the local runtimes)
|
Local model to use for this run (validated against the local runtimes)
|
||||||
.TP
|
.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
|
\fB\-\-parallel\fR
|
||||||
Start every member of a group simultaneously (issue #57)
|
Start every member of a group simultaneously (issue #57)
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+26
-3
@@ -63,23 +63,46 @@ pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
|
|||||||
/// model/base_url/provider -> values of the resolved provider. Explicit
|
/// model/base_url/provider -> values of the resolved provider. Explicit
|
||||||
/// values already present in agent.env are never overwritten.
|
/// values already present in agent.env are never overwritten.
|
||||||
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
|
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 {
|
let Some(cfg) = &agent.config else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if cfg.env_map.is_empty() {
|
if cfg.env_map.is_empty() {
|
||||||
return;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let Some(def) = crate::providers::get(config, pname) else {
|
let Some(def) = crate::providers::get(config, pname) else {
|
||||||
return;
|
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 {
|
for (slot, var) in &cfg.env_map {
|
||||||
let value = match slot.as_str() {
|
let value = match slot.as_str() {
|
||||||
"api_key" => "@secret".to_string(),
|
"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(),
|
"base_url" => def.base_url.clone(),
|
||||||
"provider" => pname.to_string(),
|
"provider" => pname.to_string(),
|
||||||
_ => continue, // unknown slots are ignored
|
_ => continue, // unknown slots are ignored
|
||||||
|
|||||||
@@ -495,6 +495,9 @@ pub enum Command {
|
|||||||
/// Local model to use for this run (validated against the local runtimes)
|
/// Local model to use for this run (validated against the local runtimes)
|
||||||
#[arg(long, value_name = "MODEL")]
|
#[arg(long, value_name = "MODEL")]
|
||||||
model: Option<String>,
|
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)
|
/// Run the agent inside a container (issue #58)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
container: bool,
|
container: bool,
|
||||||
@@ -566,6 +569,9 @@ pub struct StartArgs {
|
|||||||
/// Local model to use for this run (validated against the local runtimes)
|
/// Local model to use for this run (validated against the local runtimes)
|
||||||
#[arg(long, value_name = "MODEL")]
|
#[arg(long, value_name = "MODEL")]
|
||||||
pub model: Option<String>,
|
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)
|
/// Start every member of a group simultaneously (issue #57)
|
||||||
#[arg(long, action = ArgAction::SetTrue)]
|
#[arg(long, action = ArgAction::SetTrue)]
|
||||||
pub parallel: bool,
|
pub parallel: bool,
|
||||||
|
|||||||
+9
-2
@@ -226,8 +226,15 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
Command::Doctor { fix, watch } => {
|
Command::Doctor { fix, watch } => {
|
||||||
doctor_cmd::run(app, *fix, *watch)
|
doctor_cmd::run(app, *fix, *watch)
|
||||||
}
|
}
|
||||||
Command::Run { agent, model, container, args } => {
|
Command::Run { agent, model, provider, container, args } => {
|
||||||
run_cmd::run(app, agent, model.as_deref(), *container, args)
|
run_cmd::run(
|
||||||
|
app,
|
||||||
|
agent,
|
||||||
|
model.as_deref(),
|
||||||
|
provider.as_deref(),
|
||||||
|
*container,
|
||||||
|
args,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Command::Service(svc) => match svc {
|
Command::Service(svc) => match svc {
|
||||||
crate::cli::ServiceCmd::Install { agent, autostart } => {
|
crate::cli::ServiceCmd::Install { agent, autostart } => {
|
||||||
|
|||||||
+242
-4
@@ -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
|
// --model (issue #71): resolve against the local runtimes, then
|
||||||
// transmit through the agent's declared channel (env var or flag).
|
// 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_args = extra_args;
|
||||||
let mut extra_env = extra_env;
|
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 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_args.extend(a);
|
||||||
extra_env.extend(e);
|
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) {
|
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||||
let members = app.catalog.group_members(group);
|
let members = app.catalog.group_members(group);
|
||||||
@@ -320,6 +337,7 @@ pub fn run(
|
|||||||
app: &App,
|
app: &App,
|
||||||
target: &str,
|
target: &str,
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
|
provider: Option<&str>,
|
||||||
container: bool,
|
container: bool,
|
||||||
extra: &[OsString],
|
extra: &[OsString],
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
@@ -333,10 +351,28 @@ pub fn run(
|
|||||||
.map(|o| o.to_string_lossy().to_string())
|
.map(|o| o.to_string_lossy().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
||||||
if let Some(m) = model {
|
// Issue #92: --provider resolves from the LLM provider registry (cloud);
|
||||||
let (a, e) = apply_model(app, agent, m)?;
|
// --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_args.extend(a);
|
||||||
extra_env.extend(e);
|
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)?;
|
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
||||||
if app.dry_run() {
|
if app.dry_run() {
|
||||||
@@ -474,6 +510,70 @@ pub fn apply_model(
|
|||||||
Ok((args, env))
|
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).
|
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
||||||
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||||
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
||||||
@@ -483,3 +583,141 @@ fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
|||||||
.num_seconds();
|
.num_seconds();
|
||||||
Some(secs.max(0) as u64)
|
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
@@ -156,6 +156,18 @@ const START_FLAGS: &[HelpFlag] = &[
|
|||||||
value: "",
|
value: "",
|
||||||
desc: "Run inside the agent's container profile (issue #58)",
|
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] = &[
|
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).",
|
about: "Run the agent command directly with the given arguments (no process management).",
|
||||||
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
|
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
|
||||||
flags: &[
|
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)" },
|
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
|
||||||
],
|
],
|
||||||
subcommands: &[],
|
subcommands: &[],
|
||||||
@@ -922,6 +935,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
io: None,
|
io: None,
|
||||||
examples: &[
|
examples: &[
|
||||||
HelpExample { desc: "Run an agent command directly.", code: "run claude-code" },
|
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" },
|
HelpExample { desc: "Pass --help through to the agent (after --).", code: "run claude-code -- --help" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>"),
|
("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: {}"),
|
("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 {})"),
|
("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"),
|
("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"),
|
("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"),
|
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ impl AmCompleter {
|
|||||||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||||||
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
||||||
"--profile", "--installed", "--tag", "--output", "--random",
|
"--profile", "--installed", "--tag", "--output", "--random",
|
||||||
|
"--model", "--provider", "--no-config",
|
||||||
],
|
],
|
||||||
agents,
|
agents,
|
||||||
catalog_agents,
|
catalog_agents,
|
||||||
@@ -1732,6 +1733,8 @@ fn handle_line(
|
|||||||
agent: rest.first().cloned(),
|
agent: rest.first().cloned(),
|
||||||
background: flag("--background") || flag("-b"),
|
background: flag("--background") || flag("-b"),
|
||||||
profile: opt_value("--profile"),
|
profile: opt_value("--profile"),
|
||||||
|
model: opt_value("--model"),
|
||||||
|
provider: opt_value("--provider"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
"stop" => Command::Stop {
|
"stop" => Command::Stop {
|
||||||
@@ -1744,6 +1747,8 @@ fn handle_line(
|
|||||||
agent: rest.first().cloned(),
|
agent: rest.first().cloned(),
|
||||||
background: flag("--background") || flag("-b"),
|
background: flag("--background") || flag("-b"),
|
||||||
profile: opt_value("--profile"),
|
profile: opt_value("--profile"),
|
||||||
|
model: opt_value("--model"),
|
||||||
|
provider: opt_value("--provider"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
force: flag("--force"),
|
force: flag("--force"),
|
||||||
@@ -1839,6 +1844,7 @@ fn handle_line(
|
|||||||
Command::Run {
|
Command::Run {
|
||||||
agent,
|
agent,
|
||||||
model: opt_value("--model"),
|
model: opt_value("--model"),
|
||||||
|
provider: opt_value("--provider"),
|
||||||
container: flag("--container"),
|
container: flag("--container"),
|
||||||
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
|
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ agents:
|
|||||||
#[test]
|
#[test]
|
||||||
fn run_emits_one_run_event_with_exit_code() {
|
fn run_emits_one_run_event_with_exit_code() {
|
||||||
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
|
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);
|
assert_eq!(code, 0);
|
||||||
let evs = events(&app);
|
let evs = events(&app);
|
||||||
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
|
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]
|
#[test]
|
||||||
fn dry_run_writes_no_event() {
|
fn dry_run_writes_no_event() {
|
||||||
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
|
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_eq!(code, 0);
|
||||||
assert!(events(&app).is_empty(), "dry-run must not append events");
|
assert!(events(&app).is_empty(), "dry-run must not append events");
|
||||||
assert!(events::journal_files(&app.events_dir()).is_empty());
|
assert!(events::journal_files(&app.events_dir()).is_empty());
|
||||||
|
|||||||
Reference in New Issue
Block a user