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:
+9
-2
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user