//! 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//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> = BTreeMap::new(); /// Every provider of the effective configuration (empty when unset). /// `None` values are the YAML null-deletion markers and are skipped. pub fn all(config: &Config) -> &BTreeMap> { config.settings.providers.as_ref().unwrap_or(&EMPTY) } /// The providers actually defined (null markers filtered out). pub fn defined(config: &Config) -> impl Iterator { 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) } /// 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()) } /// Sorted provider names (for error messages listing the registry). pub fn names(config: &Config) -> Vec { let mut v: Vec = 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() && 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(), 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(), 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 } #[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")); } #[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()]); } }