Skip to main content

systemprompt_models/validators/
ai.rs

1//! AI configuration validator.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use super::ValidationConfigProvider;
7use crate::ServicesConfig;
8use systemprompt_traits::validation_report::{
9    ValidationError, ValidationReport, ValidationWarning,
10};
11use systemprompt_traits::{ConfigProvider, DomainConfig, DomainConfigError};
12
13#[derive(Debug, Default)]
14pub struct AiConfigValidator {
15    config: Option<ServicesConfig>,
16}
17
18impl AiConfigValidator {
19    pub fn new() -> Self {
20        Self::default()
21    }
22}
23
24impl DomainConfig for AiConfigValidator {
25    fn domain_id(&self) -> &'static str {
26        "ai"
27    }
28
29    fn priority(&self) -> u32 {
30        50
31    }
32
33    fn dependencies(&self) -> &[&'static str] {
34        &["mcp"]
35    }
36
37    fn load(&mut self, config: &dyn ConfigProvider) -> Result<(), DomainConfigError> {
38        let provider = config
39            .as_any()
40            .downcast_ref::<ValidationConfigProvider>()
41            .ok_or_else(|| DomainConfigError::LoadError {
42                message: "Expected ValidationConfigProvider with merged ServicesConfig".into(),
43            })?;
44
45        self.config = Some(provider.services_config().clone());
46        Ok(())
47    }
48
49    fn validate(&self) -> Result<ValidationReport, DomainConfigError> {
50        let mut report = ValidationReport::new("ai");
51        let config = self
52            .config
53            .as_ref()
54            .ok_or_else(|| DomainConfigError::ValidationError {
55                message: "Not loaded".into(),
56            })?;
57        let ai_config = &config.ai;
58
59        Self::validate_default_provider(&mut report, ai_config);
60        Self::validate_enabled_providers(&mut report, ai_config);
61        Self::validate_mcp_config(&mut report, ai_config);
62
63        if ai_config.history.retention_days == 0 {
64            report.add_warning(ValidationWarning::new(
65                "ai.history.retention_days",
66                "History retention set to 0 days, history will not be retained",
67            ));
68        }
69
70        Ok(report)
71    }
72}
73
74impl AiConfigValidator {
75    fn validate_default_provider(report: &mut ValidationReport, ai_config: &crate::AiConfig) {
76        if ai_config.default_provider.is_empty() {
77            report.add_error(ValidationError::new(
78                "ai.default_provider",
79                "Default AI provider not configured",
80            ));
81        } else if !ai_config
82            .providers
83            .contains_key(&ai_config.default_provider)
84        {
85            report.add_error(
86                ValidationError::new(
87                    "ai.default_provider",
88                    format!(
89                        "Default provider '{}' not found in providers",
90                        ai_config.default_provider
91                    ),
92                )
93                .with_suggestion("Add the provider to ai.providers or change default_provider"),
94            );
95        }
96    }
97
98    fn validate_enabled_providers(report: &mut ValidationReport, ai_config: &crate::AiConfig) {
99        let enabled: Vec<_> = ai_config
100            .providers
101            .iter()
102            .filter(|(_, c)| c.enabled)
103            .collect();
104
105        if enabled.is_empty() {
106            report.add_error(
107                ValidationError::new("ai.providers", "No AI providers are enabled")
108                    .with_suggestion("Enable at least one provider in ai.providers"),
109            );
110        }
111
112        // Connectivity (credential, endpoint, model catalog) lives in the
113        // profile `providers` registry and is validated there; this policy
114        // layer only flags an enabled provider that names no default-model
115        // override.
116        for (name, cfg) in &enabled {
117            if cfg.default_model.is_empty() {
118                report.add_warning(ValidationWarning::new(
119                    format!("ai.providers.{}.default_model", name),
120                    format!(
121                        "Provider '{}' has no default_model override; the provider client default \
122                         will be used",
123                        name
124                    ),
125                ));
126            }
127        }
128    }
129
130    fn validate_mcp_config(report: &mut ValidationReport, ai_config: &crate::AiConfig) {
131        if ai_config.mcp.resilience.connect_timeout_ms == 0 {
132            report.add_error(ValidationError::new(
133                "ai.mcp.resilience.connect_timeout_ms",
134                "MCP connect timeout must be greater than 0",
135            ));
136        }
137        if ai_config.mcp.resilience.request_timeout_ms == 0 {
138            report.add_error(ValidationError::new(
139                "ai.mcp.resilience.request_timeout_ms",
140                "MCP execution timeout must be greater than 0",
141            ));
142        }
143    }
144}