use crate::client::{AnthropicClient, LlmClient, OllamaClient, OpenAiClient};
use crate::error::Error;
pub struct AiConfig;
impl AiConfig {
pub fn from_env() -> Result<Box<dyn LlmClient>, Error> {
let provider =
std::env::var("FERRO_AI_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
let model = std::env::var("FERRO_AI_MODEL").ok();
let api_key = std::env::var("FERRO_AI_API_KEY").ok();
let base_url = std::env::var("FERRO_AI_BASE_URL").ok();
match provider.to_lowercase().as_str() {
"anthropic" => {
let key = api_key
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set".into()))?;
Ok(Box::new(AnthropicClient::new(key, model)))
}
"openai" => {
let key = api_key
.ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set for openai".into()))?;
Ok(Box::new(OpenAiClient::new(key, model, base_url)))
}
"groq" => {
let key = api_key
.ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set for groq".into()))?;
let url = base_url.unwrap_or_else(|| "https://api.groq.com/openai".into());
Ok(Box::new(OpenAiClient::new(key, model, Some(url))))
}
"ollama" => Ok(Box::new(OllamaClient::new(model, base_url))),
unknown => Err(Error::Config(format!(
"unknown FERRO_AI_PROVIDER: '{unknown}'"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_fails_on_unknown_provider() {
let _guard = crate::ENV_LOCK.lock().unwrap();
std::env::set_var("FERRO_AI_PROVIDER", "bogus");
std::env::remove_var("FERRO_AI_MODEL");
std::env::remove_var("FERRO_AI_API_KEY");
std::env::remove_var("FERRO_AI_BASE_URL");
let result = AiConfig::from_env();
std::env::remove_var("FERRO_AI_PROVIDER");
assert!(
matches!(result, Err(Error::Config(_))),
"expected Config error for unknown provider"
);
}
#[test]
fn from_env_ollama_default_model() {
let _guard = crate::ENV_LOCK.lock().unwrap();
std::env::set_var("FERRO_AI_PROVIDER", "ollama");
std::env::remove_var("FERRO_AI_MODEL");
std::env::remove_var("FERRO_AI_API_KEY");
std::env::remove_var("FERRO_AI_BASE_URL");
let client = AiConfig::from_env().expect("ollama needs no key");
std::env::remove_var("FERRO_AI_PROVIDER");
assert_eq!(client.default_model(), "llama3.1");
}
#[test]
fn from_env_anthropic_missing_key_errors() {
let _guard = crate::ENV_LOCK.lock().unwrap();
std::env::set_var("FERRO_AI_PROVIDER", "anthropic");
std::env::remove_var("FERRO_AI_API_KEY");
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("FERRO_AI_MODEL");
std::env::remove_var("FERRO_AI_BASE_URL");
let result = AiConfig::from_env();
std::env::remove_var("FERRO_AI_PROVIDER");
assert!(
matches!(result, Err(Error::Config(_))),
"expected Config error for missing anthropic key"
);
}
#[test]
fn from_env_anthropic_with_explicit_key() {
let _guard = crate::ENV_LOCK.lock().unwrap();
std::env::set_var("FERRO_AI_PROVIDER", "anthropic");
std::env::set_var("FERRO_AI_API_KEY", "test-key");
std::env::remove_var("FERRO_AI_MODEL");
std::env::remove_var("FERRO_AI_BASE_URL");
std::env::remove_var("ANTHROPIC_API_KEY");
let client = AiConfig::from_env().expect("should succeed with explicit key");
std::env::remove_var("FERRO_AI_PROVIDER");
std::env::remove_var("FERRO_AI_API_KEY");
assert_eq!(client.default_model(), "claude-sonnet-4-6");
}
#[test]
fn from_env_groq_base_url_default() {
let _guard = crate::ENV_LOCK.lock().unwrap();
std::env::set_var("FERRO_AI_PROVIDER", "groq");
std::env::set_var("FERRO_AI_API_KEY", "groq-key");
std::env::remove_var("FERRO_AI_MODEL");
std::env::remove_var("FERRO_AI_BASE_URL");
let client = AiConfig::from_env().expect("groq should succeed with key");
std::env::remove_var("FERRO_AI_PROVIDER");
std::env::remove_var("FERRO_AI_API_KEY");
assert_eq!(client.default_model(), "gpt-4o");
}
}