feat: v0.7.0 — AgentDef provider/model + install --provider/--model/--no-config : résolution flag > agent > défaut, erreur si provider inconnu (liste du registre), warning si modèle absent, résolution affichée en résumé et au --dry-run (closes #90)

- Providers réels livrés dans le catalogue par défaut (anthropic★/openai/deepseek/google) + hints provider sur claude-code/deepseek-harness
- Suppression par null dans l'overlay : am providers remove écrit <nom>: null (merge traite null comme suppression, set_path remplace le null avant de descendre)
- Cascade @secret (#89) : le provider déclaré de l'agent prime sur default_provider
This commit is contained in:
2026-08-19 20:03:47 -04:00
parent 7312e6ceab
commit ebc753cc4a
14 changed files with 336 additions and 38 deletions
+86 -6
View File
@@ -10,13 +10,21 @@ 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();
static EMPTY: BTreeMap<String, Option<ProviderDef>> = BTreeMap::new();
/// Every provider of the effective configuration (empty when unset).
pub fn all(config: &Config) -> &BTreeMap<String, ProviderDef> {
/// `None` values are the YAML null-deletion markers and are skipped.
pub fn all(config: &Config) -> &BTreeMap<String, Option<ProviderDef>> {
config.settings.providers.as_ref().unwrap_or(&EMPTY)
}
/// The providers actually defined (null markers filtered out).
pub fn defined(config: &Config) -> impl Iterator<Item = (&String, &ProviderDef)> {
all(config)
.iter()
.filter_map(|(k, v)| v.as_ref().map(|def| (k, def)))
}
/// Look up one provider by name.
pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> {
config.settings.provider(name)
@@ -41,6 +49,38 @@ pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Op
.or(provider.default_model.as_deref())
}
/// Sorted provider names (for error messages listing the registry).
pub fn names(config: &Config) -> Vec<String> {
let mut v: Vec<String> = defined(config).map(|(k, _)| k.clone()).collect();
v.sort();
v
}
/// Resolve the provider + model of an agent at install time (issue #90):
/// `flag --provider` > `agent.provider` > `settings.default_provider` ;
/// `flag --model` > `agent.model` > `provider.default_model`.
/// Returns None when no provider is configured at all.
pub fn resolve_for<'a>(
config: &'a Config,
agent_provider: Option<&'a str>,
agent_model: Option<&'a str>,
flag_provider: Option<&'a str>,
flag_model: Option<&'a str>,
) -> Option<(&'a str, &'a ProviderDef, Option<&'a str>)> {
let provider_name = flag_provider
.filter(|p| !p.is_empty())
.or(agent_provider.filter(|p| !p.is_empty()))
.or_else(|| default_name(config))?;
let def = get(config, provider_name)?;
let model = model_for(
def,
flag_model
.filter(|m| !m.is_empty())
.or(agent_model.filter(|m| !m.is_empty())),
);
Some((provider_name, def, model))
}
/// Provider names accept the same slug rule as agent names: [a-z0-9_-].
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
@@ -60,19 +100,19 @@ mod tests {
let mut providers = BTreeMap::new();
providers.insert(
"openai".to_string(),
ProviderDef {
Some(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 {
Some(ProviderDef {
base_url: "https://api.deepseek.com".to_string(),
default_model: None,
models: vec!["deepseek-chat".to_string()],
},
}),
);
c.settings.providers = Some(providers);
c
@@ -114,4 +154,44 @@ mod tests {
assert!(!is_valid_name(""));
assert!(!is_valid_name("has space"));
}
#[test]
fn resolve_for_flag_beats_agent_beats_default() {
let c = config_with();
// 1. Rien de précisé → default_provider (openai) + son default_model.
let (p, _, m) = resolve_for(&c, None, None, None, None).unwrap();
assert_eq!(p, "openai");
assert_eq!(m, Some("gpt-5.2"));
// 2. Le provider déclaré par l'agent gagne sur le défaut.
let (p, _, m) = resolve_for(&c, Some("deepseek"), None, None, None).unwrap();
assert_eq!(p, "deepseek");
assert_eq!(m, None, "deepseek n'a pas de default_model");
// 3. Le flag --provider gagne sur tout.
let (p, _, m) = resolve_for(&c, Some("openai"), None, Some("deepseek"), None).unwrap();
assert_eq!(p, "deepseek");
// 4. --model flag > agent.model > default_model.
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, None).unwrap();
assert_eq!(m, Some("o4-mini"));
let (_, _, m) = resolve_for(&c, None, Some("o4-mini"), None, Some("gpt-5.1-mini")).unwrap();
assert_eq!(m, Some("gpt-5.1-mini"));
}
#[test]
fn resolve_for_none_without_any_provider() {
let mut c = config_with();
c.settings.default_provider = None;
c.settings.providers = None;
assert!(resolve_for(&c, None, None, None, None).is_none());
// Un agent qui déclare son provider résout même sans défaut.
assert!(resolve_for(&c, Some("openai"), None, None, None).is_none());
let mut c2 = config_with();
c2.settings.default_provider = None;
assert_eq!(resolve_for(&c2, Some("openai"), None, None, None).unwrap().0, "openai");
}
#[test]
fn names_are_sorted() {
let c = config_with();
assert_eq!(names(&c), vec!["deepseek".to_string(), "openai".to_string()]);
}
}