Skip to main content

vtcode_core/llm/
factory.rs

1use super::cgp::{CanBuildProvider, CanDescribeProvider, register_builtin_cgp_providers};
2use super::model_resolver::{ModelResolver, heuristic_provider_from_model};
3use super::provider::{LLMError, LLMProvider};
4use hashbrown::HashMap;
5use vtcode_commons::ctx_err;
6use vtcode_config::core::{ModelConfig, PromptCachingConfig};
7use vtcode_config::models::Provider;
8use vtcode_llm::providers::CustomProviderBackendRouter;
9use vtcode_llm::providers::openai::CustomProviderAuthHandle;
10
11// ProviderConfig is the canonical factory config imported from vtcode-llm.
12// The struct was consolidated here to eliminate the duplicate definition.
13pub use vtcode_llm::provider_config_types::ProviderConfig;
14
15/// Factory closure producing a boxed provider from config.
16///
17/// Two dynamic-dispatch layers are intentional here, not accidental: the outer
18/// `Box<dyn Fn>` lets the registry hold heterogeneous constructors selected at
19/// runtime, and the returned `Box<dyn LLMProvider>` is the single vtable layer
20/// for the heterogeneous provider collection. Provider wrappers below this
21/// point must NOT add further `Box<dyn LLMProvider>` layers — embed the
22/// concrete inner provider instead (see `LlamaCppProvider`/`LmStudioProvider`)
23/// so each call pays at most one vtable lookup.
24type ProviderFactory = Box<dyn Fn(ProviderConfig) -> Box<dyn LLMProvider> + Send + Sync>;
25
26fn resolve_custom_provider_api_key(
27    api_key: Option<String>,
28    command_auth_configured: bool,
29    api_key_env: &str,
30) -> Option<String> {
31    api_key
32        .filter(|value| !value.trim().is_empty())
33        .or_else(|| (!command_auth_configured).then(|| std::env::var(api_key_env).ok()).flatten())
34}
35
36const BUILTIN_PROVIDER_KEYS: &[&str] = &[
37    "openai",
38    "anthropic",
39    "gemini",
40    "copilot",
41    "deepseek",
42    "meta",
43    "openrouter",
44    "ollama",
45    "ollama-cloud",
46    "lmstudio",
47    "llamacpp",
48    "moonshot",
49    "zai",
50    "minimax",
51    "mimo",
52    "mistral",
53    "huggingface",
54    "openresponses",
55    "opencode-zen",
56    "opencode-go",
57    "qwen",
58    "stepfun",
59    "evolink",
60    "poolside",
61    "xai",
62    "nvidia",
63    "merge-gateway",
64    "vercel",
65];
66
67/// LLM provider factory and registry
68pub struct LLMFactory {
69    providers: HashMap<String, ProviderFactory>,
70}
71
72impl LLMFactory {
73    /// Create a new factory pre-populated with all built-in provider registrations.
74    pub fn new() -> Self {
75        let mut factory = Self { providers: HashMap::new() };
76
77        register_builtin_cgp_providers(&mut factory);
78
79        factory
80    }
81
82    /// Register a provider discovered through the CGP (Context-Generic Provider) substrate.
83    pub fn register_cgp_provider<Ctx>(&mut self)
84    where
85        Ctx: CanDescribeProvider + CanBuildProvider + 'static,
86    {
87        self.register_provider(Ctx::PROVIDER_KEY, Ctx::build_provider);
88    }
89
90    /// Register a new provider
91    pub fn register_provider<F>(&mut self, name: &str, factory_fn: F)
92    where
93        F: Fn(ProviderConfig) -> Box<dyn LLMProvider> + Send + Sync + 'static,
94    {
95        self.providers.insert(name.to_string(), Box::new(factory_fn));
96    }
97
98    /// Create provider instance
99    pub fn create_provider(
100        &self,
101        provider_name: &str,
102        config: ProviderConfig,
103        whitelist: &[String],
104    ) -> Result<Box<dyn LLMProvider>, LLMError> {
105        if !whitelist.is_empty() && !whitelist.iter().any(|w| w.eq_ignore_ascii_case(provider_name)) {
106            return Err(LLMError::InvalidRequest {
107                message: format!("Provider '{}' is not in providers_whitelist", provider_name),
108                metadata: None,
109            });
110        }
111
112        let factory_fn = self
113            .providers
114            .iter()
115            .find(|(key, _)| key.eq_ignore_ascii_case(provider_name))
116            .map(|(_, factory_fn)| factory_fn)
117            .ok_or_else(|| {
118                let mut available: Vec<&str> = self.providers.keys().map(String::as_str).collect();
119                available.sort_unstable();
120                LLMError::InvalidRequest {
121                    message: format!(
122                        "Unknown provider: {provider_name}. Available providers: {}",
123                        available.join(", ")
124                    ),
125                    metadata: None,
126                }
127            })?;
128
129        Ok(factory_fn(config))
130    }
131
132    /// List available providers
133    pub fn list_providers(&self) -> Vec<String> {
134        self.providers.keys().cloned().collect()
135    }
136
137    /// Remove a provider registration by name.
138    pub fn remove_provider(&mut self, name: &str) {
139        self.providers.remove(name);
140    }
141
142    /// Determine provider name from model string
143    pub fn provider_from_model(&self, model: &str) -> Option<String> {
144        heuristic_provider_from_model(model).map(|provider| provider.to_string())
145    }
146}
147
148/// Infer a [`Provider`] from an optional override and model string.
149///
150/// Attempts, in order:
151/// 1. Parse the override if provided.
152/// 2. Parse the model into a [`vtcode_config::models::ModelId`] and return its provider.
153/// 3. Fall back to heuristic detection via [`LLMFactory::provider_from_model`].
154pub fn infer_provider(override_provider: Option<&str>, model: &str) -> Option<Provider> {
155    ModelResolver::resolve_provider(override_provider, model, &[])
156}
157
158impl Default for LLMFactory {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164use std::sync::{LazyLock, Mutex};
165
166use crate::models_manager::ModelsManager;
167
168static FACTORY: LazyLock<Mutex<LLMFactory>> = LazyLock::new(|| Mutex::new(LLMFactory::new()));
169
170static MODELS_MANAGER: LazyLock<ModelsManager> = LazyLock::new(ModelsManager::new);
171
172/// Get global factory instance
173pub fn get_factory() -> &'static Mutex<LLMFactory> {
174    &FACTORY
175}
176
177/// Get global models manager instance
178pub fn get_models_manager() -> &'static ModelsManager {
179    &MODELS_MANAGER
180}
181
182/// Infer provider from model slug.
183///
184/// Canonical implementation lives in `vtcode-llm::factory_types` (it needs no
185/// vtcode-core state); this re-export keeps the historical
186/// `crate::llm::factory::infer_provider_from_model` path stable.
187pub use vtcode_llm::factory_types::infer_provider_from_model;
188
189/// Create provider from model name and API key
190pub fn create_provider_for_model(
191    model: &str,
192    api_key: String,
193    prompt_cache: Option<PromptCachingConfig>,
194    model_behavior: Option<ModelConfig>,
195) -> Result<Box<dyn LLMProvider>, LLMError> {
196    // Validate model exists in ModelsManager (non-blocking check using local presets)
197    if !get_models_manager().model_exists_sync(model) {
198        tracing::warn!(model = model, "Model not found in ModelsManager presets, proceeding with factory heuristics");
199    }
200
201    let provider_name = infer_provider_from_model(model)
202        .map(|provider| provider.to_string())
203        .ok_or_else(|| LLMError::InvalidRequest {
204            message: format!("Cannot determine provider for model: {model}"),
205            metadata: None,
206        })?;
207    let factory = get_factory().lock().map_err(|_e| LLMError::Provider {
208        message: ctx_err!("llm factory", "lock poisoned"),
209        metadata: None,
210    })?;
211
212    factory.create_provider(
213        &provider_name,
214        ProviderConfig {
215            api_key: Some(api_key),
216            openai_chatgpt_auth: None,
217            copilot_auth: None,
218            base_url: None,
219            model: Some(model.to_string()),
220            prompt_cache,
221            timeouts: None,
222            openai: None,
223            anthropic: None,
224            openrouter: None,
225            model_behavior,
226            workspace_root: None,
227        },
228        &[],
229    )
230}
231
232/// Create provider with full configuration
233pub fn create_provider_with_config(
234    provider_name: &str,
235    config: ProviderConfig,
236) -> Result<Box<dyn LLMProvider>, LLMError> {
237    let factory = get_factory().lock().map_err(|_e| LLMError::Provider {
238        message: ctx_err!("llm factory", "lock poisoned"),
239        metadata: None,
240    })?;
241    factory.create_provider(provider_name, config, &[])
242}
243
244/// Register custom OpenAI-compatible providers from config into the global factory.
245///
246/// This performs a sync/replace: previously registered custom providers are
247/// removed first, then the new set is registered. Built-in providers are
248/// never touched.
249pub fn register_custom_providers(custom_providers: &[vtcode_config::core::CustomProviderConfig]) {
250    let Ok(mut factory) = get_factory().lock() else {
251        tracing::error!("Failed to lock LLM factory for custom provider registration");
252        return;
253    };
254
255    // Remove previously registered custom providers (anything not built-in)
256    let registered: Vec<String> = factory.list_providers();
257    for key in &registered {
258        if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
259            factory.remove_provider(key);
260        }
261    }
262
263    // Register each custom provider
264    for cp in custom_providers {
265        if let Err(msg) = cp.validate() {
266            tracing::warn!("Skipping invalid custom provider: {msg}");
267            continue;
268        }
269
270        let key = cp.name.to_lowercase();
271        let custom_config = cp.clone();
272        let display_name = cp.display_name.clone();
273        let default_model = cp.model.clone();
274        let auth_config = cp.auth.clone();
275        let api_key_env = cp.resolved_api_key_env();
276        let reg_key = key.clone();
277
278        factory.register_provider(&reg_key, move |config: ProviderConfig| {
279            let ProviderConfig {
280                api_key,
281                base_url,
282                model,
283                prompt_cache,
284                timeouts,
285                openai,
286                anthropic,
287                model_behavior,
288                workspace_root,
289                ..
290            } = config;
291
292            let api_key = resolve_custom_provider_api_key(api_key, auth_config.is_some(), &api_key_env);
293
294            let model = model.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| default_model.clone());
295            let base_url = base_url
296                .clone()
297                .filter(|u| !u.trim().is_empty())
298                .unwrap_or_else(|| custom_config.base_url.clone());
299            let custom_provider_auth = auth_config
300                .clone()
301                .filter(|_| api_key.is_none())
302                .map(|auth| CustomProviderAuthHandle::new(auth, workspace_root.clone()));
303
304            Box::new(CustomProviderBackendRouter::from_config(
305                custom_config.clone(),
306                api_key,
307                Some(model),
308                base_url,
309                prompt_cache,
310                timeouts,
311                openai,
312                anthropic,
313                model_behavior,
314                custom_provider_auth,
315            ))
316        });
317
318        tracing::trace!(provider = key, display_name = display_name, "Registered custom provider");
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::super::provider_config::{AnthropicProviderConfig, GeminiProviderConfig, OpenAIProviderConfig};
325    use super::super::providers::OllamaProvider;
326    use super::*;
327    use std::collections::BTreeMap;
328    use vtcode_config::core::CustomProviderConfig;
329    use vtcode_config::core::{AnthropicConfig, CustomProviderApiFormat, CustomProviderProfileConfig, OpenAIConfig};
330
331    #[test]
332    fn blank_runtime_api_key_allows_command_auth() {
333        assert_eq!(resolve_custom_provider_api_key(Some("   ".to_string()), true, "MISSING_KEY"), None);
334        assert_eq!(
335            resolve_custom_provider_api_key(Some("explicit-key".to_string()), true, "MISSING_KEY"),
336            Some("explicit-key".to_string())
337        );
338    }
339
340    #[test]
341    fn builtin_cgp_registration_exposes_expected_provider_keys() {
342        let factory = LLMFactory::new();
343        let mut providers = factory.list_providers();
344        providers.sort();
345
346        assert_eq!(
347            providers,
348            vec![
349                "anthropic",
350                "copilot",
351                "deepseek",
352                "evolink",
353                "gemini",
354                "huggingface",
355                "llamacpp",
356                "lmstudio",
357                "merge-gateway",
358                "meta",
359                "mimo",
360                "minimax",
361                "mistral",
362                "moonshot",
363                "nvidia",
364                "ollama",
365                "ollama-cloud",
366                "openai",
367                "opencode-go",
368                "opencode-zen",
369                "openresponses",
370                "openrouter",
371                "poolside",
372                "qwen",
373                "stepfun",
374                "vercel",
375                "xai",
376                "zai",
377            ]
378        );
379    }
380
381    #[test]
382    fn standard_provider_builds_through_cgp_registration() {
383        let factory = LLMFactory::new();
384        let provider = factory
385            .create_provider(
386                <GeminiProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
387                ProviderConfig {
388                    api_key: Some("test-key".to_string()),
389                    openai_chatgpt_auth: None,
390                    copilot_auth: None,
391                    base_url: None,
392                    model: Some(vtcode_config::constants::models::google::GEMINI_3_FLASH_PREVIEW.to_string()),
393                    prompt_cache: None,
394                    timeouts: None,
395                    openai: None,
396                    anthropic: None,
397                    openrouter: None,
398                    model_behavior: None,
399                    workspace_root: None,
400                },
401                &[],
402            )
403            .expect("built-in cgp registration should build");
404
405        assert_eq!(provider.name(), "gemini");
406    }
407
408    #[test]
409    fn openai_build_preserves_provider_specific_config_path() {
410        let factory = LLMFactory::new();
411        let provider = factory
412            .create_provider(
413                <OpenAIProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
414                ProviderConfig {
415                    api_key: Some("test-key".to_string()),
416                    openai_chatgpt_auth: None,
417                    copilot_auth: None,
418                    base_url: None,
419                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
420                    prompt_cache: None,
421                    timeouts: None,
422                    openai: Some(OpenAIConfig { websocket_mode: true, ..OpenAIConfig::default() }),
423                    anthropic: Some(AnthropicConfig::default()),
424                    openrouter: None,
425                    model_behavior: None,
426                    workspace_root: None,
427                },
428                &[],
429            )
430            .expect("openai cgp registration should build");
431
432        assert_eq!(provider.name(), "openai");
433    }
434
435    #[test]
436    fn anthropic_build_preserves_provider_specific_config_path() {
437        let factory = LLMFactory::new();
438        let provider = factory
439            .create_provider(
440                <AnthropicProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
441                ProviderConfig {
442                    api_key: Some("test-key".to_string()),
443                    openai_chatgpt_auth: None,
444                    copilot_auth: None,
445                    base_url: None,
446                    model: Some(vtcode_config::constants::models::anthropic::DEFAULT_MODEL.to_string()),
447                    prompt_cache: None,
448                    timeouts: None,
449                    openai: None,
450                    anthropic: Some(AnthropicConfig {
451                        count_tokens_enabled: true,
452                        ..AnthropicConfig::default()
453                    }),
454                    openrouter: None,
455                    model_behavior: None,
456                    workspace_root: None,
457                },
458                &[],
459            )
460            .expect("anthropic cgp registration should build");
461
462        assert_eq!(provider.name(), "anthropic");
463    }
464
465    #[test]
466    fn custom_provider_registration_still_coexists_with_cgp_builtins() {
467        let mut factory = LLMFactory::new();
468        factory.register_provider("custom-test", |_config| {
469            Box::new(OllamaProvider::from_config(
470                None,
471                Some("gpt-oss:20b".to_string()),
472                Some("http://localhost:11434".to_string()),
473                None,
474                None,
475                None,
476                None,
477            ))
478        });
479
480        let custom = factory
481            .create_provider(
482                "custom-test",
483                ProviderConfig {
484                    api_key: None,
485                    openai_chatgpt_auth: None,
486                    copilot_auth: None,
487                    base_url: None,
488                    model: None,
489                    prompt_cache: None,
490                    timeouts: None,
491                    openai: None,
492                    anthropic: None,
493                    openrouter: None,
494                    model_behavior: None,
495                    workspace_root: None,
496                },
497                &[],
498            )
499            .expect("custom provider should still register");
500        let builtin = factory
501            .create_provider(
502                "openai",
503                ProviderConfig {
504                    api_key: Some("test-key".to_string()),
505                    openai_chatgpt_auth: None,
506                    copilot_auth: None,
507                    base_url: None,
508                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
509                    prompt_cache: None,
510                    timeouts: None,
511                    openai: None,
512                    anthropic: None,
513                    openrouter: None,
514                    model_behavior: None,
515                    workspace_root: None,
516                },
517                &[],
518            )
519            .expect("builtin provider should still build");
520
521        assert_eq!(custom.name(), "ollama");
522        assert_eq!(builtin.name(), "openai");
523    }
524
525    #[test]
526    #[serial_test::serial(global_llm_factory)]
527    fn custom_openai_compatible_provider_uses_configured_display_name() {
528        register_custom_providers(&[CustomProviderConfig {
529            temperature: None,
530            top_p: None,
531            top_k: None,
532            presence_penalty: None,
533            frequency_penalty: None,
534            max_tokens: None,
535            reasoning_effort: None,
536            name: "mycorp".to_string(),
537            display_name: "MyCorporateName".to_string(),
538            base_url: "https://llm.corp.example/v1".to_string(),
539            api_format: CustomProviderApiFormat::Auto,
540            context_window: None,
541            supports_tools: None,
542            supports_reasoning: None,
543            supports_reasoning_effort: None,
544            supports_vision: None,
545            supports_structured_output: None,
546            supports_parallel_tool_calls: None,
547            supports_context_caching: None,
548            supports_responses_compaction: None,
549            supports_context_edits: None,
550            api_key_env: "MYCORP_API_KEY".to_string(),
551            auth: None,
552            model: "gpt-5-mini".to_string(),
553            models: Vec::new(),
554            profiles: BTreeMap::new(),
555        }]);
556
557        let provider = create_provider_with_config(
558            "mycorp",
559            ProviderConfig {
560                api_key: None,
561                openai_chatgpt_auth: None,
562                copilot_auth: None,
563                base_url: None,
564                model: Some("gpt-5-mini".to_string()),
565                prompt_cache: None,
566                timeouts: None,
567                openai: Some(OpenAIConfig::default()),
568                anthropic: None,
569                openrouter: None,
570                model_behavior: None,
571                workspace_root: None,
572            },
573        )
574        .expect("custom provider should register");
575
576        assert_eq!(provider.name(), "mycorp");
577        assert_eq!(provider.supported_models(), vec!["gpt-5-mini".to_string()]);
578
579        register_custom_providers(&[]);
580    }
581
582    #[test]
583    #[serial_test::serial(global_llm_factory)]
584    fn custom_openai_compatible_provider_uses_configured_context_window() {
585        register_custom_providers(&[CustomProviderConfig {
586            temperature: None,
587            top_p: None,
588            top_k: None,
589            presence_penalty: None,
590            frequency_penalty: None,
591            max_tokens: None,
592            reasoning_effort: None,
593            name: "mycorp".to_string(),
594            display_name: "MyCorporateName".to_string(),
595            base_url: "https://llm.corp.example/v1".to_string(),
596            api_format: CustomProviderApiFormat::Auto,
597            context_window: Some(256_000),
598            supports_tools: None,
599            supports_reasoning: None,
600            supports_reasoning_effort: None,
601            supports_vision: None,
602            supports_structured_output: None,
603            supports_parallel_tool_calls: None,
604            supports_context_caching: None,
605            supports_responses_compaction: None,
606            supports_context_edits: None,
607            api_key_env: "MYCORP_API_KEY".to_string(),
608            auth: None,
609            model: "gpt-5-mini".to_string(),
610            models: Vec::new(),
611            profiles: BTreeMap::new(),
612        }]);
613
614        let provider = create_provider_with_config(
615            "mycorp",
616            ProviderConfig {
617                api_key: None,
618                openai_chatgpt_auth: None,
619                copilot_auth: None,
620                base_url: None,
621                model: Some("gpt-5-mini".to_string()),
622                prompt_cache: None,
623                timeouts: None,
624                openai: Some(OpenAIConfig::default()),
625                anthropic: None,
626                openrouter: None,
627                model_behavior: None,
628                workspace_root: None,
629            },
630        )
631        .expect("custom provider should register");
632
633        assert_eq!(provider.effective_context_size("gpt-5-mini"), 256_000);
634
635        register_custom_providers(&[]);
636    }
637
638    fn profile_config(
639        api_format: CustomProviderApiFormat,
640        context_window: Option<usize>,
641        supports_tools: Option<bool>,
642    ) -> CustomProviderProfileConfig {
643        CustomProviderProfileConfig {
644            temperature: None,
645            top_p: None,
646            top_k: None,
647            presence_penalty: None,
648            frequency_penalty: None,
649            max_tokens: None,
650            reasoning_effort: None,
651            api_format,
652            context_window,
653            supports_tools,
654            supports_reasoning: None,
655            supports_reasoning_effort: None,
656            supports_vision: None,
657            supports_structured_output: None,
658            supports_parallel_tool_calls: None,
659            supports_context_caching: None,
660            supports_responses_compaction: None,
661            supports_context_edits: None,
662        }
663    }
664
665    fn mixed_profile_provider_config() -> CustomProviderConfig {
666        let mut profiles = BTreeMap::new();
667        profiles.insert(
668            "gpt-5-mini".to_string(),
669            profile_config(CustomProviderApiFormat::OpenAIChat, Some(256_000), Some(false)),
670        );
671        profiles.insert(
672            "claude-sonnet-5".to_string(),
673            profile_config(CustomProviderApiFormat::AnthropicMessages, Some(512_000), Some(true)),
674        );
675
676        CustomProviderConfig {
677            temperature: None,
678            top_p: None,
679            top_k: None,
680            presence_penalty: None,
681            frequency_penalty: None,
682            max_tokens: None,
683            reasoning_effort: None,
684            name: "mixed".to_string(),
685            display_name: "Mixed".to_string(),
686            base_url: "https://llm.corp.example/v1".to_string(),
687            api_format: CustomProviderApiFormat::Auto,
688            context_window: None,
689            supports_tools: None,
690            supports_reasoning: None,
691            supports_reasoning_effort: None,
692            supports_vision: None,
693            supports_structured_output: None,
694            supports_parallel_tool_calls: None,
695            supports_context_caching: None,
696            supports_responses_compaction: None,
697            supports_context_edits: None,
698            api_key_env: "MIXED_API_KEY".to_string(),
699            auth: None,
700            model: "gpt-5-mini".to_string(),
701            models: vec!["gpt-5-mini".to_string(), "claude-sonnet-5".to_string()],
702            profiles,
703        }
704    }
705
706    #[test]
707    #[serial_test::serial(global_llm_factory)]
708    fn custom_provider_backend_kind_and_identity_follow_selected_default_model() {
709        register_custom_providers(&[mixed_profile_provider_config()]);
710
711        let provider = create_provider_with_config(
712            "mixed",
713            ProviderConfig {
714                api_key: Some("test-key".to_string()),
715                openai_chatgpt_auth: None,
716                copilot_auth: None,
717                base_url: None,
718                model: Some("claude-sonnet-5".to_string()),
719                prompt_cache: None,
720                timeouts: None,
721                openai: Some(OpenAIConfig::default()),
722                anthropic: Some(AnthropicConfig::default()),
723                openrouter: None,
724                model_behavior: None,
725                workspace_root: None,
726            },
727        )
728        .expect("mixed provider should build");
729
730        assert_eq!(provider.name(), "mixed");
731        assert_eq!(provider.backend_kind(), vtcode_commons::llm::BackendKind::Anthropic);
732
733        register_custom_providers(&[]);
734    }
735
736    #[test]
737    #[serial_test::serial(global_llm_factory)]
738    fn custom_provider_profile_overrides_context_and_capabilities() {
739        register_custom_providers(&[mixed_profile_provider_config()]);
740
741        let provider = create_provider_with_config(
742            "mixed",
743            ProviderConfig {
744                api_key: Some("test-key".to_string()),
745                openai_chatgpt_auth: None,
746                copilot_auth: None,
747                base_url: None,
748                model: Some("gpt-5-mini".to_string()),
749                prompt_cache: None,
750                timeouts: None,
751                openai: Some(OpenAIConfig::default()),
752                anthropic: Some(AnthropicConfig::default()),
753                openrouter: None,
754                model_behavior: None,
755                workspace_root: None,
756            },
757        )
758        .expect("mixed provider should build");
759
760        assert!(!provider.supports_tools("gpt-5-mini"));
761        assert_eq!(provider.effective_context_size("gpt-5-mini"), 256_000);
762        assert!(provider.supports_tools("claude-sonnet-5"));
763        assert_eq!(provider.effective_context_size("claude-sonnet-5"), 512_000);
764
765        register_custom_providers(&[]);
766    }
767
768    #[test]
769    #[serial_test::serial(global_llm_factory)]
770    fn custom_provider_validation_errors_use_custom_display_name() {
771        register_custom_providers(&[mixed_profile_provider_config()]);
772
773        let provider = create_provider_with_config(
774            "mixed",
775            ProviderConfig {
776                api_key: Some("test-key".to_string()),
777                openai_chatgpt_auth: None,
778                copilot_auth: None,
779                base_url: None,
780                model: Some("gpt-5-mini".to_string()),
781                prompt_cache: None,
782                timeouts: None,
783                openai: Some(OpenAIConfig::default()),
784                anthropic: Some(AnthropicConfig::default()),
785                openrouter: None,
786                model_behavior: None,
787                workspace_root: None,
788            },
789        )
790        .expect("mixed provider should build");
791
792        let error = provider
793            .validate_request(&crate::llm::provider::LLMRequest {
794                messages: std::sync::Arc::new(Vec::new()),
795                model: "gpt-5-mini".to_string(),
796                ..Default::default()
797            })
798            .expect_err("empty request should be rejected");
799
800        assert!(format!("{error:?}").contains("Mixed"));
801
802        register_custom_providers(&[]);
803    }
804
805    /// Sample Atlas Cloud config used across custom-provider tests.
806    /// Matches the snippet documented in `docs/providers/atlascloud.md` and
807    /// `vtcode.toml.example`.
808    fn atlas_cloud_provider_config() -> CustomProviderConfig {
809        CustomProviderConfig {
810            temperature: None,
811            top_p: None,
812            top_k: None,
813            presence_penalty: None,
814            frequency_penalty: None,
815            max_tokens: None,
816            reasoning_effort: None,
817            name: "atlascloud".to_string(),
818            display_name: "Atlas Cloud".to_string(),
819            base_url: "https://api.atlascloud.ai/v1".to_string(),
820            api_format: CustomProviderApiFormat::Auto,
821            context_window: None,
822            supports_tools: None,
823            supports_reasoning: None,
824            supports_reasoning_effort: None,
825            supports_vision: None,
826            supports_structured_output: None,
827            supports_parallel_tool_calls: None,
828            supports_context_caching: None,
829            supports_responses_compaction: None,
830            supports_context_edits: None,
831            api_key_env: "ATLASCLOUD_API_KEY".to_string(),
832            auth: None,
833            model: "deepseek-ai/deepseek-flash".to_string(),
834            models: vec![
835                "deepseek-ai/deepseek-flash".to_string(),
836                "deepseek-ai/deepseek-v4-pro".to_string(),
837                "deepseek-ai/DeepSeek-V3-0324".to_string(),
838                "deepseek-ai/DeepSeek-V3.1".to_string(),
839                "deepseek-ai/deepseek-r1-0528".to_string(),
840                "deepseek-ai/deepseek-ocr".to_string(),
841                "qwen/qwen3.6-35b-a3b".to_string(),
842                "qwen/qwen3.6-plus".to_string(),
843                "qwen/qwen3.5-122b-a10b".to_string(),
844                "qwen/qwen3.5-35b-a3b".to_string(),
845                "qwen/qwen3-coder-next".to_string(),
846                "qwen/qwen3.5-397b-a17b".to_string(),
847                "qwen/qwen3-max-2026-01-23".to_string(),
848                "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
849                "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
850                "qwen/qwen3-next-80b-a3b-thinking".to_string(),
851                "qwen/qwen3-next-80b-a3b-instruct".to_string(),
852                "moonshotai/kimi-k2.7-code".to_string(),
853                "moonshotai/kimi-k2.6".to_string(),
854                "moonshotai/kimi-k2.5".to_string(),
855                "moonshotai/Kimi-K2-Thinking".to_string(),
856                "moonshotai/Kimi-K2-Instruct".to_string(),
857                "moonshotai/Kimi-K2-Instruct-0905".to_string(),
858                "zai-org/glm-5.2".to_string(),
859                "zai-org/glm-5.1".to_string(),
860                "zai-org/glm-5v-turbo".to_string(),
861                "zai-org/glm-5-turbo".to_string(),
862                "zai-org/glm-4.7".to_string(),
863                "minimaxai/minimax-m3".to_string(),
864                "minimaxai/minimax-m2.7".to_string(),
865                "minimaxai/minimax-m2.5".to_string(),
866                "minimaxai/minimax-m2.1".to_string(),
867                "kwaipilot/kat-coder-pro-v2".to_string(),
868                "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
869            ],
870            profiles: BTreeMap::new(),
871        }
872    }
873
874    /// Atlas Cloud is OpenAI-compatible and configured via `[[custom_providers]]`.
875    /// This test mirrors what `src/cli/dispatch/commands.rs` does for non-interactive
876    /// flows (`ask`, `review`, `benchmark`, …): register custom providers from
877    /// config before resolving the provider, then resolve it through the same
878    /// factory path the CLI uses, with `model: None` to exercise the
879    /// `default_model` fallback.
880    #[test]
881    #[serial_test::serial(global_llm_factory)]
882    fn atlas_cloud_registers_as_openai_compatible_custom_provider() {
883        register_custom_providers(&[atlas_cloud_provider_config()]);
884
885        let provider = create_provider_with_config(
886            "atlascloud",
887            ProviderConfig {
888                api_key: None,
889                openai_chatgpt_auth: None,
890                copilot_auth: None,
891                base_url: None,
892                model: None,
893                prompt_cache: None,
894                timeouts: None,
895                openai: Some(OpenAIConfig::default()),
896                anthropic: None,
897                openrouter: None,
898                model_behavior: None,
899                workspace_root: None,
900            },
901        )
902        .expect("atlas cloud should resolve as an OpenAI-compatible custom provider");
903
904        assert_eq!(provider.name(), "atlascloud");
905        assert_eq!(
906            provider.supported_models(),
907            vec![
908                "deepseek-ai/deepseek-flash".to_string(),
909                "deepseek-ai/deepseek-v4-pro".to_string(),
910                "deepseek-ai/DeepSeek-V3-0324".to_string(),
911                "deepseek-ai/DeepSeek-V3.1".to_string(),
912                "deepseek-ai/deepseek-r1-0528".to_string(),
913                "deepseek-ai/deepseek-ocr".to_string(),
914                "qwen/qwen3.6-35b-a3b".to_string(),
915                "qwen/qwen3.6-plus".to_string(),
916                "qwen/qwen3.5-122b-a10b".to_string(),
917                "qwen/qwen3.5-35b-a3b".to_string(),
918                "qwen/qwen3-coder-next".to_string(),
919                "qwen/qwen3.5-397b-a17b".to_string(),
920                "qwen/qwen3-max-2026-01-23".to_string(),
921                "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
922                "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
923                "qwen/qwen3-next-80b-a3b-thinking".to_string(),
924                "qwen/qwen3-next-80b-a3b-instruct".to_string(),
925                "moonshotai/kimi-k2.7-code".to_string(),
926                "moonshotai/kimi-k2.6".to_string(),
927                "moonshotai/kimi-k2.5".to_string(),
928                "moonshotai/Kimi-K2-Thinking".to_string(),
929                "moonshotai/Kimi-K2-Instruct".to_string(),
930                "moonshotai/Kimi-K2-Instruct-0905".to_string(),
931                "zai-org/glm-5.2".to_string(),
932                "zai-org/glm-5.1".to_string(),
933                "zai-org/glm-5v-turbo".to_string(),
934                "zai-org/glm-5-turbo".to_string(),
935                "zai-org/glm-4.7".to_string(),
936                "minimaxai/minimax-m3".to_string(),
937                "minimaxai/minimax-m2.7".to_string(),
938                "minimaxai/minimax-m2.5".to_string(),
939                "minimaxai/minimax-m2.1".to_string(),
940                "kwaipilot/kat-coder-pro-v2".to_string(),
941                "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
942            ]
943        );
944
945        register_custom_providers(&[]);
946    }
947
948    /// Calling `register_custom_providers(&[])` must clear any previously
949    /// registered custom providers while leaving built-ins intact. This guards
950    /// the sync/replace contract that the CLI dispatch path depends on (so a
951    /// user removing Atlas Cloud from `vtcode.toml` does not leave a stale
952    /// registration in the global factory).
953    #[test]
954    #[serial_test::serial(global_llm_factory)]
955    fn register_custom_providers_with_empty_input_clears_custom_but_keeps_builtins() {
956        register_custom_providers(&[atlas_cloud_provider_config()]);
957
958        {
959            let factory = get_factory().lock().expect("factory lock");
960            assert!(
961                factory.list_providers().iter().any(|k| k == "atlascloud"),
962                "custom provider should be registered before clearing"
963            );
964        }
965
966        register_custom_providers(&[]);
967
968        let factory = get_factory().lock().expect("factory lock");
969        let providers = factory.list_providers();
970        assert!(
971            !providers.iter().any(|k| k == "atlascloud"),
972            "custom provider should be unregistered after sync with empty input"
973        );
974        for builtin in BUILTIN_PROVIDER_KEYS {
975            assert!(
976                providers.iter().any(|k| k == builtin),
977                "built-in provider {builtin} must survive custom-provider sync"
978            );
979        }
980    }
981
982    #[test]
983    fn create_provider_for_bare_minimax_model_uses_minimax_provider() {
984        let provider = create_provider_for_model("MiniMax-M3", "test-key".to_string(), None, None)
985            .expect("bare minimax model should resolve to minimax provider");
986
987        assert_eq!(provider.name(), "minimax");
988    }
989
990    #[test]
991    fn create_provider_for_mistral_model_uses_mistral_provider() {
992        let provider = create_provider_for_model("mistral-large-2512", "test-key".to_string(), None, None)
993            .expect("mistral models should resolve through mistral provider");
994
995        assert_eq!(provider.name(), "mistral");
996    }
997
998    #[test]
999    fn create_provider_for_nvidia_model_uses_nvidia_provider() {
1000        let provider =
1001            create_provider_for_model("nvidia/nemotron-3-ultra-550b-a55b", "test-key".to_string(), None, None)
1002                .expect("NVIDIA model should resolve to NVIDIA provider");
1003
1004        assert_eq!(provider.name(), "nvidia");
1005    }
1006
1007    #[test]
1008    fn create_provider_with_config_registers_merge_gateway() {
1009        let provider = create_provider_with_config(
1010            "merge-gateway",
1011            ProviderConfig {
1012                api_key: Some("test-key".to_string()),
1013                openai_chatgpt_auth: None,
1014                copilot_auth: None,
1015                base_url: None,
1016                model: Some("deepseek/deepseek-v4-pro".to_string()),
1017                prompt_cache: None,
1018                timeouts: None,
1019                openai: None,
1020                anthropic: None,
1021                openrouter: None,
1022                model_behavior: None,
1023                workspace_root: None,
1024            },
1025        )
1026        .expect("Merge Gateway should be registered in the factory");
1027
1028        assert_eq!(provider.name(), "merge-gateway");
1029        assert_eq!(provider.backend_kind(), vtcode_commons::llm::BackendKind::MergeGateway);
1030    }
1031
1032    #[test]
1033    fn create_provider_for_openai_repo_id_uses_openrouter_provider() {
1034        let provider = create_provider_for_model("openai/gpt-oss-20b", "test-key".to_string(), None, None)
1035            .expect("repo identifiers should preserve openrouter routing");
1036
1037        assert_eq!(provider.name(), "openrouter");
1038    }
1039
1040    #[test]
1041    fn create_provider_for_unknown_model_returns_error() {
1042        match create_provider_for_model("totally-unknown-model", "test-key".to_string(), None, None) {
1043            Err(LLMError::InvalidRequest { .. }) => {}
1044            Err(error) => panic!("expected invalid request error, got {error:?}"),
1045            Ok(_) => panic!("unknown models should remain rejected"),
1046        }
1047    }
1048
1049    #[test]
1050    fn unknown_provider_error_lists_available_providers() {
1051        let factory = LLMFactory::new();
1052        let error = match factory.create_provider(
1053            "merge-api",
1054            ProviderConfig {
1055                api_key: None,
1056                openai_chatgpt_auth: None,
1057                copilot_auth: None,
1058                base_url: None,
1059                model: None,
1060                prompt_cache: None,
1061                timeouts: None,
1062                openai: None,
1063                anthropic: None,
1064                openrouter: None,
1065                model_behavior: None,
1066                workspace_root: None,
1067            },
1068            &[],
1069        ) {
1070            Err(error) => error,
1071            Ok(_) => panic!("unknown provider should be rejected"),
1072        };
1073
1074        let message = format!("{error:?}");
1075        assert!(message.contains("Unknown provider: merge-api"), "{message}");
1076        assert!(message.contains("Available providers:"), "{message}");
1077        assert!(message.contains("merge-gateway"), "{message}");
1078    }
1079
1080    #[test]
1081    fn provider_lookup_is_case_insensitive() {
1082        let factory = LLMFactory::new();
1083        let provider = factory
1084            .create_provider(
1085                "OpenAI",
1086                ProviderConfig {
1087                    api_key: Some("test-key".to_string()),
1088                    openai_chatgpt_auth: None,
1089                    copilot_auth: None,
1090                    base_url: None,
1091                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
1092                    prompt_cache: None,
1093                    timeouts: None,
1094                    openai: None,
1095                    anthropic: None,
1096                    openrouter: None,
1097                    model_behavior: None,
1098                    workspace_root: None,
1099                },
1100                &[],
1101            )
1102            .expect("provider lookup should be case-insensitive");
1103
1104        assert_eq!(provider.name(), "openai");
1105    }
1106}