Skip to main content

vtcode_core/config/
validator.rs

1//! Configuration validation utilities
2//!
3//! Validates VTCodeConfig against actual provider models and API keys.
4
5use anyhow::{Context, Result, bail};
6use hashbrown::HashMap;
7use serde_json::Value as JsonValue;
8use std::path::Path;
9
10use crate::config::api_keys::ApiKeySources;
11use crate::config::api_keys::get_api_key;
12use crate::config::constants::models::openai::RESPONSES_API_MODELS;
13use crate::config::loader::VTCodeConfig;
14use crate::config::models::{Provider, model_catalog_entry, supported_models_for_provider};
15use crate::utils::file_utils::read_file_with_context_sync;
16
17/// Loaded models database from docs/models.json
18#[derive(Debug, Clone)]
19enum ModelsDatabase {
20    Generated,
21    File {
22        providers: HashMap<String, ProviderModels>,
23    },
24}
25
26#[derive(Debug, Clone)]
27struct ProviderModels {
28    models: HashMap<String, ModelInfo>,
29}
30
31#[derive(Debug, Clone)]
32struct ModelInfo {
33    context_window: usize,
34}
35
36impl ModelsDatabase {
37    pub fn generated() -> Self {
38        Self::Generated
39    }
40
41    /// Load models database from docs/models.json
42    #[must_use = "models database load failure is silently ignored"]
43    pub fn from_file(path: &Path) -> Result<Self> {
44        let content = read_file_with_context_sync(path, "models database")?;
45
46        let json: JsonValue =
47            serde_json::from_str(&content).context("Failed to parse models database JSON")?;
48
49        let mut providers = HashMap::new();
50
51        if let Some(obj) = json.as_object() {
52            for (provider_id, provider_data) in obj {
53                if let Some(provider_obj) = provider_data.as_object() {
54                    let mut models = HashMap::new();
55
56                    if let Some(models_obj) = provider_obj.get("models").and_then(|v| v.as_object())
57                    {
58                        for (model_id, model_data) in models_obj {
59                            let context_window = model_data
60                                .get("context")
61                                .and_then(|v| v.as_u64())
62                                .unwrap_or(0)
63                                as usize;
64
65                            models.insert(model_id.clone(), ModelInfo { context_window });
66                        }
67                    }
68
69                    providers.insert(provider_id.clone(), ProviderModels { models });
70                }
71            }
72        }
73
74        Ok(Self::File { providers })
75    }
76
77    /// Check if model exists for provider
78    pub fn model_exists(&self, provider: &str, model: &str) -> bool {
79        match self {
80            Self::Generated => supported_models_for_provider(provider)
81                .map(|models| models.contains(&model))
82                .unwrap_or(false),
83            Self::File { providers } => providers
84                .get(provider)
85                .map(|p| p.models.contains_key(model))
86                .unwrap_or(false),
87        }
88    }
89
90    /// Get context window size for model
91    pub fn get_context_window(&self, provider: &str, model: &str) -> Option<usize> {
92        match self {
93            Self::Generated => model_catalog_entry(provider, model)
94                .map(|entry| entry.context_window)
95                .filter(|context_window| *context_window > 0),
96            Self::File { providers } => providers
97                .get(provider)
98                .and_then(|p| p.models.get(model))
99                .map(|m| m.context_window),
100        }
101    }
102}
103
104/// Configuration validator
105pub struct ConfigValidator {
106    models_db: ModelsDatabase,
107}
108
109impl ConfigValidator {
110    /// Create a validator backed by the build-generated model catalog.
111    pub fn generated() -> Self {
112        Self {
113            models_db: ModelsDatabase::generated(),
114        }
115    }
116
117    /// Create a new validator from models database file
118    pub fn new(models_db_path: &Path) -> Result<Self> {
119        Ok(Self {
120            models_db: ModelsDatabase::from_file(models_db_path)?,
121        })
122    }
123
124    /// Validate entire configuration
125    #[must_use = "validation errors go unnoticed"]
126    pub fn validate(&self, config: &VTCodeConfig) -> Result<ValidationResult> {
127        let mut result = ValidationResult::default();
128        let managed_auth_provider = configured_managed_auth_provider(config);
129        let custom_provider = config.custom_provider(&config.agent.provider);
130        let is_custom_provider = custom_provider.is_some();
131        let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
132
133        // Check if configured model exists
134        if !is_custom_provider
135            && !is_codex_provider
136            && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
137            && !self
138                .models_db
139                .model_exists(&config.agent.provider, &config.agent.default_model)
140        {
141            result.errors.push(format!(
142                "Model '{}' not found for provider '{}'. Check docs/models.json.",
143                config.agent.default_model, config.agent.provider
144            ));
145        }
146
147        // Check if API key is available
148        if !is_custom_provider
149            && !is_codex_provider
150            && managed_auth_provider.is_none()
151            && let Err(e) = get_api_key(&config.agent.provider, &ApiKeySources::default())
152        {
153            result.errors.push(format!(
154                "API key not found for provider '{}': {}. Set {} environment variable.",
155                config.agent.provider,
156                e,
157                config.agent.provider.to_uppercase()
158            ));
159        }
160
161        // Check context window configuration
162        if !is_custom_provider
163            && let Some(max_tokens) = self
164                .models_db
165                .get_context_window(&config.agent.provider, &config.agent.default_model)
166        {
167            let configured_context = config.context.max_context_tokens;
168            if configured_context > 0 && configured_context > max_tokens {
169                result.warnings.push(format!(
170                    "Configured context window ({} tokens) exceeds model limit ({} tokens) for {} on {}",
171                    configured_context, max_tokens,
172                    config.agent.default_model, config.agent.provider
173                ));
174            }
175        }
176
177        if let Some(message) = check_openai_hosted_shell_compat(
178            config,
179            &config.agent.default_model,
180            &config.agent.provider,
181        ) {
182            result.warnings.push(message);
183        }
184
185        // Check if workspace exists (if specified)
186        if let Ok(cwd) = std::env::current_dir() {
187            // Basic check only, actual workspace validation happens in StartupContext
188            if !cwd.exists() {
189                result
190                    .warnings
191                    .push("Current working directory does not exist".to_owned());
192            }
193        }
194
195        Ok(result)
196    }
197
198    /// Quick validation - only critical checks
199    pub fn quick_validate(&self, config: &VTCodeConfig) -> Result<()> {
200        let managed_auth_provider = configured_managed_auth_provider(config);
201        let is_custom_provider = config.custom_provider(&config.agent.provider).is_some();
202        let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
203
204        // Check model exists
205        if !is_custom_provider
206            && !is_codex_provider
207            && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
208            && !self
209                .models_db
210                .model_exists(&config.agent.provider, &config.agent.default_model)
211        {
212            bail!(
213                "Model '{}' not found for provider '{}'. Check docs/models.json.",
214                config.agent.default_model,
215                config.agent.provider
216            );
217        }
218
219        // Check API key
220        if !is_custom_provider && !is_codex_provider && managed_auth_provider.is_none() {
221            get_api_key(&config.agent.provider, &ApiKeySources::default()).with_context(|| {
222                format!(
223                    "API key not found for provider '{}'. Set {} environment variable.",
224                    config.agent.provider,
225                    config.agent.provider.to_uppercase()
226                )
227            })?;
228        }
229
230        Ok(())
231    }
232}
233
234fn configured_managed_auth_provider(config: &VTCodeConfig) -> Option<Provider> {
235    config
236        .agent
237        .provider
238        .parse::<Provider>()
239        .ok()
240        .filter(|provider| provider.uses_managed_auth())
241}
242
243fn is_managed_auth_model(provider: Option<Provider>, model: &str) -> bool {
244    matches!(provider, Some(Provider::Copilot)) && !model.trim().is_empty()
245}
246
247/// Results from configuration validation
248#[derive(Debug, Default, Clone)]
249pub struct ValidationResult {
250    pub errors: Vec<String>,
251    pub warnings: Vec<String>,
252}
253
254pub fn check_prompt_cache_retention_compat(
255    config: &VTCodeConfig,
256    model: &str,
257    provider: &str,
258) -> Option<String> {
259    if !provider.eq_ignore_ascii_case("openai") {
260        return None;
261    }
262
263    if let Some(ref retention) = config.prompt_cache.providers.openai.prompt_cache_retention {
264        if retention.trim().is_empty() {
265            return None;
266        }
267        if !RESPONSES_API_MODELS.contains(&model) {
268            return Some(format!(
269                "`prompt_cache_retention` is set but the selected model '{}' does not use the OpenAI Responses API. The setting will be ignored for this model. Run `vtcode models list --provider openai` to see supported Responses API models.",
270                model
271            ));
272        }
273    }
274
275    None
276}
277
278pub fn check_openai_hosted_shell_compat(
279    config: &VTCodeConfig,
280    model: &str,
281    provider: &str,
282) -> Option<String> {
283    if !provider.eq_ignore_ascii_case("openai") {
284        return None;
285    }
286
287    let hosted_shell = &config.provider.openai.hosted_shell;
288    if !hosted_shell.enabled {
289        return None;
290    }
291
292    if !RESPONSES_API_MODELS.contains(&model) {
293        return Some(format!(
294            "`provider.openai.hosted_shell.enabled` is set but the selected model '{}' does not use the OpenAI Responses API. VT Code will ignore hosted shell and keep the local shell tool for this model.",
295            model
296        ));
297    }
298
299    if !hosted_shell.has_valid_reference_target() {
300        return Some(
301            "`provider.openai.hosted_shell.environment = \"container_reference\"` requires a non-empty `provider.openai.hosted_shell.container_id`. VT Code will ignore hosted shell until a container ID is configured."
302                .to_string(),
303        );
304    }
305
306    if hosted_shell.uses_container_reference()
307        && (!hosted_shell.file_ids.is_empty()
308            || !hosted_shell.skills.is_empty()
309            || hosted_shell.network_policy.is_allowlist())
310    {
311        return Some(
312            "`provider.openai.hosted_shell.file_ids`, `provider.openai.hosted_shell.skills`, and allowlist `provider.openai.hosted_shell.network_policy` settings are only used with `container_auto`. VT Code will ignore those fields while `container_reference` is selected."
313                .to_string(),
314        );
315    }
316
317    if let Some(message) = hosted_shell.first_invalid_skill_message() {
318        return Some(format!(
319            "{} VT Code will ignore hosted shell until the mounted skills are corrected.",
320            message
321        ));
322    }
323
324    if let Some(message) = hosted_shell.first_invalid_network_policy_message() {
325        return Some(format!(
326            "{} VT Code will ignore hosted shell until the hosted shell network policy is corrected.",
327            message
328        ));
329    }
330
331    None
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::fs;
338    use tempfile::TempDir;
339
340    fn create_test_models_db() -> TempDir {
341        let dir = TempDir::new().unwrap();
342        let models_json = r#"{
343  "google": {
344    "id": "google",
345    "default_model": "gemini-3-flash-preview",
346    "models": {
347      "gemini-3-flash-preview": {
348        "context": 1048576
349      }
350    }
351  },
352  "openai": {
353    "id": "openai",
354    "default_model": "gpt-5",
355    "models": {
356      "gpt-5": {
357        "context": 128000
358      }
359    }
360  }
361}"#;
362        fs::write(dir.path().join("models.json"), models_json).unwrap();
363        dir
364    }
365
366    #[test]
367    fn loads_models_database() {
368        let dir = create_test_models_db();
369        let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
370
371        assert!(db.model_exists("google", "gemini-3-flash-preview"));
372        assert!(db.model_exists("openai", "gpt-5"));
373        assert!(!db.model_exists("google", "nonexistent"));
374    }
375
376    #[test]
377    fn gets_context_window() {
378        let dir = create_test_models_db();
379        let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
380
381        assert_eq!(
382            db.get_context_window("google", "gemini-3-flash-preview"),
383            Some(1048576)
384        );
385        assert_eq!(db.get_context_window("openai", "gpt-5"), Some(128000));
386        assert_eq!(db.get_context_window("google", "nonexistent"), None);
387    }
388
389    #[test]
390    fn validates_model_exists() {
391        let dir = create_test_models_db();
392        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
393        let mut config = VTCodeConfig::default();
394        config.agent.provider = "google".to_owned();
395        config.agent.default_model = "gemini-3-flash-preview".to_owned();
396
397        let result = validator.validate(&config).unwrap();
398        // Model exists, so there should be no model-specific lookup error.
399        assert!(!result.errors.iter().any(|e| {
400            e.contains("Model 'gemini-3-flash-preview' not found for provider 'google'")
401        }));
402    }
403
404    #[test]
405    fn custom_provider_skips_builtin_model_catalog_checks() {
406        let dir = create_test_models_db();
407        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
408        let mut config = VTCodeConfig::default();
409        config.agent.provider = "mycorp".to_owned();
410        config.agent.default_model = "totally-custom-model".to_owned();
411        config
412            .custom_providers
413            .push(vtcode_config::core::CustomProviderConfig {
414                name: "mycorp".to_string(),
415                display_name: "MyCorporateName".to_string(),
416                base_url: "https://llm.example/v1".to_string(),
417                api_key_env: "MYCORP_API_KEY".to_string(),
418                auth: None,
419                model: "totally-custom-model".to_string(),
420                models: Vec::new(),
421            });
422
423        let result = validator.validate(&config).unwrap();
424
425        assert!(result.errors.is_empty());
426    }
427
428    #[test]
429    fn codex_provider_skips_builtin_model_and_api_key_checks() {
430        let dir = create_test_models_db();
431        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
432        let mut config = VTCodeConfig::default();
433        config.agent.provider = "codex".to_owned();
434        config.agent.default_model = "managed-by-codex".to_owned();
435
436        let result = validator.validate(&config).unwrap();
437
438        assert!(result.errors.is_empty());
439    }
440
441    #[test]
442    fn copilot_managed_auth_model_accepts_live_raw_ids() {
443        assert!(is_managed_auth_model(
444            Some(Provider::Copilot),
445            "gpt-5.3-codex"
446        ));
447        assert!(!is_managed_auth_model(Some(Provider::Copilot), "   "));
448    }
449
450    #[test]
451    fn detects_missing_model() {
452        let dir = create_test_models_db();
453        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
454        let mut config = VTCodeConfig::default();
455        config.agent.provider = "google".to_owned();
456        config.agent.default_model = "nonexistent-model".to_owned();
457
458        let result = validator.validate(&config).unwrap();
459        assert!(result.errors.iter().any(|e| e.contains("not found")));
460    }
461
462    #[test]
463    fn detects_context_window_exceeded() {
464        let dir = create_test_models_db();
465        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
466        let mut config = VTCodeConfig::default();
467        config.agent.provider = "google".to_owned();
468        config.agent.default_model = "gemini-3-flash-preview".to_owned();
469        config.context.max_context_tokens = 2000000; // Exceeds 1048576 limit
470
471        let result = validator.validate(&config).unwrap();
472        assert!(result.warnings.iter().any(|w| w.contains("exceeds")));
473    }
474
475    #[test]
476    fn retention_warning_for_non_responses_model() {
477        let mut cfg = VTCodeConfig::default();
478        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
479        let msg = check_prompt_cache_retention_compat(&cfg, "gpt-oss-20b", "openai");
480        assert!(msg.is_some());
481    }
482
483    #[test]
484    fn retention_ok_for_responses_model() {
485        let mut cfg = VTCodeConfig::default();
486        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
487        let msg = check_prompt_cache_retention_compat(
488            &cfg,
489            crate::config::constants::models::openai::GPT_5,
490            "openai",
491        );
492        assert!(msg.is_none());
493    }
494
495    #[test]
496    fn retention_ok_for_gpt_alias() {
497        let mut cfg = VTCodeConfig::default();
498        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some("24h".to_owned());
499        let msg = check_prompt_cache_retention_compat(
500            &cfg,
501            crate::config::constants::models::openai::GPT,
502            "openai",
503        );
504        assert!(msg.is_none());
505    }
506
507    #[test]
508    fn hosted_shell_warning_for_non_responses_model() {
509        let mut cfg = VTCodeConfig::default();
510        cfg.provider.openai.hosted_shell.enabled = true;
511
512        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-oss-20b", "openai");
513        assert!(msg.is_some());
514    }
515
516    #[test]
517    fn hosted_shell_warning_for_missing_container_reference_id() {
518        let mut cfg = VTCodeConfig::default();
519        cfg.provider.openai.hosted_shell.enabled = true;
520        cfg.provider.openai.hosted_shell.environment =
521            crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
522
523        let msg = check_openai_hosted_shell_compat(
524            &cfg,
525            crate::config::constants::models::openai::GPT_5,
526            "openai",
527        );
528        assert!(msg.is_some());
529    }
530
531    #[test]
532    fn hosted_shell_warning_for_auto_only_fields_on_container_reference() {
533        let mut cfg = VTCodeConfig::default();
534        cfg.provider.openai.hosted_shell.enabled = true;
535        cfg.provider.openai.hosted_shell.environment =
536            crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
537        cfg.provider.openai.hosted_shell.container_id = Some("cntr_123".to_string());
538        cfg.provider.openai.hosted_shell.file_ids = vec!["file_123".to_string()];
539        cfg.provider.openai.hosted_shell.network_policy.policy_type =
540            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
541        cfg.provider
542            .openai
543            .hosted_shell
544            .network_policy
545            .allowed_domains = vec!["httpbin.org".to_string()];
546
547        let msg = check_openai_hosted_shell_compat(
548            &cfg,
549            crate::config::constants::models::openai::GPT_5,
550            "openai",
551        );
552        assert!(msg.is_some());
553    }
554
555    #[test]
556    fn hosted_shell_ok_for_valid_responses_config() {
557        let mut cfg = VTCodeConfig::default();
558        cfg.provider.openai.hosted_shell.enabled = true;
559
560        let msg = check_openai_hosted_shell_compat(
561            &cfg,
562            crate::config::constants::models::openai::GPT_5,
563            "openai",
564        );
565        assert!(msg.is_none());
566    }
567
568    #[test]
569    fn hosted_shell_ok_for_gpt_alias() {
570        let mut cfg = VTCodeConfig::default();
571        cfg.provider.openai.hosted_shell.enabled = true;
572
573        let msg = check_openai_hosted_shell_compat(
574            &cfg,
575            crate::config::constants::models::openai::GPT,
576            "openai",
577        );
578        assert!(msg.is_none());
579    }
580
581    #[test]
582    fn hosted_shell_warning_for_empty_skill_reference_id() {
583        let mut cfg = VTCodeConfig::default();
584        cfg.provider.openai.hosted_shell.enabled = true;
585        cfg.provider.openai.hosted_shell.skills =
586            vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
587                skill_id: "   ".to_string(),
588                version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
589            }];
590
591        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
592
593        assert!(
594            msg.as_deref()
595                .unwrap_or_default()
596                .contains("provider.openai.hosted_shell.skills[0].skill_id")
597        );
598    }
599
600    #[test]
601    fn hosted_shell_warning_for_empty_inline_bundle() {
602        let mut cfg = VTCodeConfig::default();
603        cfg.provider.openai.hosted_shell.enabled = true;
604        cfg.provider.openai.hosted_shell.skills =
605            vec![vtcode_config::core::OpenAIHostedSkill::Inline {
606                bundle_b64: " ".to_string(),
607                sha256: None,
608            }];
609
610        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
611
612        assert!(
613            msg.as_deref()
614                .unwrap_or_default()
615                .contains("provider.openai.hosted_shell.skills[0].bundle_b64")
616        );
617    }
618
619    #[test]
620    fn hosted_shell_warning_for_empty_allowlist_domains() {
621        let mut cfg = VTCodeConfig::default();
622        cfg.provider.openai.hosted_shell.enabled = true;
623        cfg.provider.openai.hosted_shell.network_policy.policy_type =
624            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
625
626        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
627
628        assert!(
629            msg.as_deref()
630                .unwrap_or_default()
631                .contains("network_policy.allowed_domains")
632        );
633    }
634
635    #[test]
636    fn hosted_shell_warning_for_secret_domain_outside_allowlist() {
637        let mut cfg = VTCodeConfig::default();
638        cfg.provider.openai.hosted_shell.enabled = true;
639        cfg.provider.openai.hosted_shell.network_policy.policy_type =
640            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
641        cfg.provider
642            .openai
643            .hosted_shell
644            .network_policy
645            .allowed_domains = vec!["pypi.org".to_string()];
646        cfg.provider
647            .openai
648            .hosted_shell
649            .network_policy
650            .domain_secrets = vec![vtcode_config::core::OpenAIHostedShellDomainSecret {
651            domain: "httpbin.org".to_string(),
652            name: "API_KEY".to_string(),
653            value: "secret".to_string(),
654        }];
655
656        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5", "openai");
657
658        assert!(
659            msg.as_deref()
660                .unwrap_or_default()
661                .contains("domain_secrets[0].domain")
662        );
663    }
664
665    #[test]
666    fn validate_surfaces_hosted_shell_warning() {
667        let dir = create_test_models_db();
668        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
669        let mut config = VTCodeConfig::default();
670        config.agent.provider = "openai".to_owned();
671        config.agent.default_model = "gpt-5".to_owned();
672        config.provider.openai.hosted_shell.enabled = true;
673        config.provider.openai.hosted_shell.skills =
674            vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
675                skill_id: "   ".to_string(),
676                version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
677            }];
678
679        let result = validator.validate(&config).unwrap();
680
681        assert!(result.warnings.iter().any(|warning| {
682            warning.contains("provider.openai.hosted_shell.skills[0].skill_id")
683        }));
684    }
685}