1use crate::error::ConfigError;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::{env, fs, io};
5
6#[derive(Debug, Deserialize, PartialEq, Clone)]
7pub struct Config {
8 pub provider: String,
9 pub providers: HashMap<String, ProviderConfig>,
10}
11
12#[derive(Debug, Deserialize, PartialEq, Clone)]
13pub struct ProviderConfig {
14 pub api_key: Option<String>,
15 pub model: String,
16 #[serde(default)]
17 pub base_url: Option<String>,
18 #[serde(default = "default_max_tokens")]
19 pub max_tokens: u32,
20 #[serde(default = "default_timeout")]
21 pub timeout: u64,
22 #[serde(default = "default_max_iterations")]
24 pub max_iterations: usize,
25 #[serde(default)]
27 pub thinking_enabled: bool,
28 #[serde(default = "default_clear_thinking")]
31 pub clear_thinking: bool,
32}
33
34fn default_model() -> String {
35 "claude-3-5-sonnet-20241022".to_string()
36}
37
38fn default_max_tokens() -> u32 {
39 4096
40}
41
42fn default_timeout() -> u64 {
43 60
44}
45
46fn default_max_iterations() -> usize {
47 100
48}
49
50fn default_clear_thinking() -> bool {
51 true
52}
53
54impl ProviderConfig {
55 pub fn api_key_or_env(&self, provider: &str) -> Option<String> {
56 if let Some(key) = &self.api_key {
57 return Some(key.clone());
58 }
59
60 match provider {
61 "anthropic" => env::var("ANTHROPIC_API_KEY").ok(),
62 "openai" => env::var("OPENAI_API_KEY")
63 .ok()
64 .or_else(|| env::var("ZAI_API_KEY").ok()),
65 "zai" => env::var("ZAI_API_KEY").ok(),
66 "local" | "ollama" | "lmstudio" | "vllm" => Some("local".to_string()),
68 _ => None,
69 }
70 }
71}
72
73impl Config {
74 pub fn validate(&self) -> Result<(), ConfigError> {
75 let valid_providers = [
77 "anthropic",
78 "openai",
79 "zai",
80 "local",
81 "ollama",
82 "lmstudio",
83 "vllm",
84 ];
85
86 if !valid_providers.contains(&self.provider.as_str()) {
88 return Err(ConfigError::InvalidProvider(self.provider.clone()));
89 }
90
91 if !self.providers.contains_key(&self.provider) {
93 return Err(ConfigError::MissingProvider(self.provider.clone()));
94 }
95
96 let local_providers = ["local", "ollama", "lmstudio", "vllm"];
98 if local_providers.contains(&self.provider.as_str()) {
99 return Ok(());
100 }
101
102 let provider_config = self.providers.get(&self.provider).unwrap();
104 if provider_config.api_key.is_none() {
105 let env_var = match self.provider.as_str() {
107 "anthropic" => "ANTHROPIC_API_KEY",
108 "openai" => "OPENAI_API_KEY",
109 "zai" => "ZAI_API_KEY",
110 _ => "API_KEY",
111 };
112 if env::var(env_var).is_err() {
113 let env_var_display = if self.provider == "openai" {
115 "OPENAI_API_KEY or ZAI_API_KEY"
116 } else {
117 env_var
118 };
119 return Err(ConfigError::MissingApiKey {
120 provider: self.provider.clone(),
121 env_var: env_var_display.to_string(),
122 });
123 }
124 }
125
126 Ok(())
127 }
128}
129
130impl Config {
131 pub fn load() -> Result<Self, io::Error> {
132 let config_path = config_path();
133
134 if !config_path.exists() {
135 return Ok(Config::default());
136 }
137
138 let config_content = fs::read_to_string(&config_path)?;
139
140 if config_content.contains("api_key") && !config_content.contains("[providers.") {
142 return Err(io::Error::new(
143 io::ErrorKind::InvalidData,
144 "Old config format detected. Please update to multi-provider format.\n\nNew format:\nprovider = \"anthropic\"\n\n[providers.anthropic]\nmodel = \"claude-3-5-sonnet-20241022\"\napi_key = \"...\" # optional, falls back to ANTHROPIC_API_KEY env var"
145 ));
146 }
147
148 let config: Config = toml::from_str(&config_content)
149 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
150
151 config
152 .validate()
153 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
154
155 Ok(config)
156 }
157}
158
159impl Default for Config {
160 fn default() -> Self {
161 let mut providers = HashMap::new();
162 providers.insert(
163 "anthropic".to_string(),
164 ProviderConfig {
165 api_key: None,
166 model: default_model(),
167 base_url: None,
168 max_tokens: default_max_tokens(),
169 timeout: default_timeout(),
170 max_iterations: default_max_iterations(),
171 thinking_enabled: false,
172 clear_thinking: true,
173 },
174 );
175 Config {
176 provider: "anthropic".to_string(),
177 providers,
178 }
179 }
180}
181
182fn config_path() -> std::path::PathBuf {
183 let home_dir = dirs::home_dir().expect("Failed to get home directory");
184 home_dir.join(".limit").join("config.toml")
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_load_from_actual_config() {
193 let config = Config::load().unwrap();
195
196 assert!(!config.provider.is_empty());
198 assert!(config.providers.contains_key(&config.provider));
199 }
200
201 #[test]
202 fn test_load_valid_config() {
203 let config_content = r#"
204provider = "anthropic"
205
206[providers.anthropic]
207api_key = "sk-ant-test123"
208model = "claude-3-5-sonnet-20241022"
209"#;
210
211 let config: Config = toml::from_str(config_content).unwrap();
212
213 assert_eq!(config.provider, "anthropic");
214 assert!(config.providers.contains_key("anthropic"));
215 let anthropic = config.providers.get("anthropic").unwrap();
216 assert_eq!(anthropic.api_key, Some("sk-ant-test123".to_string()));
217 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
218 }
219
220 #[test]
221 fn test_load_partial_config_uses_defaults() {
222 let config_content = r#"
223provider = "anthropic"
224
225[providers.anthropic]
226api_key = "sk-ant-partial"
227model = "custom-model"
228"#;
229
230 let config: Config = toml::from_str(config_content).unwrap();
231
232 assert_eq!(config.provider, "anthropic");
233 let anthropic = config.providers.get("anthropic").unwrap();
234 assert_eq!(anthropic.api_key, Some("sk-ant-partial".to_string()));
235 assert_eq!(anthropic.model, "custom-model");
236 assert!(anthropic.base_url.is_none()); }
238
239 #[test]
240 fn test_load_config_with_base_url() {
241 let config_content = r#"
242provider = "openai"
243
244[providers.openai]
245api_key = "sk-test123"
246model = "gpt-4"
247base_url = "https://api.z.ai/api/paas/v4/chat/completions"
248"#;
249
250 let config: Config = toml::from_str(config_content).unwrap();
251
252 assert_eq!(config.provider, "openai");
253 let openai = config.providers.get("openai").unwrap();
254 assert_eq!(openai.api_key, Some("sk-test123".to_string()));
255 assert_eq!(openai.model, "gpt-4");
256 assert_eq!(
257 openai.base_url,
258 Some("https://api.z.ai/api/paas/v4/chat/completions".to_string())
259 );
260 }
261
262 #[test]
263 fn test_load_config_without_base_url() {
264 let config_content = r#"
265provider = "anthropic"
266
267[providers.anthropic]
268api_key = "sk-ant-test456"
269model = "claude-3-5-sonnet-20241022"
270"#;
271
272 let config: Config = toml::from_str(config_content).unwrap();
273
274 assert_eq!(config.provider, "anthropic");
275 let anthropic = config.providers.get("anthropic").unwrap();
276 assert_eq!(anthropic.api_key, Some("sk-ant-test456".to_string()));
277 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
278 assert!(anthropic.base_url.is_none()); }
280
281 #[test]
282 fn test_default_config() {
283 let config = Config::default();
284
285 assert_eq!(config.provider, "anthropic");
286 assert!(config.providers.contains_key("anthropic"));
287 let anthropic = config.providers.get("anthropic").unwrap();
288 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
289 assert!(anthropic.api_key.is_none());
290 assert!(anthropic.base_url.is_none());
291 }
292
293 #[test]
294 fn test_old_format_detection() {
295 let config_content = r#"
296api_key = "sk-ant-test123"
297model = "claude-3-5-sonnet-20241022"
298"#;
299
300 let result: Result<Config, _> = toml::from_str(config_content);
301 assert!(result.is_err(), "Old format should fail to parse");
302 }
303
304 #[test]
305 fn test_api_key_or_env_from_config() {
306 let provider_config = ProviderConfig {
307 api_key: Some("sk-from-config".to_string()),
308 model: "claude-3-5-sonnet-20241022".to_string(),
309 base_url: None,
310 max_tokens: 4096,
311 timeout: 60,
312 max_iterations: 100,
313 thinking_enabled: false,
314 clear_thinking: true,
315 };
316
317 let key = provider_config.api_key_or_env("anthropic");
318 assert_eq!(key, Some("sk-from-config".to_string()));
319 }
320
321 #[test]
322 fn test_api_key_or_env_from_env() {
323 env::set_var("ANTHROPIC_API_KEY", "sk-from-env");
324 let provider_config = ProviderConfig {
325 api_key: None,
326 model: "claude-3-5-sonnet-20241022".to_string(),
327 base_url: None,
328 max_tokens: 4096,
329 timeout: 60,
330 max_iterations: 100,
331 thinking_enabled: false,
332 clear_thinking: true,
333 };
334
335 let key = provider_config.api_key_or_env("anthropic");
336 assert_eq!(key, Some("sk-from-env".to_string()));
337 env::remove_var("ANTHROPIC_API_KEY");
338 }
339
340 #[test]
341 fn test_openai_fallback_to_zai_api_key() {
342 env::set_var("ZAI_API_KEY", "sk-zai-key");
343 let provider_config = ProviderConfig {
344 api_key: None,
345 model: "gpt-4".to_string(),
346 base_url: None,
347 max_tokens: 4096,
348 timeout: 60,
349 max_iterations: 100,
350 thinking_enabled: false,
351 clear_thinking: true,
352 };
353
354 let key = provider_config.api_key_or_env("openai");
355 assert_eq!(key, Some("sk-zai-key".to_string()));
356 env::remove_var("ZAI_API_KEY");
357 }
358
359 #[test]
360 fn test_unknown_provider_no_env_var() {
361 let provider_config = ProviderConfig {
362 api_key: None,
363 model: "test-model".to_string(),
364 base_url: None,
365 max_tokens: 4096,
366 timeout: 60,
367 max_iterations: 100,
368 thinking_enabled: false,
369 clear_thinking: true,
370 };
371
372 let key = provider_config.api_key_or_env("unknown");
373 assert_eq!(key, None);
374 }
375
376 #[test]
377 fn test_zai_config_validation() {
378 env::set_var("ZAI_API_KEY", "test-zai-key");
379 let config_content = r#"
380provider = "zai"
381
382[providers.zai]
383model = "glm-4.7"
384"#;
385 let config: Config = toml::from_str(config_content).unwrap();
386 config.validate().unwrap();
387 env::remove_var("ZAI_API_KEY");
388 }
389 #[test]
390 fn test_zai_api_key_env_var() {
391 env::set_var("ZAI_API_KEY", "test-zai-key");
392 let provider_config = ProviderConfig {
393 api_key: None,
394 model: "glm-4.7".to_string(),
395 base_url: None,
396 max_tokens: 4096,
397 timeout: 60,
398 max_iterations: 100,
399 thinking_enabled: false,
400 clear_thinking: true,
401 };
402 let key = provider_config.api_key_or_env("zai");
403 assert_eq!(key, Some("test-zai-key".to_string()));
404 env::remove_var("ZAI_API_KEY");
405 }
406}