Skip to main content

vtcode_core/config/
validation.rs

1/// Configuration validation module
2///
3/// Provides comprehensive validation of VTCodeConfig at startup to catch
4/// common configuration errors early and provide helpful error messages.
5use anyhow::{Result, bail};
6use std::path::Path;
7use vtcode_commons::MultiErrors;
8
9use crate::config::FullAutoConfig;
10use crate::config::loader::VTCodeConfig;
11use crate::config::models::{catalog_provider_keys, model_catalog_entry, supported_models_for_provider};
12use vtcode_config::core::CustomProviderConfig;
13
14/// Result of a configuration validation check
15#[derive(Debug, Clone)]
16pub struct ValidationResult {
17    pub is_valid: bool,
18    pub errors: MultiErrors<String>,
19    pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23    pub fn new() -> Self {
24        Self {
25            is_valid: true,
26            errors: MultiErrors::new(),
27            warnings: Vec::new(),
28        }
29    }
30
31    pub fn add_error(&mut self, error: String) {
32        self.is_valid = false;
33        self.errors.push(error);
34    }
35
36    pub fn add_warning(&mut self, warning: String) {
37        self.warnings.push(warning);
38    }
39}
40
41impl Default for ValidationResult {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47/// Validate that the configured model exists in the generated model catalog.
48pub fn validate_model_exists(provider: &str, model: &str) -> Result<()> {
49    if provider.eq_ignore_ascii_case("copilot") || provider.eq_ignore_ascii_case("merge-gateway") {
50        if model.trim().is_empty() {
51            bail!("Model must not be empty for provider '{provider}'");
52        }
53        return Ok(());
54    }
55
56    if let Some(models) = supported_models_for_provider(provider) {
57        if !models.contains(&model) {
58            // Check for known deprecated OpenAI models and suggest replacements.
59            if provider.eq_ignore_ascii_case("openai")
60                && let Some((replacement, reason)) =
61                    vtcode_config::constants::models::openai::deprecated_model_replacement(model)
62            {
63                bail!("{reason}. Update your config to use '{replacement}' or run /model to pick a current model.");
64            }
65            bail!("Model '{}' not found for provider '{}'. Available models: {}", model, provider, models.join(", "));
66        }
67        Ok(())
68    } else {
69        bail!(
70            "Provider '{}' not recognized. Available providers: {}",
71            provider,
72            catalog_provider_keys().join(", ")
73        );
74    }
75}
76
77/// Get context window size for a model from the catalog.
78fn catalog_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
79    Ok(model_catalog_entry(provider, model)
80        .map(|entry| entry.context_window)
81        .filter(|context_window| *context_window > 0))
82}
83
84/// Resolve the effective context window size for a model.
85pub fn effective_model_context_window(provider: &str, model: &str) -> Result<Option<usize>> {
86    if provider.eq_ignore_ascii_case("anthropic") {
87        return Ok(Some(crate::llm::providers::anthropic::capabilities::effective_context_size(model)));
88    }
89
90    catalog_model_context_window(provider, model)
91}
92
93fn custom_provider_for_model<'a>(
94    config: &'a VTCodeConfig,
95    provider: &str,
96    model: &str,
97) -> Option<&'a CustomProviderConfig> {
98    config
99        .custom_providers
100        .iter()
101        .find(|custom| custom.name.eq_ignore_ascii_case(provider))
102        .and_then(|custom| {
103            custom.effective_models().into_iter().find(|candidate| candidate == model)?;
104            Some(custom)
105        })
106}
107
108fn effective_model_context_window_for_config(
109    config: &VTCodeConfig,
110    provider: &str,
111    model: &str,
112) -> Result<Option<usize>> {
113    if let Some(custom) = custom_provider_for_model(config, provider, model) {
114        let profile = custom.resolved_profile(model);
115        if let Some(context_window) = profile.context_window {
116            return Ok(Some(context_window));
117        }
118
119        return match profile.api_format {
120            Some(vtcode_config::core::CustomProviderApiFormat::AnthropicMessages) => {
121                effective_model_context_window("anthropic", model)
122            }
123            _ => Ok(effective_model_context_window("openai", model)?.or(Some(128_000))),
124        };
125    }
126
127    effective_model_context_window(provider, model)
128}
129
130/// Validate full VTCodeConfig at startup
131pub fn validate_config(config: &VTCodeConfig, workspace: &Path) -> Result<ValidationResult> {
132    let mut result = ValidationResult::new();
133
134    // Validate agent model exists
135    validate_agent_model(config, &mut result);
136
137    // Validate provider is in whitelist (if configured)
138    if !config.providers_whitelist.is_empty()
139        && !config
140            .providers_whitelist
141            .iter()
142            .any(|w| w.eq_ignore_ascii_case(&config.agent.provider))
143    {
144        result.add_error(format!(
145            "Provider '{}' is not in providers_whitelist: {:?}",
146            config.agent.provider, config.providers_whitelist
147        ));
148    }
149
150    // Validate context window if specified
151    validate_context_window(config, &mut result);
152
153    // Validate checkpointing directory if enabled
154    if config.agent.checkpointing.enabled
155        && let Some(storage_dir) = &config.agent.checkpointing.storage_dir
156    {
157        validate_checkpointing_dir(storage_dir, workspace, &mut result);
158    }
159
160    // Validate automation configuration
161    if config.automation.full_auto.enabled {
162        validate_full_auto_config(&config.automation.full_auto, workspace, &mut result);
163    }
164
165    Ok(result)
166}
167
168fn validate_agent_model(config: &VTCodeConfig, result: &mut ValidationResult) {
169    let provider = &config.agent.provider;
170    let model = &config.agent.default_model;
171    if provider.eq_ignore_ascii_case("codex") {
172        return;
173    }
174
175    let validation = if let Some(custom) = config
176        .custom_providers
177        .iter()
178        .find(|custom| custom.name.eq_ignore_ascii_case(provider))
179    {
180        if custom.effective_models().iter().any(|candidate| candidate == model) {
181            Ok(())
182        } else {
183            Err(anyhow::anyhow!(
184                "Model '{}' not found for custom provider '{}'. Available models: {}",
185                model,
186                custom.display_name,
187                custom.effective_models().join(", ")
188            ))
189        }
190    } else {
191        validate_model_exists(provider, model)
192    };
193
194    match validation {
195        Ok(_) => {
196            // Also check context window
197            if let Ok(Some(context_size)) = effective_model_context_window_for_config(config, provider, model) {
198                let display_size = if context_size >= 1_000_000 {
199                    format!("{}M", context_size / 1_000_000)
200                } else if context_size >= 1_000 {
201                    format!("{}K", context_size / 1_000)
202                } else {
203                    context_size.to_string()
204                };
205                tracing::debug!("Agent model '{}' context window: {}", model, display_size);
206            }
207        }
208        Err(e) => {
209            result.add_error(format!("Agent model configuration invalid: {e}"));
210        }
211    }
212}
213
214fn validate_context_window(config: &VTCodeConfig, result: &mut ValidationResult) {
215    if config.agent.provider.eq_ignore_ascii_case("codex") {
216        return;
217    }
218
219    let context_window = config.context.max_context_tokens;
220    if context_window > 0
221        && let Ok(Some(model_context)) =
222            effective_model_context_window_for_config(config, &config.agent.provider, &config.agent.default_model)
223        && context_window > model_context
224    {
225        result.add_warning(format!(
226            "Configured session context safety budget {context_window} exceeds provider capacity {model_context}. \
227             The provider capacity remains the hard upper bound for compaction and request validation."
228        ));
229    }
230}
231
232fn validate_checkpointing_dir(storage_dir: &str, workspace: &Path, result: &mut ValidationResult) {
233    let path = if Path::new(storage_dir).is_absolute() {
234        std::path::PathBuf::from(storage_dir)
235    } else {
236        workspace.join(storage_dir)
237    };
238
239    // Check if parent directory exists
240    if let Some(parent) = path.parent()
241        && !parent.exists()
242    {
243        result.add_warning(format!(
244            "Checkpointing storage directory parent '{}' does not exist. \
245             It will be created when checkpointing is first used.",
246            parent.display()
247        ));
248    }
249}
250
251fn validate_full_auto_config(full_auto_cfg: &FullAutoConfig, workspace: &Path, result: &mut ValidationResult) {
252    if full_auto_cfg.require_profile_ack {
253        if let Some(profile_path) = &full_auto_cfg.profile_path {
254            let resolved = if Path::new(profile_path).is_absolute() {
255                std::path::PathBuf::from(profile_path)
256            } else {
257                workspace.join(profile_path)
258            };
259
260            if !resolved.exists() {
261                result.add_error(format!(
262                    "Full-auto profile '{}' required but not found. \
263                     Create the acknowledgement file before using --full-auto.",
264                    resolved.display()
265                ));
266            }
267        } else {
268            result.add_error("Full-auto profile_path is required when require_profile_ack = true".to_owned());
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn generated_catalog_contains_providers() {
279        let providers = catalog_provider_keys();
280        assert!(!providers.is_empty(), "Should expose generated providers");
281        assert!(
282            providers.contains(&"gemini") || providers.contains(&"openai"),
283            "Should have at least one major provider"
284        );
285    }
286
287    #[test]
288    fn validates_known_model() {
289        let result = validate_model_exists("google", "gemini-3.7-flash");
290        assert!(result.is_ok(), "Should validate gemini-3.7-flash for google provider");
291    }
292
293    #[test]
294    fn rejects_unknown_model() {
295        let result = validate_model_exists("google", "model-does-not-exist");
296        assert!(result.is_err(), "Should reject unknown model");
297    }
298
299    #[test]
300    fn accepts_live_copilot_model_id() {
301        let result = validate_model_exists("copilot", "gpt-5-codex");
302        assert!(result.is_ok(), "Should accept live Copilot model ids");
303    }
304
305    #[test]
306    fn accepts_arbitrary_merge_gateway_model_id() {
307        let result = validate_model_exists("merge-gateway", "deepseek/deepseek-v4-pro");
308        assert!(result.is_ok(), "Merge Gateway should accept valid explicit route ids");
309    }
310
311    #[test]
312    fn validate_config_skips_codex_model_catalog_checks() {
313        let mut config = VTCodeConfig::default();
314        config.agent.provider = "codex".to_string();
315        config.agent.default_model = "upstream-managed-model".to_string();
316
317        let result = validate_config(&config, Path::new(".")).expect("config validation should run");
318
319        assert!(result.errors.is_empty());
320    }
321
322    #[test]
323    fn rejects_unknown_provider() {
324        let result = validate_model_exists("provider-does-not-exist", "some-model");
325        assert!(result.is_err(), "Should reject unknown provider");
326    }
327
328    #[test]
329    fn gets_context_window() {
330        let result = effective_model_context_window("google", "gemini-3.7-flash");
331        assert!(result.is_ok(), "Should get context window");
332
333        let context = result.unwrap();
334        assert!(context.is_some() && context.unwrap() > 0, "Should have positive context window");
335    }
336
337    #[test]
338    fn anthropic_46_uses_effective_context_window() {
339        let result = effective_model_context_window("anthropic", "claude-sonnet-5");
340        assert_eq!(result.unwrap(), Some(1_000_000));
341    }
342
343    #[test]
344    fn validation_result_collects_errors() {
345        let mut result = ValidationResult::new();
346        assert!(result.is_valid);
347
348        result.add_error("Error 1".to_owned());
349        assert!(!result.is_valid);
350
351        result.add_error("Error 2".to_owned());
352        assert_eq!(result.errors.len(), 2);
353    }
354
355    #[test]
356    fn validation_result_collects_warnings() {
357        let mut result = ValidationResult::new();
358        result.add_warning("Warning 1".to_owned());
359        assert_eq!(result.warnings.len(), 1);
360        assert!(result.is_valid); // Warnings don't invalidate
361    }
362
363    #[test]
364    fn whitelist_allows_configured_provider() {
365        let mut config = VTCodeConfig::default();
366        config.agent.provider = "openai".to_string();
367        config.agent.default_model = "gpt-5.6-sol".to_string();
368        config.providers_whitelist = vec!["openai".to_string()];
369
370        let result = validate_config(&config, Path::new(".")).expect("validation should run");
371        assert!(result.errors.is_empty(), "openai should pass when whitelisted: {:?}", result.errors);
372    }
373
374    #[test]
375    fn whitelist_blocks_non_whitelisted_provider() {
376        let mut config = VTCodeConfig::default();
377        config.agent.provider = "openai".to_string();
378        config.agent.default_model = "gpt-5.6-sol".to_string();
379        config.providers_whitelist = vec!["anthropic".to_string()];
380
381        let result = validate_config(&config, Path::new(".")).expect("validation should run");
382        assert!(
383            result.errors.iter().any(|e| e.contains("not in providers_whitelist")),
384            "expected whitelist error, got: {:?}",
385            result.errors
386        );
387    }
388
389    #[test]
390    fn empty_whitelist_allows_all_providers() {
391        let mut config = VTCodeConfig::default();
392        config.agent.provider = "openai".to_string();
393        config.agent.default_model = "gpt-5.6-sol".to_string();
394        config.providers_whitelist.clear();
395
396        let result = validate_config(&config, Path::new(".")).expect("validation should run");
397        assert!(result.errors.is_empty(), "empty whitelist should allow all: {:?}", result.errors);
398    }
399
400    #[test]
401    fn deprecated_openai_model_produces_actionable_error() {
402        let mut config = VTCodeConfig::default();
403        config.agent.provider = "openai".to_string();
404        config.agent.default_model = "gpt-5".to_string();
405        config.providers_whitelist.clear();
406
407        let result = validate_config(&config, Path::new(".")).expect("validation should run");
408        assert!(
409            result.errors.iter().any(|e| e.contains("gpt-5.6-sol")),
410            "expected actionable deprecation replacement, got: {:?}",
411            result.errors
412        );
413    }
414
415    #[test]
416    fn non_openai_provider_with_deprecated_id_gets_generic_error() {
417        // A non-OpenAI provider using a deprecated OpenAI ID should NOT get
418        // OpenAI-specific migration advice.
419        let mut config = VTCodeConfig::default();
420        config.agent.provider = "custom-provider".to_string();
421        config.agent.default_model = "gpt-5".to_string();
422        config.providers_whitelist.clear();
423        // Make the custom provider advertise this model so it passes the
424        // model-exists check for custom providers and reaches validation.
425        config.custom_providers.push(CustomProviderConfig {
426            name: "custom-provider".to_string(),
427            models: vec!["gpt-5".to_string()],
428            ..Default::default()
429        });
430
431        let result = validate_config(&config, Path::new(".")).expect("validation should run");
432        assert!(
433            !result.errors.iter().any(|e| e.contains("gpt-5.6-sol")),
434            "non-OpenAI provider should not get OpenAI migration advice: {:?}",
435            result.errors
436        );
437    }
438}