Skip to main content

ferro_ai/
config.rs

1//! Environment-driven LLM client factory.
2//!
3//! [`AiConfig::from_env`] reads four environment variables and constructs the
4//! appropriate [`crate::client::LlmClient`] implementation. Unknown providers
5//! and missing required keys are rejected at construction time — not at the
6//! first LLM call (D-06).
7//!
8//! ## Environment variables
9//!
10//! | Variable | Required | Default | Description |
11//! |---|---|---|---|
12//! | `FERRO_AI_PROVIDER` | No | `anthropic` | Provider name: `anthropic`, `openai`, `groq`, `ollama` |
13//! | `FERRO_AI_MODEL` | No | provider default | Model identifier override |
14//! | `FERRO_AI_API_KEY` | Yes (except ollama) | — | API key for the selected provider |
15//! | `FERRO_AI_BASE_URL` | No | provider default | Base URL override (useful for proxies) |
16//! | `FERRO_AI_EMBED_MODEL` | No | provider default | Embedding model override, read per-provider by `embed()` (separate from the chat `FERRO_AI_MODEL`) |
17//!
18//! For `anthropic`, `ANTHROPIC_API_KEY` is accepted as a fallback when
19//! `FERRO_AI_API_KEY` is not set (backward compatibility only; `FERRO_AI_API_KEY`
20//! takes precedence).
21
22use crate::client::{AnthropicClient, LlmClient, OllamaClient, OpenAiClient};
23use crate::error::Error;
24
25/// Zero-sized marker for the environment-driven client factory.
26///
27/// All functionality is in the associated function [`AiConfig::from_env`].
28pub struct AiConfig;
29
30impl AiConfig {
31    /// Construct the configured LLM client from environment variables.
32    ///
33    /// Reads `FERRO_AI_PROVIDER` (default `"anthropic"`), `FERRO_AI_MODEL`,
34    /// `FERRO_AI_API_KEY`, and `FERRO_AI_BASE_URL`. Returns
35    /// `Err(Error::Config)` if the provider is unknown or a required key is
36    /// missing — fail-fast at startup, not on the first call (D-06).
37    ///
38    /// # Provider strings
39    ///
40    /// - `"anthropic"` — [`AnthropicClient`]; requires `FERRO_AI_API_KEY` (or `ANTHROPIC_API_KEY` fallback)
41    /// - `"openai"` — [`OpenAiClient`]; requires `FERRO_AI_API_KEY`
42    /// - `"groq"` — [`OpenAiClient`] with Groq base URL; requires `FERRO_AI_API_KEY`
43    /// - `"ollama"` — [`OllamaClient`]; no key needed
44    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        // Remove both key sources so there is nothing to fall back to.
113        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        // groq uses OpenAiClient; its default_model() is "gpt-4o"
150        assert_eq!(client.default_model(), "gpt-4o");
151    }
152}