feat: v0.7.0 — registre de providers LLM : am providers list/show/add/remove/default, settings.providers + default_provider (closes #88)

This commit is contained in:
2026-08-19 15:32:45 -04:00
parent 1948e3bc79
commit d38cd7352f
18 changed files with 823 additions and 13 deletions
+117
View File
@@ -0,0 +1,117 @@
//! 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/<name>/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<String, ProviderDef> = BTreeMap::new();
/// Every provider of the effective configuration (empty when unset).
pub fn all(config: &Config) -> &BTreeMap<String, ProviderDef> {
config.settings.providers.as_ref().unwrap_or(&EMPTY)
}
/// 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())
}
/// 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(),
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 {
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"));
}
}