universal_bot_core/
config.rs

1//! Configuration management for Universal Bot
2//!
3//! This module provides configuration structures and builders for the bot,
4//! following the builder pattern for ergonomic configuration.
5
6use std::time::Duration;
7
8use anyhow::{Context as _, Result};
9use serde::{Deserialize, Serialize};
10use validator::{Validate, ValidationError};
11
12use crate::error::Error;
13
14/// Main bot configuration
15#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
16pub struct BotConfig {
17    /// AI model to use for generation
18    #[validate(custom(function = "validate_model"))]
19    pub model: String,
20
21    /// Temperature for generation (0.0 to 1.0)
22    #[validate(range(min = 0.0, max = 1.0))]
23    pub temperature: f32,
24
25    /// Maximum tokens to generate
26    #[validate(range(min = 1, max = 100_000))]
27    pub max_tokens: usize,
28
29    /// Request timeout
30    #[serde(with = "humantime_serde")]
31    pub timeout: Duration,
32
33    /// Number of retries for failed requests
34    #[validate(range(min = 0, max = 10))]
35    pub max_retries: u32,
36
37    /// Enable request logging
38    pub enable_logging: bool,
39
40    /// Enable cost tracking
41    pub enable_cost_tracking: bool,
42
43    /// Context configuration
44    pub context_config: ContextConfig,
45
46    /// Pipeline configuration
47    pub pipeline_config: PipelineConfig,
48
49    /// Plugin configuration
50    pub plugin_config: PluginConfig,
51}
52
53impl BotConfig {
54    /// Create a new configuration builder
55    #[must_use]
56    pub fn builder() -> BotConfigBuilder {
57        BotConfigBuilder::default()
58    }
59
60    /// Validate the configuration
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if validation fails.
65    pub fn validate(&self) -> Result<()> {
66        Validate::validate(self).map_err(|e| Error::Validation(e.to_string()))?;
67        Ok(())
68    }
69
70    /// Load configuration from environment variables
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if required environment variables are missing.
75    pub fn from_env() -> Result<Self> {
76        let model = std::env::var("DEFAULT_MODEL")
77            .unwrap_or_else(|_| "anthropic.claude-opus-4-1".to_string());
78
79        let temperature = std::env::var("TEMPERATURE")
80            .unwrap_or_else(|_| "0.1".to_string())
81            .parse()
82            .context("Invalid TEMPERATURE value")?;
83
84        let max_tokens = std::env::var("MAX_TOKENS")
85            .unwrap_or_else(|_| "2048".to_string())
86            .parse()
87            .context("Invalid MAX_TOKENS value")?;
88
89        Ok(Self {
90            model,
91            temperature,
92            max_tokens,
93            ..Default::default()
94        })
95    }
96}
97
98impl Default for BotConfig {
99    fn default() -> Self {
100        Self {
101            model: "anthropic.claude-opus-4-1".to_string(),
102            temperature: 0.1,
103            max_tokens: 2048,
104            timeout: Duration::from_secs(30),
105            max_retries: 3,
106            enable_logging: true,
107            enable_cost_tracking: true,
108            context_config: ContextConfig::default(),
109            pipeline_config: PipelineConfig::default(),
110            plugin_config: PluginConfig::default(),
111        }
112    }
113}
114
115/// Configuration for context management
116#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
117pub struct ContextConfig {
118    /// Maximum context size in tokens
119    #[validate(range(min = 100, max = 100_000))]
120    pub max_context_tokens: usize,
121
122    /// Context TTL
123    #[serde(with = "humantime_serde")]
124    pub context_ttl: Duration,
125
126    /// Enable context persistence
127    pub persist_context: bool,
128
129    /// Context storage backend
130    pub storage_backend: StorageBackend,
131}
132
133impl Default for ContextConfig {
134    fn default() -> Self {
135        Self {
136            max_context_tokens: 4096,
137            context_ttl: Duration::from_secs(3600),
138            persist_context: false,
139            storage_backend: StorageBackend::Memory,
140        }
141    }
142}
143
144/// Storage backend for context persistence
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum StorageBackend {
148    /// In-memory storage (default)
149    Memory,
150    /// Redis storage
151    Redis {
152        /// Redis connection URL
153        url: String,
154    },
155    /// `PostgreSQL` storage
156    Postgres {
157        /// `PostgreSQL` connection URL
158        url: String,
159    },
160    /// `SQLite` storage
161    Sqlite {
162        /// `SQLite` database file path
163        path: String,
164    },
165}
166
167/// Configuration for the message pipeline
168#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
169pub struct PipelineConfig {
170    /// Enable message sanitization
171    pub enable_sanitization: bool,
172
173    /// Enable message enrichment
174    pub enable_enrichment: bool,
175
176    /// Maximum pipeline processing time
177    #[serde(with = "humantime_serde")]
178    pub max_processing_time: Duration,
179
180    /// Pipeline stages to enable
181    pub enabled_stages: Vec<String>,
182}
183
184impl Default for PipelineConfig {
185    fn default() -> Self {
186        Self {
187            enable_sanitization: true,
188            enable_enrichment: true,
189            max_processing_time: Duration::from_secs(10),
190            enabled_stages: vec![
191                "sanitize".to_string(),
192                "enrich".to_string(),
193                "route".to_string(),
194                "process".to_string(),
195                "format".to_string(),
196            ],
197        }
198    }
199}
200
201/// Configuration for plugins
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PluginConfig {
204    /// Enable plugin system
205    pub enable_plugins: bool,
206
207    /// Plugin directories to scan
208    pub plugin_dirs: Vec<String>,
209
210    /// Plugins to auto-load
211    pub auto_load: Vec<String>,
212
213    /// Plugin timeout
214    #[serde(with = "humantime_serde")]
215    pub plugin_timeout: Duration,
216}
217
218impl Default for PluginConfig {
219    fn default() -> Self {
220        Self {
221            enable_plugins: true,
222            plugin_dirs: vec!["plugins".to_string()],
223            auto_load: Vec::new(),
224            plugin_timeout: Duration::from_secs(5),
225        }
226    }
227}
228
229/// Builder for `BotConfig`
230#[derive(Default)]
231pub struct BotConfigBuilder {
232    model: Option<String>,
233    temperature: Option<f32>,
234    max_tokens: Option<usize>,
235    timeout: Option<Duration>,
236    max_retries: Option<u32>,
237    enable_logging: Option<bool>,
238    enable_cost_tracking: Option<bool>,
239    context_config: Option<ContextConfig>,
240    pipeline_config: Option<PipelineConfig>,
241    plugin_config: Option<PluginConfig>,
242}
243
244impl BotConfigBuilder {
245    /// Set the AI model
246    #[must_use]
247    pub fn model(mut self, model: impl Into<String>) -> Self {
248        self.model = Some(model.into());
249        self
250    }
251
252    /// Set the temperature
253    #[must_use]
254    pub fn temperature(mut self, temperature: f32) -> Self {
255        self.temperature = Some(temperature);
256        self
257    }
258
259    /// Set the maximum tokens
260    #[must_use]
261    pub fn max_tokens(mut self, max_tokens: usize) -> Self {
262        self.max_tokens = Some(max_tokens);
263        self
264    }
265
266    /// Set the timeout
267    #[must_use]
268    pub fn timeout(mut self, timeout: Duration) -> Self {
269        self.timeout = Some(timeout);
270        self
271    }
272
273    /// Set the maximum retries
274    #[must_use]
275    pub fn max_retries(mut self, max_retries: u32) -> Self {
276        self.max_retries = Some(max_retries);
277        self
278    }
279
280    /// Enable or disable logging
281    #[must_use]
282    pub fn enable_logging(mut self, enable: bool) -> Self {
283        self.enable_logging = Some(enable);
284        self
285    }
286
287    /// Enable or disable cost tracking
288    #[must_use]
289    pub fn enable_cost_tracking(mut self, enable: bool) -> Self {
290        self.enable_cost_tracking = Some(enable);
291        self
292    }
293
294    /// Set the context configuration
295    #[must_use]
296    pub fn context_config(mut self, config: ContextConfig) -> Self {
297        self.context_config = Some(config);
298        self
299    }
300
301    /// Set the pipeline configuration
302    #[must_use]
303    pub fn pipeline_config(mut self, config: PipelineConfig) -> Self {
304        self.pipeline_config = Some(config);
305        self
306    }
307
308    /// Set the plugin configuration
309    #[must_use]
310    pub fn plugin_config(mut self, config: PluginConfig) -> Self {
311        self.plugin_config = Some(config);
312        self
313    }
314
315    /// Build the configuration
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if required fields are missing or validation fails.
320    pub fn build(self) -> Result<BotConfig> {
321        let config = BotConfig {
322            model: self.model.context("model is required")?,
323            temperature: self.temperature.unwrap_or(0.1),
324            max_tokens: self.max_tokens.unwrap_or(2048),
325            timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
326            max_retries: self.max_retries.unwrap_or(3),
327            enable_logging: self.enable_logging.unwrap_or(true),
328            enable_cost_tracking: self.enable_cost_tracking.unwrap_or(true),
329            context_config: self.context_config.unwrap_or_default(),
330            pipeline_config: self.pipeline_config.unwrap_or_default(),
331            plugin_config: self.plugin_config.unwrap_or_default(),
332        };
333
334        config.validate()?;
335        Ok(config)
336    }
337}
338
339/// Validate model name
340fn validate_model(model: &str) -> Result<(), ValidationError> {
341    const ALLOWED_MODELS: &[&str] = &[
342        "anthropic.claude-opus-4-1",
343        "us.anthropic.claude-opus-4-1-20250805-v1:0", // Opus 4.1 inference profile
344        "anthropic.claude-sonnet-4",
345        "anthropic.claude-haiku",
346        "meta.llama3-70b-instruct",
347        "meta.llama3-8b-instruct",
348        "amazon.titan-text-express",
349        "ai21.j2-ultra",
350        "ai21.j2-mid",
351    ];
352
353    if ALLOWED_MODELS.contains(&model) {
354        Ok(())
355    } else {
356        Err(ValidationError::new("invalid_model"))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_default_config() {
366        let config = BotConfig::default();
367        assert_eq!(config.model, "anthropic.claude-opus-4-1");
368        assert!((config.temperature - 0.1).abs() < f32::EPSILON);
369        assert_eq!(config.max_tokens, 2048);
370        assert!(config.validate().is_ok());
371    }
372
373    #[test]
374    fn test_config_builder() {
375        let config = BotConfig::builder()
376            .model("anthropic.claude-sonnet-4")
377            .temperature(0.5)
378            .max_tokens(4096)
379            .timeout(Duration::from_secs(60))
380            .build();
381
382        assert!(config.is_ok());
383        let config = config.unwrap();
384        assert_eq!(config.model, "anthropic.claude-sonnet-4");
385        assert!((config.temperature - 0.5).abs() < f32::EPSILON);
386        assert_eq!(config.max_tokens, 4096);
387    }
388
389    #[test]
390    fn test_invalid_model() {
391        let config = BotConfig::builder().model("invalid-model").build();
392
393        assert!(config.is_err());
394    }
395
396    #[test]
397    fn test_invalid_temperature() {
398        let config = BotConfig {
399            temperature: 1.5,
400            ..Default::default()
401        };
402        assert!(config.validate().is_err());
403
404        let config = BotConfig {
405            temperature: -0.1,
406            ..Default::default()
407        };
408        assert!(config.validate().is_err());
409    }
410
411    #[test]
412    fn test_from_env() {
413        std::env::set_var("DEFAULT_MODEL", "anthropic.claude-opus-4-1");
414        std::env::set_var("TEMPERATURE", "0.7");
415        std::env::set_var("MAX_TOKENS", "4096");
416
417        let config = BotConfig::from_env();
418        assert!(config.is_ok());
419
420        let config = config.unwrap();
421        assert!((config.temperature - 0.7).abs() < f32::EPSILON);
422        assert_eq!(config.max_tokens, 4096);
423    }
424
425    #[cfg(feature = "property-testing")]
426    mod property_tests {
427        use super::*;
428        use proptest::prelude::*;
429
430        proptest! {
431            #[test]
432            fn test_temperature_validation(temp in -10.0f32..10.0) {
433                let config = BotConfig {
434                    temperature: temp,
435                    ..Default::default()
436                };
437
438                let result = config.validate();
439                if (0.0..=1.0).contains(&temp) {
440                    prop_assert!(result.is_ok());
441                } else {
442                    prop_assert!(result.is_err());
443                }
444            }
445
446            #[test]
447            fn test_max_tokens_validation(tokens in 0usize..200_000) {
448                let config = BotConfig {
449                    max_tokens: tokens,
450                    ..Default::default()
451                };
452
453                let result = config.validate();
454                if tokens > 0 && tokens <= 100_000 {
455                    prop_assert!(result.is_ok());
456                } else {
457                    prop_assert!(result.is_err());
458                }
459            }
460        }
461    }
462}