1use crate::error::ConfigError;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::{env, fs, io};
6
7#[derive(Debug, Deserialize, PartialEq, Clone)]
8pub struct Config {
9 pub provider: String,
10 pub providers: HashMap<String, ProviderConfig>,
11 #[serde(default)]
12 pub browser: BrowserConfigSection,
13 #[serde(default)]
14 pub compaction: CompactionSettings,
15 #[serde(default)]
16 pub cache: CacheSettings,
17}
18
19#[derive(Debug, Deserialize, PartialEq, Clone)]
21pub struct BrowserConfigSection {
22 #[serde(default)]
24 pub enabled: bool,
25 #[serde(default)]
27 pub binary_path: Option<PathBuf>,
28 #[serde(default = "default_browser_engine")]
30 pub engine: String,
31 #[serde(default = "default_true")]
33 pub headless: bool,
34 #[serde(default = "default_browser_timeout")]
36 pub timeout_ms: u64,
37}
38
39impl Default for BrowserConfigSection {
40 fn default() -> Self {
41 Self {
42 enabled: false,
43 binary_path: None,
44 engine: default_browser_engine(),
45 headless: default_true(),
46 timeout_ms: default_browser_timeout(),
47 }
48 }
49}
50
51fn default_browser_engine() -> String {
52 "chrome".to_string()
53}
54
55fn default_true() -> bool {
56 true
57}
58
59fn default_browser_timeout() -> u64 {
60 30_000
61}
62
63#[derive(Debug, Deserialize, PartialEq, Clone)]
65pub struct CompactionSettings {
66 #[serde(default = "default_compaction_enabled")]
68 pub enabled: bool,
69 #[serde(default = "default_reserve_tokens")]
71 pub reserve_tokens: u32,
72 #[serde(default = "default_keep_recent_tokens")]
74 pub keep_recent_tokens: u32,
75 #[serde(default = "default_true")]
77 pub use_summarization: bool,
78}
79
80fn default_compaction_enabled() -> bool {
81 true
82}
83
84fn default_reserve_tokens() -> u32 {
85 16384
86}
87
88fn default_keep_recent_tokens() -> u32 {
89 20000
90}
91
92impl Default for CompactionSettings {
93 fn default() -> Self {
94 Self {
95 enabled: true,
96 reserve_tokens: 16384,
97 keep_recent_tokens: 20000,
98 use_summarization: true,
99 }
100 }
101}
102
103#[derive(Debug, Deserialize, PartialEq, Clone)]
105pub struct CacheSettings {
106 #[serde(default = "default_cache_retention")]
111 pub retention: String,
112}
113
114fn default_cache_retention() -> String {
115 "short".to_string()
116}
117
118impl Default for CacheSettings {
119 fn default() -> Self {
120 Self {
121 retention: default_cache_retention(),
122 }
123 }
124}
125
126impl CacheSettings {
127 pub fn is_enabled(&self) -> bool {
129 self.retention != "none"
130 }
131
132 pub fn is_long_retention(&self) -> bool {
134 self.retention == "long"
135 }
136}
137
138#[derive(Debug, Deserialize, PartialEq, Clone)]
139pub struct ProviderConfig {
140 pub api_key: Option<String>,
141 pub model: String,
142 #[serde(default)]
143 pub base_url: Option<String>,
144 #[serde(default = "default_max_tokens")]
145 pub max_tokens: u32,
146 #[serde(default = "default_timeout")]
147 pub timeout: u64,
148 #[serde(default = "default_max_iterations")]
150 pub max_iterations: usize,
151 #[serde(default)]
153 pub thinking_enabled: bool,
154 #[serde(default = "default_clear_thinking")]
157 pub clear_thinking: bool,
158}
159
160fn default_model() -> String {
161 "claude-3-5-sonnet-20241022".to_string()
162}
163
164fn default_max_tokens() -> u32 {
165 4096
166}
167
168fn default_timeout() -> u64 {
169 60
170}
171
172fn default_max_iterations() -> usize {
173 100
174}
175
176fn default_clear_thinking() -> bool {
177 true
178}
179
180impl ProviderConfig {
181 pub fn api_key_or_env(&self, provider: &str) -> Option<String> {
182 if let Some(key) = &self.api_key {
183 return Some(key.clone());
184 }
185
186 match provider {
187 "anthropic" => env::var("ANTHROPIC_API_KEY").ok(),
188 "openai" => env::var("OPENAI_API_KEY")
189 .ok()
190 .or_else(|| env::var("ZAI_API_KEY").ok()),
191 "zai" => env::var("ZAI_API_KEY").ok(),
192 "local" | "ollama" | "lmstudio" | "vllm" => Some("local".to_string()),
194 _ => None,
195 }
196 }
197}
198
199impl Config {
200 pub fn validate(&self) -> Result<(), ConfigError> {
201 let valid_providers = [
203 "anthropic",
204 "openai",
205 "zai",
206 "local",
207 "ollama",
208 "lmstudio",
209 "vllm",
210 ];
211
212 if !valid_providers.contains(&self.provider.as_str()) {
214 return Err(ConfigError::InvalidProvider(self.provider.clone()));
215 }
216
217 if !self.providers.contains_key(&self.provider) {
219 return Err(ConfigError::MissingProvider(self.provider.clone()));
220 }
221
222 let local_providers = ["local", "ollama", "lmstudio", "vllm"];
224 if local_providers.contains(&self.provider.as_str()) {
225 return Ok(());
226 }
227
228 let provider_config = self.providers.get(&self.provider).unwrap();
230 if provider_config.api_key.is_none() {
231 let env_var = match self.provider.as_str() {
233 "anthropic" => "ANTHROPIC_API_KEY",
234 "openai" => "OPENAI_API_KEY",
235 "zai" => "ZAI_API_KEY",
236 _ => "API_KEY",
237 };
238 if env::var(env_var).is_err() {
239 let env_var_display = if self.provider == "openai" {
241 "OPENAI_API_KEY or ZAI_API_KEY"
242 } else {
243 env_var
244 };
245 return Err(ConfigError::MissingApiKey {
246 provider: self.provider.clone(),
247 env_var: env_var_display.to_string(),
248 });
249 }
250 }
251
252 Ok(())
253 }
254}
255
256impl Config {
257 pub fn load() -> Result<Self, io::Error> {
258 let config_path = config_path();
259
260 if !config_path.exists() {
261 return Ok(Config::default());
262 }
263
264 let config_content = fs::read_to_string(&config_path)?;
265
266 if config_content.contains("api_key") && !config_content.contains("[providers.") {
268 return Err(io::Error::new(
269 io::ErrorKind::InvalidData,
270 "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"
271 ));
272 }
273
274 let config: Config = toml::from_str(&config_content)
275 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
276
277 config
278 .validate()
279 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
280
281 Ok(config)
282 }
283}
284
285impl Default for Config {
286 fn default() -> Self {
287 let mut providers = HashMap::new();
288 providers.insert(
289 "anthropic".to_string(),
290 ProviderConfig {
291 api_key: None,
292 model: default_model(),
293 base_url: None,
294 max_tokens: default_max_tokens(),
295 timeout: default_timeout(),
296 max_iterations: default_max_iterations(),
297 thinking_enabled: false,
298 clear_thinking: true,
299 },
300 );
301 Config {
302 provider: "anthropic".to_string(),
303 providers,
304 browser: BrowserConfigSection::default(),
305 compaction: CompactionSettings::default(),
306 cache: CacheSettings::default(),
307 }
308 }
309}
310
311fn config_path() -> std::path::PathBuf {
312 let home_dir = dirs::home_dir().expect("Failed to get home directory");
313 home_dir.join(".limit").join("config.toml")
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_load_from_actual_config() {
322 let config = Config::load().unwrap();
324
325 assert!(!config.provider.is_empty());
327 assert!(config.providers.contains_key(&config.provider));
328 }
329
330 #[test]
331 fn test_load_valid_config() {
332 let config_content = r#"
333provider = "anthropic"
334
335[providers.anthropic]
336api_key = "sk-ant-test123"
337model = "claude-3-5-sonnet-20241022"
338"#;
339
340 let config: Config = toml::from_str(config_content).unwrap();
341
342 assert_eq!(config.provider, "anthropic");
343 assert!(config.providers.contains_key("anthropic"));
344 let anthropic = config.providers.get("anthropic").unwrap();
345 assert_eq!(anthropic.api_key, Some("sk-ant-test123".to_string()));
346 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
347 }
348
349 #[test]
350 fn test_load_partial_config_uses_defaults() {
351 let config_content = r#"
352provider = "anthropic"
353
354[providers.anthropic]
355api_key = "sk-ant-partial"
356model = "custom-model"
357"#;
358
359 let config: Config = toml::from_str(config_content).unwrap();
360
361 assert_eq!(config.provider, "anthropic");
362 let anthropic = config.providers.get("anthropic").unwrap();
363 assert_eq!(anthropic.api_key, Some("sk-ant-partial".to_string()));
364 assert_eq!(anthropic.model, "custom-model");
365 assert!(anthropic.base_url.is_none()); }
367
368 #[test]
369 fn test_load_config_with_base_url() {
370 let config_content = r#"
371provider = "openai"
372
373[providers.openai]
374api_key = "sk-test123"
375model = "gpt-4"
376base_url = "https://api.z.ai/api/paas/v4/chat/completions"
377"#;
378
379 let config: Config = toml::from_str(config_content).unwrap();
380
381 assert_eq!(config.provider, "openai");
382 let openai = config.providers.get("openai").unwrap();
383 assert_eq!(openai.api_key, Some("sk-test123".to_string()));
384 assert_eq!(openai.model, "gpt-4");
385 assert_eq!(
386 openai.base_url,
387 Some("https://api.z.ai/api/paas/v4/chat/completions".to_string())
388 );
389 }
390
391 #[test]
392 fn test_load_config_without_base_url() {
393 let config_content = r#"
394provider = "anthropic"
395
396[providers.anthropic]
397api_key = "sk-ant-test456"
398model = "claude-3-5-sonnet-20241022"
399"#;
400
401 let config: Config = toml::from_str(config_content).unwrap();
402
403 assert_eq!(config.provider, "anthropic");
404 let anthropic = config.providers.get("anthropic").unwrap();
405 assert_eq!(anthropic.api_key, Some("sk-ant-test456".to_string()));
406 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
407 assert!(anthropic.base_url.is_none()); }
409
410 #[test]
411 fn test_default_config() {
412 let config = Config::default();
413
414 assert_eq!(config.provider, "anthropic");
415 assert!(config.providers.contains_key("anthropic"));
416 let anthropic = config.providers.get("anthropic").unwrap();
417 assert_eq!(anthropic.model, "claude-3-5-sonnet-20241022");
418 assert!(anthropic.api_key.is_none());
419 assert!(anthropic.base_url.is_none());
420 }
421
422 #[test]
423 fn test_old_format_detection() {
424 let config_content = r#"
425api_key = "sk-ant-test123"
426model = "claude-3-5-sonnet-20241022"
427"#;
428
429 let result: Result<Config, _> = toml::from_str(config_content);
430 assert!(result.is_err(), "Old format should fail to parse");
431 }
432
433 #[test]
434 fn test_api_key_or_env_from_config() {
435 let provider_config = ProviderConfig {
436 api_key: Some("sk-from-config".to_string()),
437 model: "claude-3-5-sonnet-20241022".to_string(),
438 base_url: None,
439 max_tokens: 4096,
440 timeout: 60,
441 max_iterations: 100,
442 thinking_enabled: false,
443 clear_thinking: true,
444 };
445
446 let key = provider_config.api_key_or_env("anthropic");
447 assert_eq!(key, Some("sk-from-config".to_string()));
448 }
449
450 #[test]
451 fn test_api_key_or_env_from_env() {
452 env::set_var("ANTHROPIC_API_KEY", "sk-from-env");
453 let provider_config = ProviderConfig {
454 api_key: None,
455 model: "claude-3-5-sonnet-20241022".to_string(),
456 base_url: None,
457 max_tokens: 4096,
458 timeout: 60,
459 max_iterations: 100,
460 thinking_enabled: false,
461 clear_thinking: true,
462 };
463
464 let key = provider_config.api_key_or_env("anthropic");
465 assert_eq!(key, Some("sk-from-env".to_string()));
466 env::remove_var("ANTHROPIC_API_KEY");
467 }
468
469 #[test]
470 fn test_openai_fallback_to_zai_api_key() {
471 env::set_var("ZAI_API_KEY", "sk-zai-key");
472 let provider_config = ProviderConfig {
473 api_key: None,
474 model: "gpt-4".to_string(),
475 base_url: None,
476 max_tokens: 4096,
477 timeout: 60,
478 max_iterations: 100,
479 thinking_enabled: false,
480 clear_thinking: true,
481 };
482
483 let key = provider_config.api_key_or_env("openai");
484 assert_eq!(key, Some("sk-zai-key".to_string()));
485 env::remove_var("ZAI_API_KEY");
486 }
487
488 #[test]
489 fn test_unknown_provider_no_env_var() {
490 let provider_config = ProviderConfig {
491 api_key: None,
492 model: "test-model".to_string(),
493 base_url: None,
494 max_tokens: 4096,
495 timeout: 60,
496 max_iterations: 100,
497 thinking_enabled: false,
498 clear_thinking: true,
499 };
500
501 let key = provider_config.api_key_or_env("unknown");
502 assert_eq!(key, None);
503 }
504
505 #[test]
506 fn test_zai_config_validation() {
507 let config_content = r#"
508provider = "zai"
509
510[providers.zai]
511model = "glm-4.7"
512api_key = "test-key"
513"#;
514 let config: Config = toml::from_str(config_content).unwrap();
515 config.validate().unwrap();
516 }
517 #[test]
518 fn test_zai_api_key_env_var() {
519 env::set_var("ZAI_API_KEY", "test-zai-key");
520 let provider_config = ProviderConfig {
521 api_key: None,
522 model: "glm-4.7".to_string(),
523 base_url: None,
524 max_tokens: 4096,
525 timeout: 60,
526 max_iterations: 100,
527 thinking_enabled: false,
528 clear_thinking: true,
529 };
530 let key = provider_config.api_key_or_env("zai");
531 assert_eq!(key, Some("test-zai-key".to_string()));
532 env::remove_var("ZAI_API_KEY");
533 }
534}