- 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
198 lines
7.3 KiB
Rust
198 lines
7.3 KiB
Rust
//! 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, Option<ProviderDef>> = 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<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)
|
|
}
|
|
|
|
/// 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<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()
|
|
&& 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()]);
|
|
}
|
|
}
|