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_with_mode;
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;
16use vtcode_config::PromptCacheRetention;
17
18/// Loaded models database from docs/models.json
19#[derive(Debug, Clone)]
20enum ModelsDatabase {
21    Generated,
22    File { providers: HashMap<String, ProviderModels> },
23}
24
25#[derive(Debug, Clone)]
26struct ProviderModels {
27    models: HashMap<String, ModelInfo>,
28}
29
30#[derive(Debug, Clone)]
31struct ModelInfo {
32    context_window: usize,
33}
34
35impl ModelsDatabase {
36    pub fn generated() -> Self {
37        Self::Generated
38    }
39
40    /// Load models database from docs/models.json
41    #[must_use = "models database load failure is silently ignored"]
42    pub fn from_file(path: &Path) -> Result<Self> {
43        let content = read_file_with_context_sync(path, "models database")?;
44
45        let json: JsonValue = serde_json::from_str(&content).context("Failed to parse models database JSON")?;
46
47        let mut providers = HashMap::new();
48
49        if let Some(obj) = json.as_object() {
50            for (provider_id, provider_data) in obj {
51                if let Some(provider_obj) = provider_data.as_object() {
52                    let mut models = HashMap::new();
53
54                    if let Some(models_obj) = provider_obj.get("models").and_then(|v| v.as_object()) {
55                        for (model_id, model_data) in models_obj {
56                            let context_window =
57                                model_data.get("context").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
58
59                            models.insert(model_id.clone(), ModelInfo { context_window });
60                        }
61                    }
62
63                    providers.insert(provider_id.clone(), ProviderModels { models });
64                }
65            }
66        }
67
68        Ok(Self::File { providers })
69    }
70
71    /// Check if model exists for provider
72    pub fn model_exists(&self, provider: &str, model: &str) -> bool {
73        match self {
74            Self::Generated => supported_models_for_provider(provider)
75                .map(|models| models.contains(&model))
76                .unwrap_or(false),
77            Self::File { providers } => providers.get(provider).map(|p| p.models.contains_key(model)).unwrap_or(false),
78        }
79    }
80
81    /// Get context window size for model
82    pub fn get_context_window(&self, provider: &str, model: &str) -> Option<usize> {
83        match self {
84            Self::Generated => model_catalog_entry(provider, model)
85                .map(|entry| entry.context_window)
86                .filter(|context_window| *context_window > 0),
87            Self::File { providers } => providers
88                .get(provider)
89                .and_then(|p| p.models.get(model))
90                .map(|m| m.context_window),
91        }
92    }
93}
94
95/// Configuration validator
96pub struct ConfigValidator {
97    models_db: ModelsDatabase,
98}
99
100impl ConfigValidator {
101    /// Create a validator backed by the build-generated model catalog.
102    pub fn generated() -> Self {
103        Self { models_db: ModelsDatabase::generated() }
104    }
105
106    /// Create a new validator from models database file
107    pub fn new(models_db_path: &Path) -> Result<Self> {
108        Ok(Self {
109            models_db: ModelsDatabase::from_file(models_db_path)?,
110        })
111    }
112
113    /// Validate entire configuration
114    #[must_use = "validation errors go unnoticed"]
115    pub fn validate(&self, config: &VTCodeConfig) -> Result<ValidationResult> {
116        let mut result = ValidationResult::default();
117        let managed_auth_provider = configured_managed_auth_provider(config);
118        let custom_provider = config.custom_provider(&config.agent.provider);
119        let is_custom_provider = custom_provider.is_some();
120        let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
121        // Built-in OpenAI supports multi-source auth (ChatGPT OAuth, Codex
122        // fallback, API key). The static API-key check below is only meaningful
123        // for providers that require an API key as their sole credential.
124        let is_openai_provider = config.agent.provider.eq_ignore_ascii_case("openai");
125
126        // Check if configured model exists
127        if let Some(custom) = custom_provider {
128            if !custom
129                .effective_models()
130                .iter()
131                .any(|model| model == &config.agent.default_model)
132            {
133                result.errors.push(format!(
134                    "Model '{}' not found for custom provider '{}'.",
135                    config.agent.default_model, custom.display_name
136                ));
137            }
138        } else if !is_codex_provider
139            && !is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
140            && !self.models_db.model_exists(&config.agent.provider, &config.agent.default_model)
141        {
142            // Check for known deprecated OpenAI models and suggest replacements.
143            if config.agent.provider.eq_ignore_ascii_case("openai")
144                && let Some((replacement, reason)) =
145                    vtcode_config::constants::models::openai::deprecated_model_replacement(&config.agent.default_model)
146            {
147                result.errors.push(format!(
148                    "{reason}. Update your config to use '{replacement}' or run /model to pick a current model."
149                ));
150            } else {
151                result.errors.push(format!(
152                    "Model '{}' not found for provider '{}'. Check docs/models.json.",
153                    config.agent.default_model, config.agent.provider
154                ));
155            }
156        }
157
158        // Check if API key is available — skip for built-in OpenAI which
159        // supports ChatGPT OAuth and Codex fallback in addition to API keys.
160        // The runtime auth resolver validates OpenAI's multi-source credentials.
161        if !is_custom_provider
162            && !is_codex_provider
163            && !is_openai_provider
164            && managed_auth_provider.is_none()
165            && let Err(e) = get_api_key_with_mode(
166                &config.agent.provider,
167                &ApiKeySources::default(),
168                config.agent.credential_storage_mode,
169            )
170        {
171            result.errors.push(format!(
172                "API key not found for provider '{}': {}. Set {} environment variable.",
173                config.agent.provider,
174                e,
175                config.agent.provider.to_uppercase()
176            ));
177        }
178
179        // Check context window configuration
180        let model_context_window = if let Some(custom) = custom_provider {
181            custom
182                .effective_models()
183                .iter()
184                .any(|model| model == &config.agent.default_model)
185                .then(|| custom.resolved_profile(&config.agent.default_model).context_window)
186                .flatten()
187        } else {
188            self.models_db
189                .get_context_window(&config.agent.provider, &config.agent.default_model)
190        };
191        if let Some(max_tokens) = model_context_window {
192            let configured_context = config.context.max_context_tokens;
193            if configured_context > 0 && configured_context > max_tokens {
194                result.warnings.push(format!(
195                    "Configured session context safety budget ({} tokens) exceeds provider limit ({} tokens) for {} on {}",
196                    configured_context, max_tokens, config.agent.default_model, config.agent.provider
197                ));
198            }
199        }
200
201        if let Some(message) =
202            check_openai_hosted_shell_compat(config, &config.agent.default_model, &config.agent.provider)
203        {
204            result.warnings.push(message);
205        }
206
207        if let Some(message) = config
208            .provider
209            .anthropic
210            .fallbacks
211            .validation_error("provider.anthropic.fallbacks")
212        {
213            result
214                .warnings
215                .push(format!("{message} VT Code will not request server-side fallbacks until this is corrected."));
216        }
217
218        // Check if workspace exists (if specified)
219        if let Ok(cwd) = std::env::current_dir() {
220            // Basic check only, actual workspace validation happens in StartupContext
221            if !cwd.exists() {
222                result.warnings.push("Current working directory does not exist".to_owned());
223            }
224        }
225
226        Ok(result)
227    }
228
229    /// Quick validation - only critical checks
230    pub fn quick_validate(&self, config: &VTCodeConfig) -> Result<()> {
231        let managed_auth_provider = configured_managed_auth_provider(config);
232        let custom_provider = config.custom_provider(&config.agent.provider);
233        let is_custom_provider = custom_provider.is_some();
234        let is_codex_provider = config.agent.provider.eq_ignore_ascii_case("codex");
235        let is_openai_provider = config.agent.provider.eq_ignore_ascii_case("openai");
236
237        // Check model exists
238        let model_is_valid = custom_provider.is_some_and(|custom| {
239            custom
240                .effective_models()
241                .iter()
242                .any(|model| model == &config.agent.default_model)
243        }) || (!is_custom_provider
244            && (is_codex_provider
245                || is_managed_auth_model(managed_auth_provider, &config.agent.default_model)
246                || self.models_db.model_exists(&config.agent.provider, &config.agent.default_model)));
247        if !model_is_valid {
248            bail!(
249                "Model '{}' not found for provider '{}'. Check docs/models.json.",
250                config.agent.default_model,
251                config.agent.provider
252            );
253        }
254
255        // Check API key — skip for built-in OpenAI (multi-source auth).
256        if !is_custom_provider && !is_codex_provider && !is_openai_provider && managed_auth_provider.is_none() {
257            get_api_key_with_mode(
258                &config.agent.provider,
259                &ApiKeySources::default(),
260                config.agent.credential_storage_mode,
261            )
262            .with_context(|| {
263                format!(
264                    "API key not found for provider '{}'. Set {} environment variable.",
265                    config.agent.provider,
266                    config.agent.provider.to_uppercase()
267                )
268            })?;
269        }
270
271        Ok(())
272    }
273}
274
275fn configured_managed_auth_provider(config: &VTCodeConfig) -> Option<Provider> {
276    config
277        .agent
278        .provider
279        .parse::<Provider>()
280        .ok()
281        .filter(|provider| provider.uses_managed_auth())
282}
283
284fn is_managed_auth_model(provider: Option<Provider>, model: &str) -> bool {
285    matches!(provider, Some(Provider::Copilot)) && !model.trim().is_empty()
286}
287
288/// Results from configuration validation
289#[derive(Debug, Default, Clone)]
290pub struct ValidationResult {
291    pub errors: Vec<String>,
292    pub warnings: Vec<String>,
293}
294
295pub fn check_prompt_cache_retention_compat(config: &VTCodeConfig, model: &str, provider: &str) -> Option<String> {
296    if !provider.eq_ignore_ascii_case("openai") {
297        return None;
298    }
299
300    if let Some(retention) = config.prompt_cache.providers.openai.prompt_cache_retention {
301        if retention == PromptCacheRetention::Unknown {
302            return Some("prompt_cache_retention must be one of: in_memory, 24h".to_string());
303        }
304        if !RESPONSES_API_MODELS.contains(&model) {
305            return Some(format!(
306                "`prompt_cache_retention` is set but the selected model '{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."
307            ));
308        }
309    }
310
311    None
312}
313
314pub fn check_openai_hosted_shell_compat(config: &VTCodeConfig, model: &str, provider: &str) -> Option<String> {
315    if !provider.eq_ignore_ascii_case("openai") {
316        return None;
317    }
318
319    let hosted_shell = &config.provider.openai.hosted_shell;
320    if !hosted_shell.enabled {
321        return None;
322    }
323
324    if !RESPONSES_API_MODELS.contains(&model) {
325        return Some(format!(
326            "`provider.openai.hosted_shell.enabled` is set but the selected model '{model}' does not use the OpenAI Responses API. VT Code will ignore hosted shell and keep the local shell tool for this model."
327        ));
328    }
329
330    if !hosted_shell.has_valid_reference_target() {
331        return Some(
332            "`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."
333                .to_string(),
334        );
335    }
336
337    if hosted_shell.uses_container_reference()
338        && (!hosted_shell.file_ids.is_empty()
339            || !hosted_shell.skills.is_empty()
340            || hosted_shell.network_policy.is_allowlist())
341    {
342        return Some(
343            "`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."
344                .to_string(),
345        );
346    }
347
348    if let Some(message) = hosted_shell.first_invalid_skill_message() {
349        return Some(format!("{message} VT Code will ignore hosted shell until the mounted skills are corrected."));
350    }
351
352    if let Some(message) = hosted_shell.first_invalid_network_policy_message() {
353        return Some(format!(
354            "{message} VT Code will ignore hosted shell until the hosted shell network policy is corrected."
355        ));
356    }
357
358    None
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use serial_test::serial;
365    use std::fs;
366    use tempfile::TempDir;
367
368    fn create_test_models_db() -> TempDir {
369        let dir = TempDir::new().unwrap();
370        let models_json = r#"{
371  "google": {
372    "id": "google",
373    "default_model": "gemini-3-flash-preview",
374    "models": {
375      "gemini-3-flash-preview": {
376        "context": 1048576
377      }
378    }
379  },
380  "openai": {
381    "id": "openai",
382    "default_model": "gpt-5",
383    "models": {
384      "gpt-5": {
385        "context": 128000
386      }
387    }
388  }
389}"#;
390        fs::write(dir.path().join("models.json"), models_json).unwrap();
391        dir
392    }
393
394    #[test]
395    fn loads_models_database() {
396        let dir = create_test_models_db();
397        let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
398
399        assert!(db.model_exists("google", "gemini-3-flash-preview"));
400        assert!(db.model_exists("openai", "gpt-5"));
401        assert!(!db.model_exists("google", "nonexistent"));
402    }
403
404    #[test]
405    fn gets_context_window() {
406        let dir = create_test_models_db();
407        let db = ModelsDatabase::from_file(&dir.path().join("models.json")).unwrap();
408
409        assert_eq!(db.get_context_window("google", "gemini-3-flash-preview"), Some(1048576));
410        assert_eq!(db.get_context_window("openai", "gpt-5"), Some(128000));
411        assert_eq!(db.get_context_window("google", "nonexistent"), None);
412    }
413
414    #[test]
415    fn validates_model_exists() {
416        let dir = create_test_models_db();
417        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
418        let mut config = VTCodeConfig::default();
419        config.agent.provider = "google".to_owned();
420        config.agent.default_model = "gemini-3-flash-preview".to_owned();
421
422        let result = validator.validate(&config).unwrap();
423        // Model exists, so there should be no model-specific lookup error.
424        assert!(
425            !result
426                .errors
427                .iter()
428                .any(|e| { e.contains("Model 'gemini-3-flash-preview' not found for provider 'google'") })
429        );
430    }
431
432    #[test]
433    fn custom_provider_skips_builtin_model_catalog_checks() {
434        let dir = create_test_models_db();
435        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
436        let mut config = VTCodeConfig::default();
437        config.agent.provider = "mycorp".to_owned();
438        config.agent.default_model = "totally-custom-model".to_owned();
439        config.custom_providers.push(vtcode_config::core::CustomProviderConfig {
440            name: "mycorp".to_string(),
441            display_name: "MyCorporateName".to_string(),
442            base_url: "https://llm.example/v1".to_string(),
443            context_window: None,
444            api_key_env: "MYCORP_API_KEY".to_string(),
445            auth: None,
446            model: "totally-custom-model".to_string(),
447            models: Vec::new(),
448            ..vtcode_config::core::CustomProviderConfig::default()
449        });
450
451        let result = validator.validate(&config).unwrap();
452
453        assert!(result.errors.is_empty());
454    }
455
456    #[test]
457    fn codex_provider_skips_builtin_model_and_api_key_checks() {
458        let dir = create_test_models_db();
459        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
460        let mut config = VTCodeConfig::default();
461        config.agent.provider = "codex".to_owned();
462        config.agent.default_model = "managed-by-codex".to_owned();
463
464        let result = validator.validate(&config).unwrap();
465
466        assert!(result.errors.is_empty());
467    }
468
469    #[test]
470    fn copilot_managed_auth_model_accepts_live_raw_ids() {
471        assert!(is_managed_auth_model(Some(Provider::Copilot), "gpt-5-codex"));
472        assert!(!is_managed_auth_model(Some(Provider::Copilot), "   "));
473    }
474
475    #[test]
476    fn detects_missing_model() {
477        let dir = create_test_models_db();
478        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
479        let mut config = VTCodeConfig::default();
480        config.agent.provider = "google".to_owned();
481        config.agent.default_model = "nonexistent-model".to_owned();
482
483        let result = validator.validate(&config).unwrap();
484        assert!(result.errors.iter().any(|e| e.contains("not found")));
485    }
486
487    #[test]
488    fn detects_context_window_exceeded() {
489        let dir = create_test_models_db();
490        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
491        let mut config = VTCodeConfig::default();
492        config.agent.provider = "google".to_owned();
493        config.agent.default_model = "gemini-3-flash-preview".to_owned();
494        config.context.max_context_tokens = 2000000; // Exceeds 1048576 limit
495
496        let result = validator.validate(&config).unwrap();
497        assert!(result.warnings.iter().any(|w| w.contains("exceeds")));
498    }
499
500    #[test]
501    fn retention_warning_for_non_responses_model() {
502        let mut cfg = VTCodeConfig::default();
503        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
504        let msg = check_prompt_cache_retention_compat(&cfg, "gpt-oss-20b", "openai");
505        assert!(msg.is_some());
506    }
507
508    #[test]
509    fn retention_ok_for_responses_model() {
510        let mut cfg = VTCodeConfig::default();
511        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
512        let msg =
513            check_prompt_cache_retention_compat(&cfg, crate::config::constants::models::openai::GPT_5_6, "openai");
514        assert!(msg.is_none());
515    }
516
517    #[test]
518    fn retention_ok_for_gpt_alias() {
519        let mut cfg = VTCodeConfig::default();
520        cfg.prompt_cache.providers.openai.prompt_cache_retention = Some(PromptCacheRetention::H24);
521        let msg = check_prompt_cache_retention_compat(&cfg, crate::config::constants::models::openai::GPT, "openai");
522        assert!(msg.is_none());
523    }
524
525    #[test]
526    fn hosted_shell_warning_for_non_responses_model() {
527        let mut cfg = VTCodeConfig::default();
528        cfg.provider.openai.hosted_shell.enabled = true;
529
530        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-oss-20b", "openai");
531        assert!(msg.is_some());
532    }
533
534    #[test]
535    fn hosted_shell_warning_for_missing_container_reference_id() {
536        let mut cfg = VTCodeConfig::default();
537        cfg.provider.openai.hosted_shell.enabled = true;
538        cfg.provider.openai.hosted_shell.environment =
539            crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
540
541        let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5, "openai");
542        assert!(msg.is_some());
543    }
544
545    #[test]
546    fn hosted_shell_warning_for_auto_only_fields_on_container_reference() {
547        let mut cfg = VTCodeConfig::default();
548        cfg.provider.openai.hosted_shell.enabled = true;
549        cfg.provider.openai.hosted_shell.environment =
550            crate::config::core::OpenAIHostedShellEnvironment::ContainerReference;
551        cfg.provider.openai.hosted_shell.container_id = Some("cntr_123".to_string());
552        cfg.provider.openai.hosted_shell.file_ids = vec!["file_123".to_string()];
553        cfg.provider.openai.hosted_shell.network_policy.policy_type =
554            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
555        cfg.provider.openai.hosted_shell.network_policy.allowed_domains = vec!["httpbin.org".to_string()];
556
557        let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5, "openai");
558        assert!(msg.is_some());
559    }
560
561    #[test]
562    fn hosted_shell_ok_for_valid_responses_config() {
563        let mut cfg = VTCodeConfig::default();
564        cfg.provider.openai.hosted_shell.enabled = true;
565
566        let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT_5_6, "openai");
567        assert!(msg.is_none());
568    }
569
570    #[test]
571    fn hosted_shell_ok_for_gpt_alias() {
572        let mut cfg = VTCodeConfig::default();
573        cfg.provider.openai.hosted_shell.enabled = true;
574
575        let msg = check_openai_hosted_shell_compat(&cfg, crate::config::constants::models::openai::GPT, "openai");
576        assert!(msg.is_none());
577    }
578
579    #[test]
580    fn hosted_shell_warning_for_empty_skill_reference_id() {
581        let mut cfg = VTCodeConfig::default();
582        cfg.provider.openai.hosted_shell.enabled = true;
583        cfg.provider.openai.hosted_shell.skills = vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
584            skill_id: "   ".to_string(),
585            version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
586        }];
587
588        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
589
590        assert!(
591            msg.as_deref()
592                .unwrap_or_default()
593                .contains("provider.openai.hosted_shell.skills[0].skill_id")
594        );
595    }
596
597    #[test]
598    fn hosted_shell_warning_for_empty_inline_bundle() {
599        let mut cfg = VTCodeConfig::default();
600        cfg.provider.openai.hosted_shell.enabled = true;
601        cfg.provider.openai.hosted_shell.skills =
602            vec![vtcode_config::core::OpenAIHostedSkill::Inline { bundle_b64: " ".to_string(), sha256: None }];
603
604        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
605
606        assert!(
607            msg.as_deref()
608                .unwrap_or_default()
609                .contains("provider.openai.hosted_shell.skills[0].bundle_b64")
610        );
611    }
612
613    #[test]
614    fn hosted_shell_warning_for_empty_allowlist_domains() {
615        let mut cfg = VTCodeConfig::default();
616        cfg.provider.openai.hosted_shell.enabled = true;
617        cfg.provider.openai.hosted_shell.network_policy.policy_type =
618            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
619
620        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
621
622        assert!(msg.as_deref().unwrap_or_default().contains("network_policy.allowed_domains"));
623    }
624
625    #[test]
626    fn hosted_shell_warning_for_secret_domain_outside_allowlist() {
627        let mut cfg = VTCodeConfig::default();
628        cfg.provider.openai.hosted_shell.enabled = true;
629        cfg.provider.openai.hosted_shell.network_policy.policy_type =
630            vtcode_config::core::OpenAIHostedShellNetworkPolicyType::Allowlist;
631        cfg.provider.openai.hosted_shell.network_policy.allowed_domains = vec!["pypi.org".to_string()];
632        cfg.provider.openai.hosted_shell.network_policy.domain_secrets =
633            vec![vtcode_config::core::OpenAIHostedShellDomainSecret {
634                domain: "httpbin.org".to_string(),
635                name: "API_KEY".to_string(),
636                value: "secret".to_string(),
637            }];
638
639        let msg = check_openai_hosted_shell_compat(&cfg, "gpt-5.6-sol", "openai");
640
641        assert!(msg.as_deref().unwrap_or_default().contains("domain_secrets[0].domain"));
642    }
643
644    #[test]
645    fn validate_surfaces_hosted_shell_warning() {
646        let dir = create_test_models_db();
647        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
648        let mut config = VTCodeConfig::default();
649        config.agent.provider = "openai".to_owned();
650        config.agent.default_model = "gpt-5.6".to_owned();
651        config.provider.openai.hosted_shell.enabled = true;
652        config.provider.openai.hosted_shell.skills = vec![vtcode_config::core::OpenAIHostedSkill::SkillReference {
653            skill_id: "   ".to_string(),
654            version: vtcode_config::core::OpenAIHostedSkillVersion::default(),
655        }];
656
657        let result = validator.validate(&config).unwrap();
658
659        assert!(
660            result
661                .warnings
662                .iter()
663                .any(|warning| { warning.contains("provider.openai.hosted_shell.skills[0].skill_id") })
664        );
665    }
666
667    #[test]
668    fn validate_surfaces_invalid_anthropic_fallbacks_warning() {
669        let dir = create_test_models_db();
670        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
671        let mut config = VTCodeConfig::default();
672        let target = vtcode_config::core::AnthropicFallbackTarget {
673            model: "claude-opus-4-8".to_string(),
674            max_tokens: None,
675        };
676        config.provider.anthropic.fallbacks =
677            vtcode_config::core::AnthropicFallbacks::Models(vec![target.clone(), target]);
678
679        let result = validator.validate(&config).unwrap();
680
681        assert!(
682            result
683                .warnings
684                .iter()
685                .any(|warning| warning.contains("provider.anthropic.fallbacks") && warning.contains("distinct"))
686        );
687    }
688
689    // ── OpenAI multi-source auth: static validation skip ──
690
691    #[test]
692    fn openai_without_api_key_does_not_emit_static_warning() {
693        // Built-in OpenAI supports ChatGPT OAuth + Codex fallback + API key.
694        // The static API-key check must be skipped so users with only a
695        // ChatGPT subscription don't see a false "API key not found" error.
696        let dir = create_test_models_db();
697        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
698        let mut config = VTCodeConfig::default();
699        config.agent.provider = "openai".to_owned();
700        config.agent.default_model = "gpt-5".to_owned();
701
702        let result = validator.validate(&config).unwrap();
703        assert!(
704            !result
705                .errors
706                .iter()
707                .any(|e| e.contains("API key not found for provider 'openai'")),
708            "OpenAI should not emit a static API-key warning: errors={:?}",
709            result.errors
710        );
711    }
712
713    #[test]
714    fn openai_quick_validate_without_api_key_does_not_fail() {
715        // Same check for quick_validate — OpenAI should not bail on missing API key.
716        let dir = create_test_models_db();
717        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
718        let mut config = VTCodeConfig::default();
719        config.agent.provider = "openai".to_owned();
720        config.agent.default_model = "gpt-5".to_owned();
721
722        // Should succeed (no API key error) even without OPENAI_API_KEY set.
723        let result = validator.quick_validate(&config);
724        assert!(result.is_ok(), "quick_validate should not fail for OpenAI without API key: {result:?}");
725    }
726
727    #[test]
728    #[serial]
729    fn non_openai_provider_still_requires_api_key() {
730        // A non-OpenAI, non-Codex provider without an API key must still
731        // produce a static API-key error. Use a fictional provider that is
732        // very unlikely to have an env var set on the developer's machine.
733        let dir = TempDir::new().unwrap();
734        let models_json = r#"{
735          "ztestprov": {
736            "id": "ztestprov",
737            "default_model": "zmodel",
738            "models": { "zmodel": { "context": 128000 } }
739          }
740        }"#;
741        fs::write(dir.path().join("models.json"), models_json).unwrap();
742        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
743        let mut config = VTCodeConfig::default();
744        config.agent.provider = "ztestprov".to_owned();
745        config.agent.default_model = "zmodel".to_owned();
746        // Ensure no env var is set for this fictional provider.
747        let prev = std::env::var("ZTESTPROV_API_KEY").ok();
748        vtcode_commons::env_lock::set_var("ZTESTPROV_API_KEY", "");
749
750        let result = validator.validate(&config).unwrap();
751        vtcode_commons::env_lock::lock().restore_var("ZTESTPROV_API_KEY", prev.as_deref());
752
753        assert!(
754            result
755                .errors
756                .iter()
757                .any(|e| e.contains("API key not found for provider 'ztestprov'")),
758            "non-OpenAI provider should still require an API key: errors={:?}",
759            result.errors
760        );
761    }
762
763    #[test]
764    fn deprecated_openai_model_diagnostic_is_present() {
765        // A deprecated OpenAI model that is not in the model DB should produce
766        // the deprecation diagnostic with a replacement suggestion.
767        let dir = create_test_models_db();
768        let validator = ConfigValidator::new(&dir.path().join("models.json")).unwrap();
769        let mut config = VTCodeConfig::default();
770        config.agent.provider = "openai".to_owned();
771        config.agent.default_model = "o3".to_owned(); // deprecated → gpt-5.6-sol
772
773        let result = validator.validate(&config).unwrap();
774        assert!(
775            result
776                .errors
777                .iter()
778                .any(|e| e.contains("gpt-5.6-sol") || e.contains("deprecated") || e.contains("Update your config")),
779            "deprecated OpenAI model should produce a replacement diagnostic: errors={:?}",
780            result.errors
781        );
782    }
783}