1use crate::client::{AnthropicClient, LlmClient, OllamaClient, OpenAiClient};
23use crate::error::Error;
24
25pub struct AiConfig;
29
30impl AiConfig {
31 pub fn from_env() -> Result<Box<dyn LlmClient>, Error> {
45 let provider =
46 std::env::var("FERRO_AI_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
47 let model = std::env::var("FERRO_AI_MODEL").ok();
48 let api_key = std::env::var("FERRO_AI_API_KEY").ok();
49 let base_url = std::env::var("FERRO_AI_BASE_URL").ok();
50
51 match provider.to_lowercase().as_str() {
52 "anthropic" => {
53 let key = api_key
54 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
55 .ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set".into()))?;
56 Ok(Box::new(AnthropicClient::new(key, model)))
57 }
58 "openai" => {
59 let key = api_key
60 .ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set for openai".into()))?;
61 Ok(Box::new(OpenAiClient::new(key, model, base_url)))
62 }
63 "groq" => {
64 let key = api_key
65 .ok_or_else(|| Error::Config("FERRO_AI_API_KEY not set for groq".into()))?;
66 let url = base_url.unwrap_or_else(|| "https://api.groq.com/openai".into());
67 Ok(Box::new(OpenAiClient::new(key, model, Some(url))))
68 }
69 "ollama" => Ok(Box::new(OllamaClient::new(model, base_url))),
70 unknown => Err(Error::Config(format!(
71 "unknown FERRO_AI_PROVIDER: '{unknown}'"
72 ))),
73 }
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn from_env_fails_on_unknown_provider() {
83 let _guard = crate::ENV_LOCK.lock().unwrap();
84 std::env::set_var("FERRO_AI_PROVIDER", "bogus");
85 std::env::remove_var("FERRO_AI_MODEL");
86 std::env::remove_var("FERRO_AI_API_KEY");
87 std::env::remove_var("FERRO_AI_BASE_URL");
88 let result = AiConfig::from_env();
89 std::env::remove_var("FERRO_AI_PROVIDER");
90 assert!(
91 matches!(result, Err(Error::Config(_))),
92 "expected Config error for unknown provider"
93 );
94 }
95
96 #[test]
97 fn from_env_ollama_default_model() {
98 let _guard = crate::ENV_LOCK.lock().unwrap();
99 std::env::set_var("FERRO_AI_PROVIDER", "ollama");
100 std::env::remove_var("FERRO_AI_MODEL");
101 std::env::remove_var("FERRO_AI_API_KEY");
102 std::env::remove_var("FERRO_AI_BASE_URL");
103 let client = AiConfig::from_env().expect("ollama needs no key");
104 std::env::remove_var("FERRO_AI_PROVIDER");
105 assert_eq!(client.default_model(), "llama3.1");
106 }
107
108 #[test]
109 fn from_env_anthropic_missing_key_errors() {
110 let _guard = crate::ENV_LOCK.lock().unwrap();
111 std::env::set_var("FERRO_AI_PROVIDER", "anthropic");
112 std::env::remove_var("FERRO_AI_API_KEY");
114 std::env::remove_var("ANTHROPIC_API_KEY");
115 std::env::remove_var("FERRO_AI_MODEL");
116 std::env::remove_var("FERRO_AI_BASE_URL");
117 let result = AiConfig::from_env();
118 std::env::remove_var("FERRO_AI_PROVIDER");
119 assert!(
120 matches!(result, Err(Error::Config(_))),
121 "expected Config error for missing anthropic key"
122 );
123 }
124
125 #[test]
126 fn from_env_anthropic_with_explicit_key() {
127 let _guard = crate::ENV_LOCK.lock().unwrap();
128 std::env::set_var("FERRO_AI_PROVIDER", "anthropic");
129 std::env::set_var("FERRO_AI_API_KEY", "test-key");
130 std::env::remove_var("FERRO_AI_MODEL");
131 std::env::remove_var("FERRO_AI_BASE_URL");
132 std::env::remove_var("ANTHROPIC_API_KEY");
133 let client = AiConfig::from_env().expect("should succeed with explicit key");
134 std::env::remove_var("FERRO_AI_PROVIDER");
135 std::env::remove_var("FERRO_AI_API_KEY");
136 assert_eq!(client.default_model(), "claude-sonnet-4-6");
137 }
138
139 #[test]
140 fn from_env_groq_base_url_default() {
141 let _guard = crate::ENV_LOCK.lock().unwrap();
142 std::env::set_var("FERRO_AI_PROVIDER", "groq");
143 std::env::set_var("FERRO_AI_API_KEY", "groq-key");
144 std::env::remove_var("FERRO_AI_MODEL");
145 std::env::remove_var("FERRO_AI_BASE_URL");
146 let client = AiConfig::from_env().expect("groq should succeed with key");
147 std::env::remove_var("FERRO_AI_PROVIDER");
148 std::env::remove_var("FERRO_AI_API_KEY");
149 assert_eq!(client.default_model(), "gpt-4o");
151 }
152}