1use serde_json::Value;
4use std::collections::HashMap;
5
6use super::backend::Protocol;
7use super::error::LlmError;
8
9#[derive(Clone, Default)]
16pub struct LlmConfig {
17 pub protocol: Option<Protocol>,
22
23 pub api_key: String,
27
28 pub model: String,
30
31 pub base_url: String,
33
34 pub options: HashMap<String, Value>,
36}
37
38impl 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 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 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 let protocol = "anthropic".parse::<Protocol>().ok();
85 assert_eq!(protocol, Some(Protocol::Anthropic));
86 }
87
88 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 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 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 assert!(rendered.contains("https://custom.api.com/v1"));
216 assert!(rendered.contains("test"));
217 }
218}