Skip to main content

liter_llm/client/
config_file.rs

1//! TOML-based configuration file loading.
2//!
3//! Load client configuration from `liter-llm.toml` files with auto-discovery
4//! (searches current directory and parents).
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::error::{LiterLlmError, Result};
13
14/// TOML file representation of client configuration.
15///
16/// All fields are optional — missing fields use defaults from [`ClientConfigBuilder`].
17/// Convert to a builder via [`FileConfig::into_builder`].
18///
19/// # Example `liter-llm.toml`
20///
21/// ```toml
22/// api_key = "sk-..."
23/// base_url = "https://api.openai.com/v1"
24/// timeout_secs = 120
25/// max_retries = 5
26///
27/// [cache]
28/// max_entries = 512
29/// ttl_seconds = 600
30/// backend = "memory"
31///
32/// [budget]
33/// global_limit = 50.0
34/// enforcement = "hard"
35///
36/// [[providers]]
37/// name = "my-provider"
38/// base_url = "https://my-llm.example.com/v1"
39/// model_prefixes = ["my-provider/"]
40/// ```
41#[derive(Debug, Clone, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct FileConfig {
44    pub api_key: Option<String>,
45    pub base_url: Option<String>,
46    pub model_hint: Option<String>,
47    pub timeout_secs: Option<u64>,
48    pub max_retries: Option<u32>,
49    pub extra_headers: Option<HashMap<String, String>>,
50    pub cache: Option<FileCacheConfig>,
51    pub budget: Option<FileBudgetConfig>,
52    pub cooldown_secs: Option<u64>,
53    pub rate_limit: Option<FileRateLimitConfig>,
54    pub health_check_secs: Option<u64>,
55    pub cost_tracking: Option<bool>,
56    pub tracing: Option<bool>,
57    pub providers: Option<Vec<FileProviderConfig>>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct FileCacheConfig {
63    pub max_entries: Option<usize>,
64    pub ttl_seconds: Option<u64>,
65    pub backend: Option<String>,
66    pub backend_config: Option<HashMap<String, String>>,
67}
68
69#[derive(Debug, Clone, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct FileBudgetConfig {
72    pub global_limit: Option<f64>,
73    pub model_limits: Option<HashMap<String, f64>>,
74    pub enforcement: Option<String>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct FileRateLimitConfig {
80    pub rpm: Option<u32>,
81    pub tpm: Option<u64>,
82    pub window_seconds: Option<u64>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct FileProviderConfig {
88    pub name: String,
89    pub base_url: String,
90    pub auth_header: Option<String>,
91    pub model_prefixes: Vec<String>,
92}
93
94impl FileConfig {
95    /// Load from a TOML file path.
96    pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self> {
97        let path = path.as_ref();
98        let content = std::fs::read_to_string(path).map_err(|e| LiterLlmError::InternalError {
99            message: format!("failed to read config file {}: {e}", path.display()),
100        })?;
101        Self::from_toml_str(&content)
102    }
103
104    /// Parse from a TOML string.
105    pub fn from_toml_str(s: &str) -> Result<Self> {
106        toml::from_str(s).map_err(|e| LiterLlmError::InternalError {
107            message: format!("invalid TOML config: {e}"),
108        })
109    }
110
111    /// Discover `liter-llm.toml` by walking from current directory to filesystem root.
112    ///
113    /// Returns `Ok(None)` if no config file is found.
114    pub fn discover() -> Result<Option<Self>> {
115        let mut current = std::env::current_dir().map_err(|e| LiterLlmError::InternalError {
116            message: format!("failed to get current directory: {e}"),
117        })?;
118        loop {
119            let config_path = current.join("liter-llm.toml");
120            if config_path.exists() {
121                return Ok(Some(Self::from_toml_file(config_path)?));
122            }
123            match current.parent() {
124                Some(parent) => current = parent.to_path_buf(),
125                None => break,
126            }
127        }
128        Ok(None)
129    }
130
131    /// Convert into a [`ClientConfigBuilder`](super::ClientConfigBuilder),
132    /// applying all fields that are set.
133    ///
134    /// Fields not present in the TOML file use the builder's defaults.
135    pub fn into_builder(self) -> super::ClientConfigBuilder {
136        let api_key = self.api_key.unwrap_or_default();
137        let mut builder = super::ClientConfigBuilder::new(api_key);
138
139        if let Some(url) = self.base_url {
140            builder = builder.base_url(url);
141        }
142        if let Some(t) = self.timeout_secs {
143            builder = builder.timeout(Duration::from_secs(t));
144        }
145        if let Some(r) = self.max_retries {
146            builder = builder.max_retries(r);
147        }
148
149        // Extra headers: push validated headers directly to the builder's
150        // internal config.  We cannot use `builder.header()` in a loop because
151        // it consumes `self` and on `Err` the builder is lost.  Since we are
152        // in the same crate, we can access `pub(crate)` fields.
153        #[cfg(any(feature = "native-http", feature = "wasm-http"))]
154        if let Some(headers) = self.extra_headers {
155            for (k, v) in headers {
156                // Validate header name and value before pushing.
157                if reqwest::header::HeaderName::from_bytes(k.as_bytes()).is_ok()
158                    && reqwest::header::HeaderValue::from_str(&v).is_ok()
159                {
160                    builder.config.extra_headers.push((k, v));
161                }
162            }
163        }
164
165        // Tower middleware configs
166        #[cfg(feature = "tower")]
167        {
168            // Cache
169            if let Some(cache) = self.cache {
170                use crate::tower::{CacheBackend, CacheConfig};
171                let backend = match cache.backend.as_deref() {
172                    Some("memory") | None => CacheBackend::Memory,
173                    #[cfg(feature = "opendal-cache")]
174                    Some(scheme) => CacheBackend::OpenDal {
175                        scheme: scheme.to_string(),
176                        config: cache.backend_config.unwrap_or_default(),
177                    },
178                    #[cfg(not(feature = "opendal-cache"))]
179                    Some(_) => CacheBackend::Memory,
180                };
181                builder = builder.cache(CacheConfig {
182                    max_entries: cache.max_entries.unwrap_or(256),
183                    ttl: Duration::from_secs(cache.ttl_seconds.unwrap_or(300)),
184                    backend,
185                });
186            }
187
188            // Budget
189            if let Some(budget) = self.budget {
190                use crate::tower::{BudgetConfig, Enforcement};
191                builder = builder.budget(BudgetConfig {
192                    global_limit: budget.global_limit,
193                    model_limits: budget.model_limits.unwrap_or_default(),
194                    enforcement: match budget.enforcement.as_deref() {
195                        Some("soft") => Enforcement::Soft,
196                        _ => Enforcement::Hard,
197                    },
198                });
199            }
200
201            // Cooldown
202            if let Some(secs) = self.cooldown_secs {
203                builder = builder.cooldown(Duration::from_secs(secs));
204            }
205
206            // Rate limit
207            if let Some(rl) = self.rate_limit {
208                use crate::tower::RateLimitConfig;
209                builder = builder.rate_limit(RateLimitConfig {
210                    rpm: rl.rpm,
211                    tpm: rl.tpm,
212                    window: Duration::from_secs(rl.window_seconds.unwrap_or(60)),
213                });
214            }
215
216            // Health check
217            if let Some(secs) = self.health_check_secs {
218                builder = builder.health_check(Duration::from_secs(secs));
219            }
220
221            // Cost tracking
222            if let Some(ct) = self.cost_tracking {
223                builder = builder.cost_tracking(ct);
224            }
225
226            // Tracing
227            if let Some(t) = self.tracing {
228                builder = builder.tracing(t);
229            }
230        }
231
232        builder
233    }
234
235    /// Get the custom provider configurations from this file config.
236    pub fn providers(&self) -> &[FileProviderConfig] {
237        self.providers.as_deref().unwrap_or(&[])
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn parse_minimal_config() {
247        let toml = r#"api_key = "sk-test""#;
248        let config = FileConfig::from_toml_str(toml).unwrap();
249        assert_eq!(config.api_key.as_deref(), Some("sk-test"));
250        assert!(config.base_url.is_none());
251        assert!(config.cache.is_none());
252    }
253
254    #[test]
255    fn parse_full_config() {
256        let toml = r#"
257api_key = "sk-test"
258base_url = "https://api.example.com/v1"
259model_hint = "openai"
260timeout_secs = 120
261max_retries = 5
262cooldown_secs = 30
263health_check_secs = 60
264cost_tracking = true
265tracing = true
266
267[cache]
268max_entries = 512
269ttl_seconds = 600
270backend = "memory"
271
272[budget]
273global_limit = 50.0
274enforcement = "hard"
275
276[budget.model_limits]
277"openai/gpt-4o" = 25.0
278
279[rate_limit]
280rpm = 60
281tpm = 100000
282
283[extra_headers]
284"X-Custom" = "value"
285
286[[providers]]
287name = "my-provider"
288base_url = "https://my-llm.example.com/v1"
289auth_header = "Authorization"
290model_prefixes = ["my-provider/"]
291"#;
292        let config = FileConfig::from_toml_str(toml).unwrap();
293        assert_eq!(config.timeout_secs, Some(120));
294        assert_eq!(config.max_retries, Some(5));
295        assert!(config.cache.is_some());
296        assert!(config.budget.is_some());
297        assert_eq!(config.providers().len(), 1);
298        assert_eq!(config.providers()[0].name, "my-provider");
299    }
300
301    #[test]
302    fn rejects_unknown_fields() {
303        let toml = r#"
304api_key = "sk-test"
305unknown_field = true
306"#;
307        assert!(FileConfig::from_toml_str(toml).is_err());
308    }
309
310    #[test]
311    fn into_builder_produces_valid_config() {
312        let toml = r#"
313api_key = "sk-test"
314timeout_secs = 30
315max_retries = 2
316"#;
317        let file_config = FileConfig::from_toml_str(toml).unwrap();
318        let config = file_config.into_builder().build();
319        assert_eq!(config.timeout, Duration::from_secs(30));
320        assert_eq!(config.max_retries, 2);
321    }
322
323    #[test]
324    fn empty_config_is_valid() {
325        let config = FileConfig::from_toml_str("").unwrap();
326        assert!(config.api_key.is_none());
327    }
328}