liter_llm/client/
config_file.rs1use std::collections::HashMap;
7use std::path::Path;
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::error::{LiterLlmError, Result};
13
14#[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 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 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 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 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 #[cfg(any(feature = "native-http", feature = "wasm-http"))]
154 if let Some(headers) = self.extra_headers {
155 for (k, v) in headers {
156 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 #[cfg(feature = "tower")]
167 {
168 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 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 if let Some(secs) = self.cooldown_secs {
203 builder = builder.cooldown(Duration::from_secs(secs));
204 }
205
206 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 if let Some(secs) = self.health_check_secs {
218 builder = builder.health_check(Duration::from_secs(secs));
219 }
220
221 if let Some(ct) = self.cost_tracking {
223 builder = builder.cost_tracking(ct);
224 }
225
226 if let Some(t) = self.tracing {
228 builder = builder.tracing(t);
229 }
230 }
231
232 builder
233 }
234
235 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}