Skip to main content

llm_trait/
config.rs

1//! Unified LLM provider configuration.
2
3use serde_json::Value;
4use std::collections::HashMap;
5
6use super::backend::Protocol;
7use super::error::LlmError;
8
9/// LLM Provider configuration (protocol-agnostic, vendor-agnostic).
10///
11/// Design principles:
12/// 1. Protocol can be configured (explicit)
13/// 2. Protocol can be auto-inferred (from URL/model) by the ModelRegistry
14/// 3. Defaults to OpenAI protocol (fallback)
15#[derive(Clone, Default)]
16pub struct LlmConfig {
17    /// Protocol (optional, auto-inferred if not set)
18    ///
19    /// If set, overrides all inference (highest priority).
20    /// If not set, the factory uses ModelRegistry to infer from URL and model name.
21    pub protocol: Option<Protocol>,
22
23    /// API Key (required)
24    ///
25    /// Hidden from `Debug` output — never log a config expecting to see the key.
26    pub api_key: String,
27
28    /// Model name (required)
29    pub model: String,
30
31    /// Base URL (required)
32    pub base_url: String,
33
34    /// Other options
35    pub options: HashMap<String, Value>,
36}
37
38/// `Debug` redacts [`LlmConfig::api_key`] so a config cannot leak a credential
39/// through a log line, a panic message, or a test failure dump.
40impl std::fmt::Debug for LlmConfig {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("LlmConfig")
43            .field("protocol", &self.protocol)
44            .field("api_key", &"<redacted>")
45            .field("model", &self.model)
46            .field("base_url", &self.base_url)
47            .field("options", &self.options)
48            .finish()
49    }
50}
51
52impl LlmConfig {
53    /// Create from environment variables.
54    pub fn from_env() -> Result<Self, LlmError> {
55        let protocol = std::env::var("LLM_PROTOCOL")
56            .ok()
57            .and_then(|s| s.parse().ok());
58
59        Ok(Self {
60            protocol,
61            api_key: std::env::var("LLM_API_KEY")
62                .map_err(|_| LlmError::config("LLM_API_KEY environment variable not set"))?,
63            model: std::env::var("LLM_MODEL")
64                .map_err(|_| LlmError::config("LLM_MODEL environment variable not set"))?,
65            base_url: std::env::var("LLM_BASE_URL")
66                .map_err(|_| LlmError::config("LLM_BASE_URL environment variable not set"))?,
67            options: HashMap::new(),
68        })
69    }
70
71    /// Get base URL (always available since it's required).
72    pub fn resolve_base_url(&self) -> &str {
73        &self.base_url
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn from_env_with_protocol() {
83        // This test verifies the protocol parsing logic
84        let protocol = "anthropic".parse::<Protocol>().ok();
85        assert_eq!(protocol, Some(Protocol::Anthropic));
86    }
87
88    /// `from_env` reads process env, and tests run in parallel — so anything
89    /// touching `LLM_*` must hold this lock for its whole scope.
90    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
91        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
92        match LOCK.lock() {
93            Ok(guard) => guard,
94            Err(poisoned) => poisoned.into_inner(),
95        }
96    }
97
98    fn set_llm_env(vars: &[(&str, &str)]) {
99        for key in ["LLM_API_KEY", "LLM_MODEL", "LLM_BASE_URL", "LLM_PROTOCOL"] {
100            // SAFETY: callers hold `env_lock()`, so no other test reads these
101            // variables concurrently.
102            unsafe { std::env::remove_var(key) };
103        }
104        for (key, value) in vars {
105            unsafe { std::env::set_var(key, value) };
106        }
107    }
108
109    #[test]
110    fn from_env_reads_required_vars() {
111        let _guard = env_lock();
112        set_llm_env(&[
113            ("LLM_API_KEY", "sk-from-env"),
114            ("LLM_MODEL", "gpt-4o-mini"),
115            ("LLM_BASE_URL", "https://api.openai.com/v1"),
116        ]);
117
118        let config = LlmConfig::from_env().unwrap();
119        assert_eq!(config.api_key, "sk-from-env");
120        assert_eq!(config.model, "gpt-4o-mini");
121        assert_eq!(config.base_url, "https://api.openai.com/v1");
122        assert_eq!(config.protocol, None, "unset LLM_PROTOCOL means infer");
123    }
124
125    #[test]
126    fn from_env_parses_protocol_override() {
127        let _guard = env_lock();
128        set_llm_env(&[
129            ("LLM_API_KEY", "sk"),
130            ("LLM_MODEL", "m"),
131            ("LLM_BASE_URL", "https://example.invalid"),
132            ("LLM_PROTOCOL", "anthropic"),
133        ]);
134
135        let config = LlmConfig::from_env().unwrap();
136        assert_eq!(config.protocol, Some(Protocol::Anthropic));
137
138        // An unrecognised value leaves protocol unset (registry infers) rather
139        // than failing the whole config.
140        set_llm_env(&[
141            ("LLM_API_KEY", "sk"),
142            ("LLM_MODEL", "m"),
143            ("LLM_BASE_URL", "https://example.invalid"),
144            ("LLM_PROTOCOL", "not-a-protocol"),
145        ]);
146        let config = LlmConfig::from_env().unwrap();
147        assert_eq!(config.protocol, None);
148    }
149
150    #[test]
151    fn from_env_names_whichever_var_is_missing() {
152        let _guard = env_lock();
153        let complete = [
154            ("LLM_API_KEY", "sk"),
155            ("LLM_MODEL", "m"),
156            ("LLM_BASE_URL", "https://example.invalid"),
157        ];
158
159        for drop in ["LLM_API_KEY", "LLM_MODEL", "LLM_BASE_URL"] {
160            let kept: Vec<(&str, &str)> = complete
161                .iter()
162                .cloned()
163                .filter(|(k, _)| *k != drop)
164                .collect();
165            set_llm_env(&kept);
166
167            let err = LlmConfig::from_env().unwrap_err();
168            assert!(
169                err.to_string().contains(drop),
170                "expected {drop} in error, got: {err}"
171            );
172        }
173    }
174
175    #[test]
176    fn from_env_without_protocol() {
177        let protocol: Option<Protocol> = None;
178        assert!(protocol.is_none());
179    }
180
181    #[test]
182    fn default_config() {
183        let config = LlmConfig::default();
184        assert!(config.protocol.is_none());
185        assert!(config.api_key.is_empty());
186        assert!(config.model.is_empty());
187        assert!(config.base_url.is_empty());
188    }
189
190    #[test]
191    fn resolve_base_url_returns_configured_url() {
192        let config = LlmConfig {
193            protocol: None,
194            api_key: "sk-test".to_string(),
195            model: "test".to_string(),
196            base_url: "https://custom.api.com/v1".to_string(),
197            options: HashMap::new(),
198        };
199        assert_eq!(config.resolve_base_url(), "https://custom.api.com/v1");
200    }
201
202    #[test]
203    fn debug_does_not_leak_api_key() {
204        let config = LlmConfig {
205            protocol: None,
206            api_key: "sk-super-secret-value".to_string(),
207            model: "test".to_string(),
208            base_url: "https://custom.api.com/v1".to_string(),
209            options: HashMap::new(),
210        };
211        let rendered = format!("{config:?}");
212        assert!(!rendered.contains("sk-super-secret-value"));
213        assert!(rendered.contains("<redacted>"));
214        // Non-secret fields stay visible for debugging.
215        assert!(rendered.contains("https://custom.api.com/v1"));
216        assert!(rendered.contains("test"));
217    }
218}