Skip to main content

limit_llm/
config.rs

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/// Browser configuration section in config.toml
20#[derive(Debug, Deserialize, PartialEq, Clone)]
21pub struct BrowserConfigSection {
22    /// Enable browser tool (default: false)
23    #[serde(default)]
24    pub enabled: bool,
25    /// Path to agent-browser binary (default: "agent-browser" in PATH)
26    #[serde(default)]
27    pub binary_path: Option<PathBuf>,
28    /// Browser engine to use (default: "chrome")
29    #[serde(default = "default_browser_engine")]
30    pub engine: String,
31    /// Run in headless mode (default: true)
32    #[serde(default = "default_true")]
33    pub headless: bool,
34    /// Timeout for operations in milliseconds (default: 30000)
35    #[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/// Compaction settings for managing context window
64#[derive(Debug, Deserialize, PartialEq, Clone)]
65pub struct CompactionSettings {
66    /// Enable token-aware compaction (default: true)
67    #[serde(default = "default_compaction_enabled")]
68    pub enabled: bool,
69    /// Tokens to reserve for LLM response (default: 16384)
70    #[serde(default = "default_reserve_tokens")]
71    pub reserve_tokens: u32,
72    /// Recent tokens to keep when compacting (default: 20000)
73    #[serde(default = "default_keep_recent_tokens")]
74    pub keep_recent_tokens: u32,
75    /// Use LLM summarization instead of truncation (default: true)
76    #[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/// Cache settings for API-level prompt caching.
104#[derive(Debug, Deserialize, PartialEq, Clone)]
105pub struct CacheSettings {
106    /// Cache retention policy.
107    /// - "none": Disable caching
108    /// - "short": Default retention (5 min for Anthropic, in-memory for OpenAI)
109    /// - "long": Extended retention (1h for Anthropic, 24h for OpenAI)
110    #[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    /// Check if caching is enabled.
128    pub fn is_enabled(&self) -> bool {
129        self.retention != "none"
130    }
131
132    /// Check if long retention is enabled.
133    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    /// Maximum iterations for agent loop (0 = unlimited, default: 100)
149    #[serde(default = "default_max_iterations")]
150    pub max_iterations: usize,
151    /// Enable thinking/reasoning mode (Z.AI only, default: false)
152    #[serde(default)]
153    pub thinking_enabled: bool,
154    /// Preserve thinking between turns (Z.AI only, default: true)
155    /// Set to false for Preserved Thinking in multi-turn conversations
156    #[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 providers don't require API key, return placeholder
193            "local" | "ollama" | "lmstudio" | "vllm" => Some("local".to_string()),
194            _ => None,
195        }
196    }
197}
198
199impl Config {
200    pub fn validate(&self) -> Result<(), ConfigError> {
201        // Valid provider names (includes local LLM aliases)
202        let valid_providers = [
203            "anthropic",
204            "openai",
205            "zai",
206            "local",
207            "ollama",
208            "lmstudio",
209            "vllm",
210        ];
211
212        // Check provider field is valid
213        if !valid_providers.contains(&self.provider.as_str()) {
214            return Err(ConfigError::InvalidProvider(self.provider.clone()));
215        }
216
217        // Check provider config exists
218        if !self.providers.contains_key(&self.provider) {
219            return Err(ConfigError::MissingProvider(self.provider.clone()));
220        }
221
222        // Local providers don't require API key
223        let local_providers = ["local", "ollama", "lmstudio", "vllm"];
224        if local_providers.contains(&self.provider.as_str()) {
225            return Ok(());
226        }
227
228        // Check active provider has required fields
229        let provider_config = self.providers.get(&self.provider).unwrap();
230        if provider_config.api_key.is_none() {
231            // Check if env var exists
232            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                // For openai, also check ZAI_API_KEY as fallback
240                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        // Check for old format (detect by presence of 'api_key' at top level)
267        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        // Load config from actual path (tests loading with existing file)
323        let config = Config::load().unwrap();
324
325        // Should have loaded a valid config (provider and config entry exist)
326        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()); // default
366    }
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()); // default
408    }
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}