use crate::config::{ANTHROPIC_PROVIDER_NAME, BACKGROUND_PROFILE_NAME, Config, ProviderConfig};
use crate::provider::Provider;
use crate::provider::anthropic::AnthropicProvider;
use crate::provider::fallback::FallbackProvider;
use crate::provider::openai_compatible::OpenAICompatibleProvider;
use anyhow::Result;
use std::collections::HashMap;
use std::sync::Arc;
pub struct ProviderRegistry {
providers: HashMap<String, Arc<dyn Provider>>,
}
impl ProviderRegistry {
pub fn from_config(config: &Config) -> Result<Self> {
let errors = config.validate_profiles();
if !errors.is_empty() {
anyhow::bail!(
"invalid profile configuration:\n - {}",
errors.join("\n - ")
);
}
let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
providers.insert(
ANTHROPIC_PROVIDER_NAME.to_string(),
Arc::new(AnthropicProvider::new(&config.anthropic)?),
);
for (name, pcfg) in &config.providers {
if name == ANTHROPIC_PROVIDER_NAME {
anyhow::bail!(
"provider name '{ANTHROPIC_PROVIDER_NAME}' is reserved for the built-in"
);
}
let provider: Arc<dyn Provider> = match pcfg {
ProviderConfig::OpenAiCompatible(c) => {
let mut cfg = c.clone();
if cfg.provider_name.is_none() {
cfg.provider_name = Some(name.clone());
}
Arc::new(OpenAICompatibleProvider::new(&cfg))
}
};
providers.insert(name.clone(), provider);
}
Ok(Self { providers })
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Provider>> {
self.providers.get(name).cloned()
}
pub fn anthropic(&self) -> Arc<dyn Provider> {
self.providers
.get(ANTHROPIC_PROVIDER_NAME)
.cloned()
.expect("anthropic provider is always registered")
}
pub fn for_room(&self, config: &Config, room_id: &str) -> Arc<dyn Provider> {
let Some(profile_name) = config.profile_for(room_id) else {
return self.anthropic();
};
self.for_profile(config, profile_name)
}
pub fn for_profile(&self, config: &Config, profile_name: &str) -> Arc<dyn Provider> {
let Some(profile) = config.profiles.get(profile_name) else {
return self.anthropic();
};
let primary = self
.get(&profile.provider)
.unwrap_or_else(|| self.anthropic());
match profile
.fallback_provider
.as_deref()
.and_then(|n| self.get(n))
{
Some(fallback) => Arc::new(FallbackProvider::new(primary, fallback)),
None => primary,
}
}
pub fn background_provider(&self, config: &Config) -> Arc<dyn Provider> {
if config.profiles.contains_key(BACKGROUND_PROFILE_NAME) {
self.for_profile(config, BACKGROUND_PROFILE_NAME)
} else {
self.anthropic()
}
}
pub fn background_provider_for_namespace(
&self,
config: &Config,
namespace: &str,
) -> Arc<dyn Provider> {
match config.background_profile_for_namespace(namespace) {
Some(profile_name) => self.for_profile(config, profile_name),
None => self.anthropic(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Config {
toml::from_str(s).expect("config should parse")
}
#[test]
fn registry_always_has_anthropic() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
assert!(reg.get(ANTHROPIC_PROVIDER_NAME).is_some());
}
#[test]
fn registry_loads_openai_compatible() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let local = reg.get("local").expect("local provider registered");
assert_eq!(local.name(), "local");
}
#[test]
fn from_config_rejects_invalid_profiles() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.default]
provider = "ghost"
"#,
);
let err = ProviderRegistry::from_config(&cfg)
.err()
.expect("expected an error");
assert!(format!("{err:#}").contains("ghost"));
}
#[test]
fn from_config_rejects_reserved_name() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.anthropic]
type = "openai_compatible"
base_url = "http://x/v1"
model = "y"
"#,
);
let err = ProviderRegistry::from_config(&cfg)
.err()
.expect("expected an error");
assert!(format!("{err:#}").contains("reserved"));
}
#[test]
fn for_room_falls_back_to_anthropic() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let p = reg.for_room(&cfg, "!any:srv");
assert_eq!(p.name(), "anthropic");
}
#[test]
fn for_room_wraps_in_fallback_when_profile_defines_one() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.default]
provider = "anthropic"
fallback_provider = "local"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let raw_anthropic = reg.anthropic();
let routed = reg.for_room(&cfg, "!any:srv");
assert_eq!(routed.name(), "anthropic");
assert!(
!Arc::ptr_eq(&raw_anthropic, &routed),
"expected a wrapped FallbackProvider, got the bare anthropic Arc"
);
}
#[test]
fn background_provider_uses_background_profile_when_present() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.background]
provider = "anthropic"
fallback_provider = "local"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let raw_anthropic = reg.anthropic();
let bg = reg.background_provider(&cfg);
assert!(
!Arc::ptr_eq(&raw_anthropic, &bg),
"background provider should be wrapped when profile has fallback"
);
}
#[test]
fn background_provider_for_namespace_uses_namespace_override() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.local_only]
provider = "local"
[memory_namespace.user_nsfw]
include = ["default"]
background_profile = "local_only"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let p = reg.background_provider_for_namespace(&cfg, "user_nsfw");
assert_eq!(p.name(), "local");
let p_default = reg.background_provider_for_namespace(&cfg, "default");
assert_eq!(p_default.name(), "anthropic");
}
#[test]
fn background_provider_for_namespace_falls_back_to_global() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.background]
provider = "anthropic"
fallback_provider = "local"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let raw_anthropic = reg.anthropic();
let p = reg.background_provider_for_namespace(&cfg, "default");
assert!(
!Arc::ptr_eq(&raw_anthropic, &p),
"expected wrapped FallbackProvider, got the bare anthropic Arc"
);
}
#[test]
fn background_provider_defaults_to_anthropic_when_not_configured() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
let raw_anthropic = reg.anthropic();
let bg = reg.background_provider(&cfg);
assert!(
Arc::ptr_eq(&raw_anthropic, &bg),
"without [profiles.background], background should be plain anthropic"
);
}
#[test]
fn for_room_routes_to_configured_profile() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.nsfw]
provider = "local"
[room_profile.private_nsfw]
profile = "nsfw"
rooms = ["!nsfw:srv"]
"#,
);
let reg = ProviderRegistry::from_config(&cfg).unwrap();
assert_eq!(reg.for_room(&cfg, "!nsfw:srv").name(), "local");
assert_eq!(reg.for_room(&cfg, "!other:srv").name(), "anthropic");
}
}