ferrous_llm_core/
config.rs

1//! Configuration system for LLM providers.
2//!
3//! This module defines the configuration traits and utilities that providers
4//! use to manage their settings, validation, and initialization.
5
6use crate::error::ConfigError;
7use serde::{Deserialize, Serialize, Serializer};
8use std::fmt::Debug;
9use std::time::Duration;
10
11/// Trait for provider configuration types.
12///
13/// All provider configurations must implement this trait to provide
14/// consistent validation and construction patterns.
15pub trait ProviderConfig: Clone + Debug + Send + Sync {
16    /// The provider type that this configuration creates
17    type Provider;
18
19    /// Build a provider instance from this configuration.
20    ///
21    /// This method should validate the configuration and create a new
22    /// provider instance. It should fail if the configuration is invalid.
23    fn build(self) -> Result<Self::Provider, ConfigError>;
24
25    /// Validate the configuration without building a provider.
26    ///
27    /// This method should check that all required fields are present
28    /// and that all values are valid.
29    fn validate(&self) -> Result<(), ConfigError>;
30}
31
32/// A secure string type for sensitive configuration values like API keys.
33///
34/// This type ensures that sensitive values are not accidentally logged
35/// or displayed in debug output. It also redacts the value during serialization
36/// to avoid accidentally exposing secrets in configuration files or logs.
37#[derive(Clone, Deserialize)]
38pub struct SecretString(String);
39
40impl SecretString {
41    /// Create a new secret string
42    pub fn new(value: impl Into<String>) -> Self {
43        Self(value.into())
44    }
45
46    /// Get the secret value
47    ///
48    /// # Security Note
49    /// Be careful when using this method - the returned string
50    /// can be logged or displayed if not handled properly.
51    pub fn expose_secret(&self) -> &str {
52        &self.0
53    }
54
55    /// Check if the secret is empty
56    pub fn is_empty(&self) -> bool {
57        self.0.is_empty()
58    }
59
60    /// Get the length of the secret
61    pub fn len(&self) -> usize {
62        self.0.len()
63    }
64}
65
66impl From<String> for SecretString {
67    fn from(value: String) -> Self {
68        Self(value)
69    }
70}
71
72impl From<&str> for SecretString {
73    fn from(value: &str) -> Self {
74        Self(value.to_string())
75    }
76}
77
78impl Debug for SecretString {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str("[REDACTED]")
81    }
82}
83
84impl Serialize for SecretString {
85    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
86    where
87        S: Serializer,
88    {
89        serializer.serialize_str("[REDACTED]")
90    }
91}
92
93/// Common HTTP client configuration options.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HttpConfig {
96    /// Request timeout duration
97    pub timeout: Duration,
98
99    /// Maximum number of retry attempts
100    pub max_retries: u32,
101
102    /// Base delay for exponential backoff
103    pub retry_delay: Duration,
104
105    /// Maximum delay for exponential backoff
106    pub max_retry_delay: Duration,
107
108    /// User agent string for requests
109    pub user_agent: Option<String>,
110
111    /// Additional HTTP headers to include in requests
112    pub headers: std::collections::HashMap<String, String>,
113
114    /// Whether to enable compression
115    pub compression: bool,
116
117    /// Connection pool settings
118    pub pool: PoolConfig,
119}
120
121/// Connection pool configuration.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PoolConfig {
124    /// Maximum number of connections per host
125    pub max_connections_per_host: usize,
126
127    /// Maximum number of idle connections to keep alive
128    pub max_idle_connections: usize,
129
130    /// How long to keep idle connections alive
131    pub idle_timeout: Duration,
132
133    /// Connection timeout
134    pub connect_timeout: Duration,
135}
136
137/// Rate limiting configuration.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RateLimitConfig {
140    /// Maximum requests per second
141    pub requests_per_second: f64,
142
143    /// Burst capacity (maximum requests that can be made at once)
144    pub burst_capacity: u32,
145
146    /// Whether rate limiting is enabled
147    pub enabled: bool,
148}
149
150/// Retry configuration for failed requests.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct RetryConfig {
153    /// Maximum number of retry attempts
154    pub max_attempts: u32,
155
156    /// Base delay between retries
157    pub base_delay: Duration,
158
159    /// Maximum delay between retries
160    pub max_delay: Duration,
161
162    /// Multiplier for exponential backoff
163    pub backoff_multiplier: f64,
164
165    /// Whether to add jitter to retry delays
166    pub jitter: bool,
167
168    /// Whether retries are enabled
169    pub enabled: bool,
170}
171
172impl Default for HttpConfig {
173    fn default() -> Self {
174        Self {
175            timeout: Duration::from_secs(30),
176            max_retries: 3,
177            retry_delay: Duration::from_millis(100),
178            max_retry_delay: Duration::from_secs(60),
179            user_agent: Some("ferrous-llm-core/2.0".to_string()),
180            headers: std::collections::HashMap::new(),
181            compression: true,
182            pool: PoolConfig::default(),
183        }
184    }
185}
186
187impl Default for PoolConfig {
188    fn default() -> Self {
189        Self {
190            max_connections_per_host: 100,
191            max_idle_connections: 10,
192            idle_timeout: Duration::from_secs(90),
193            connect_timeout: Duration::from_secs(10),
194        }
195    }
196}
197
198impl Default for RateLimitConfig {
199    fn default() -> Self {
200        Self {
201            requests_per_second: 10.0,
202            burst_capacity: 20,
203            enabled: true,
204        }
205    }
206}
207
208impl Default for RetryConfig {
209    fn default() -> Self {
210        Self {
211            max_attempts: 3,
212            base_delay: Duration::from_millis(100),
213            max_delay: Duration::from_secs(60),
214            backoff_multiplier: 2.0,
215            jitter: true,
216            enabled: true,
217        }
218    }
219}
220
221/// Validation utilities for configuration values.
222pub mod validation {
223    use super::*;
224    use url::Url;
225
226    /// Validate that a string is not empty
227    pub fn validate_non_empty(value: &str, field_name: &str) -> Result<(), ConfigError> {
228        if value.trim().is_empty() {
229            Err(ConfigError::missing_field(field_name))
230        } else {
231            Ok(())
232        }
233    }
234
235    /// Validate that a secret string is not empty
236    pub fn validate_secret_non_empty(
237        value: &SecretString,
238        field_name: &str,
239    ) -> Result<(), ConfigError> {
240        if value.is_empty() {
241            Err(ConfigError::missing_field(field_name))
242        } else {
243            Ok(())
244        }
245    }
246
247    /// Validate that a URL is well-formed
248    pub fn validate_url(url: &str, field_name: &str) -> Result<Url, ConfigError> {
249        Url::parse(url)
250            .map_err(|_| ConfigError::invalid_value(field_name, format!("Invalid URL: {url}")))
251    }
252
253    /// Validate that a URL is HTTPS
254    pub fn validate_https_url(url: &Url, field_name: &str) -> Result<(), ConfigError> {
255        if url.scheme() != "https" {
256            Err(ConfigError::invalid_value(
257                field_name,
258                "URL must use HTTPS scheme",
259            ))
260        } else {
261            Ok(())
262        }
263    }
264
265    /// Validate that a numeric value is within a range
266    pub fn validate_range<T>(value: T, min: T, max: T, field_name: &str) -> Result<(), ConfigError>
267    where
268        T: PartialOrd + std::fmt::Display,
269    {
270        if value < min || value > max {
271            Err(ConfigError::invalid_value(
272                field_name,
273                format!("Value {value} must be between {min} and {max}"),
274            ))
275        } else {
276            Ok(())
277        }
278    }
279
280    /// Validate that a duration is positive
281    pub fn validate_positive_duration(
282        duration: Duration,
283        field_name: &str,
284    ) -> Result<(), ConfigError> {
285        if duration.is_zero() {
286            Err(ConfigError::invalid_value(
287                field_name,
288                "Duration must be positive",
289            ))
290        } else {
291            Ok(())
292        }
293    }
294
295    /// Validate an API key format (basic checks)
296    pub fn validate_api_key(api_key: &SecretString, field_name: &str) -> Result<(), ConfigError> {
297        let key = api_key.expose_secret();
298
299        // Basic validation - not empty and reasonable length
300        if key.is_empty() {
301            return Err(ConfigError::missing_field(field_name));
302        }
303
304        if key.len() < 10 {
305            return Err(ConfigError::invalid_value(
306                field_name,
307                "API key appears to be too short",
308            ));
309        }
310
311        // Check for common patterns that indicate a placeholder
312        let placeholder_patterns = ["your_api_key", "api_key_here", "replace_me", "xxx"];
313        for pattern in &placeholder_patterns {
314            if key.to_lowercase().contains(pattern) {
315                return Err(ConfigError::invalid_value(
316                    field_name,
317                    "API key appears to be a placeholder",
318                ));
319            }
320        }
321
322        Ok(())
323    }
324
325    /// Validate a model name
326    pub fn validate_model_name(model: &str, field_name: &str) -> Result<(), ConfigError> {
327        validate_non_empty(model, field_name)?;
328
329        // Basic validation - no whitespace, reasonable length
330        if model.contains(char::is_whitespace) {
331            return Err(ConfigError::invalid_value(
332                field_name,
333                "Model name cannot contain whitespace",
334            ));
335        }
336
337        if model.len() > 100 {
338            return Err(ConfigError::invalid_value(
339                field_name,
340                "Model name is too long",
341            ));
342        }
343
344        Ok(())
345    }
346}
347
348/// Builder pattern helper for configuration types.
349///
350/// This trait provides a consistent interface for building configurations
351/// with method chaining.
352pub trait ConfigBuilder<T> {
353    /// Build the final configuration
354    fn build(self) -> T;
355}
356
357/// Environment variable helper for loading configuration.
358pub mod env {
359    use super::*;
360    use std::env;
361
362    /// Load a required environment variable
363    pub fn required(key: &str) -> Result<String, ConfigError> {
364        env::var(key).map_err(|_| ConfigError::missing_field(key))
365    }
366
367    /// Load an optional environment variable
368    pub fn optional(key: &str) -> Option<String> {
369        env::var(key).ok()
370    }
371
372    /// Load a required environment variable as a SecretString
373    pub fn required_secret(key: &str) -> Result<SecretString, ConfigError> {
374        required(key).map(SecretString::new)
375    }
376
377    /// Load an optional environment variable as a SecretString
378    pub fn optional_secret(key: &str) -> Option<SecretString> {
379        optional(key).map(SecretString::new)
380    }
381
382    /// Load an environment variable with a default value
383    pub fn with_default(key: &str, default: &str) -> String {
384        optional(key).unwrap_or_else(|| default.to_string())
385    }
386
387    /// Parse an environment variable as a specific type
388    pub fn parse<T>(key: &str) -> Result<T, ConfigError>
389    where
390        T: std::str::FromStr,
391        T::Err: std::fmt::Display,
392    {
393        let value = required(key)?;
394        value
395            .parse()
396            .map_err(|e| ConfigError::invalid_value(key, format!("Failed to parse: {e}")))
397    }
398
399    /// Parse an optional environment variable as a specific type
400    pub fn parse_optional<T>(key: &str) -> Result<Option<T>, ConfigError>
401    where
402        T: std::str::FromStr,
403        T::Err: std::fmt::Display,
404    {
405        match optional(key) {
406            Some(value) => value
407                .parse()
408                .map(Some)
409                .map_err(|e| ConfigError::invalid_value(key, format!("Failed to parse: {e}"))),
410            None => Ok(None),
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_secret_string_debug() {
421        let secret = SecretString::new("super_secret_key");
422        let debug_output = format!("{:?}", secret);
423        assert_eq!(debug_output, "[REDACTED]");
424        assert!(!debug_output.contains("super_secret_key"));
425    }
426
427    #[test]
428    fn test_secret_string_expose() {
429        let secret = SecretString::new("my_secret");
430        assert_eq!(secret.expose_secret(), "my_secret");
431    }
432
433    #[test]
434    fn test_validation_non_empty() {
435        use validation::*;
436
437        assert!(validate_non_empty("valid", "test").is_ok());
438        assert!(validate_non_empty("", "test").is_err());
439        assert!(validate_non_empty("   ", "test").is_err());
440    }
441
442    #[test]
443    fn test_validation_url() {
444        use validation::*;
445
446        assert!(validate_url("https://api.example.com", "url").is_ok());
447        assert!(validate_url("not_a_url", "url").is_err());
448    }
449
450    #[test]
451    fn test_validation_range() {
452        use validation::*;
453
454        assert!(validate_range(5, 1, 10, "value").is_ok());
455        assert!(validate_range(0, 1, 10, "value").is_err());
456        assert!(validate_range(15, 1, 10, "value").is_err());
457    }
458}