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            model_behavior,
225            workspace_root: None,
226        },
227        &[],
228    )
229}
230
231/// Create provider with full configuration
232pub fn create_provider_with_config(
233    provider_name: &str,
234    config: ProviderConfig,
235) -> Result<Box<dyn LLMProvider>, LLMError> {
236    let factory = get_factory().lock().map_err(|_e| LLMError::Provider {
237        message: ctx_err!("llm factory", "lock poisoned"),
238        metadata: None,
239    })?;
240    factory.create_provider(provider_name, config, &[])
241}
242
243/// Register custom OpenAI-compatible providers from config into the global factory.
244///
245/// This performs a sync/replace: previously registered custom providers are
246/// removed first, then the new set is registered. Built-in providers are
247/// never touched.
248pub fn register_custom_providers(custom_providers: &[vtcode_config::core::CustomProviderConfig]) {
249    let Ok(mut factory) = get_factory().lock() else {
250        tracing::error!("Failed to lock LLM factory for custom provider registration");
251        return;
252    };
253
254    // Remove previously registered custom providers (anything not built-in)
255    let registered: Vec<String> = factory.list_providers();
256    for key in &registered {
257        if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
258            factory.remove_provider(key);
259        }
260    }
261
262    // Register each custom provider
263    for cp in custom_providers {
264        if let Err(msg) = cp.validate() {
265            tracing::warn!("Skipping invalid custom provider: {msg}");
266            continue;
267        }
268
269        let key = cp.name.to_lowercase();
270        let custom_config = cp.clone();
271        let display_name = cp.display_name.clone();
272        let default_model = cp.model.clone();
273        let auth_config = cp.auth.clone();
274        let api_key_env = cp.resolved_api_key_env();
275        let reg_key = key.clone();
276
277        factory.register_provider(&reg_key, move |config: ProviderConfig| {
278            let ProviderConfig {
279                api_key,
280                base_url,
281                model,
282                prompt_cache,
283                timeouts,
284                openai,
285                anthropic,
286                model_behavior,
287                workspace_root,
288                ..
289            } = config;
290
291            let api_key = resolve_custom_provider_api_key(api_key, auth_config.is_some(), &api_key_env);
292
293            let model = model.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| default_model.clone());
294            let base_url = base_url
295                .clone()
296                .filter(|u| !u.trim().is_empty())
297                .unwrap_or_else(|| custom_config.base_url.clone());
298            let custom_provider_auth = auth_config
299                .clone()
300                .filter(|_| api_key.is_none())
301                .map(|auth| CustomProviderAuthHandle::new(auth, workspace_root.clone()));
302
303            Box::new(CustomProviderBackendRouter::from_config(
304                custom_config.clone(),
305                api_key,
306                Some(model),
307                base_url,
308                prompt_cache,
309                timeouts,
310                openai,
311                anthropic,
312                model_behavior,
313                custom_provider_auth,
314            ))
315        });
316
317        tracing::trace!(provider = key, display_name = display_name, "Registered custom provider");
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::super::provider_config::{AnthropicProviderConfig, GeminiProviderConfig, OpenAIProviderConfig};
324    use super::super::providers::OllamaProvider;
325    use super::*;
326    use std::collections::BTreeMap;
327    use vtcode_config::core::CustomProviderConfig;
328    use vtcode_config::core::{AnthropicConfig, CustomProviderApiFormat, CustomProviderProfileConfig, OpenAIConfig};
329
330    #[test]
331    fn blank_runtime_api_key_allows_command_auth() {
332        assert_eq!(resolve_custom_provider_api_key(Some("   ".to_string()), true, "MISSING_KEY"), None);
333        assert_eq!(
334            resolve_custom_provider_api_key(Some("explicit-key".to_string()), true, "MISSING_KEY"),
335            Some("explicit-key".to_string())
336        );
337    }
338
339    #[test]
340    fn builtin_cgp_registration_exposes_expected_provider_keys() {
341        let factory = LLMFactory::new();
342        let mut providers = factory.list_providers();
343        providers.sort();
344
345        assert_eq!(
346            providers,
347            vec![
348                "anthropic",
349                "copilot",
350                "deepseek",
351                "evolink",
352                "gemini",
353                "huggingface",
354                "llamacpp",
355                "lmstudio",
356                "merge-gateway",
357                "meta",
358                "mimo",
359                "minimax",
360                "mistral",
361                "moonshot",
362                "nvidia",
363                "ollama",
364                "ollama-cloud",
365                "openai",
366                "opencode-go",
367                "opencode-zen",
368                "openresponses",
369                "openrouter",
370                "poolside",
371                "qwen",
372                "stepfun",
373                "vercel",
374                "xai",
375                "zai",
376            ]
377        );
378    }
379
380    #[test]
381    fn standard_provider_builds_through_cgp_registration() {
382        let factory = LLMFactory::new();
383        let provider = factory
384            .create_provider(
385                <GeminiProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
386                ProviderConfig {
387                    api_key: Some("test-key".to_string()),
388                    openai_chatgpt_auth: None,
389                    copilot_auth: None,
390                    base_url: None,
391                    model: Some(vtcode_config::constants::models::google::GEMINI_3_FLASH_PREVIEW.to_string()),
392                    prompt_cache: None,
393                    timeouts: None,
394                    openai: None,
395                    anthropic: None,
396                    model_behavior: None,
397                    workspace_root: None,
398                },
399                &[],
400            )
401            .expect("built-in cgp registration should build");
402
403        assert_eq!(provider.name(), "gemini");
404    }
405
406    #[test]
407    fn openai_build_preserves_provider_specific_config_path() {
408        let factory = LLMFactory::new();
409        let provider = factory
410            .create_provider(
411                <OpenAIProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
412                ProviderConfig {
413                    api_key: Some("test-key".to_string()),
414                    openai_chatgpt_auth: None,
415                    copilot_auth: None,
416                    base_url: None,
417                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
418                    prompt_cache: None,
419                    timeouts: None,
420                    openai: Some(OpenAIConfig { websocket_mode: true, ..OpenAIConfig::default() }),
421                    anthropic: Some(AnthropicConfig::default()),
422                    model_behavior: None,
423                    workspace_root: None,
424                },
425                &[],
426            )
427            .expect("openai cgp registration should build");
428
429        assert_eq!(provider.name(), "openai");
430    }
431
432    #[test]
433    fn anthropic_build_preserves_provider_specific_config_path() {
434        let factory = LLMFactory::new();
435        let provider = factory
436            .create_provider(
437                <AnthropicProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
438                ProviderConfig {
439                    api_key: Some("test-key".to_string()),
440                    openai_chatgpt_auth: None,
441                    copilot_auth: None,
442                    base_url: None,
443                    model: Some(vtcode_config::constants::models::anthropic::DEFAULT_MODEL.to_string()),
444                    prompt_cache: None,
445                    timeouts: None,
446                    openai: None,
447                    anthropic: Some(AnthropicConfig {
448                        count_tokens_enabled: true,
449                        ..AnthropicConfig::default()
450                    }),
451                    model_behavior: None,
452                    workspace_root: None,
453                },
454                &[],
455            )
456            .expect("anthropic cgp registration should build");
457
458        assert_eq!(provider.name(), "anthropic");
459    }
460
461    #[test]
462    fn custom_provider_registration_still_coexists_with_cgp_builtins() {
463        let mut factory = LLMFactory::new();
464        factory.register_provider("custom-test", |_config| {
465            Box::new(OllamaProvider::from_config(
466                None,
467                Some("gpt-oss:20b".to_string()),
468                Some("http://localhost:11434".to_string()),
469                None,
470                None,
471                None,
472                None,
473            ))
474        });
475
476        let custom = factory
477            .create_provider(
478                "custom-test",
479                ProviderConfig {
480                    api_key: None,
481                    openai_chatgpt_auth: None,
482                    copilot_auth: None,
483                    base_url: None,
484                    model: None,
485                    prompt_cache: None,
486                    timeouts: None,
487                    openai: None,
488                    anthropic: None,
489                    model_behavior: None,
490                    workspace_root: None,
491                },
492                &[],
493            )
494            .expect("custom provider should still register");
495        let builtin = factory
496            .create_provider(
497                "openai",
498                ProviderConfig {
499                    api_key: Some("test-key".to_string()),
500                    openai_chatgpt_auth: None,
501                    copilot_auth: None,
502                    base_url: None,
503                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
504                    prompt_cache: None,
505                    timeouts: None,
506                    openai: None,
507                    anthropic: None,
508                    model_behavior: None,
509                    workspace_root: None,
510                },
511                &[],
512            )
513            .expect("builtin provider should still build");
514
515        assert_eq!(custom.name(), "ollama");
516        assert_eq!(builtin.name(), "openai");
517    }
518
519    #[test]
520    #[serial_test::serial(global_llm_factory)]
521    fn custom_openai_compatible_provider_uses_configured_display_name() {
522        register_custom_providers(&[CustomProviderConfig {
523            temperature: None,
524            top_p: None,
525            top_k: None,
526            presence_penalty: None,
527            frequency_penalty: None,
528            max_tokens: None,
529            reasoning_effort: None,
530            name: "mycorp".to_string(),
531            display_name: "MyCorporateName".to_string(),
532            base_url: "https://llm.corp.example/v1".to_string(),
533            api_format: CustomProviderApiFormat::Auto,
534            context_window: None,
535            supports_tools: None,
536            supports_reasoning: None,
537            supports_reasoning_effort: None,
538            supports_vision: None,
539            supports_structured_output: None,
540            supports_parallel_tool_calls: None,
541            supports_context_caching: None,
542            supports_responses_compaction: None,
543            supports_context_edits: None,
544            api_key_env: "MYCORP_API_KEY".to_string(),
545            auth: None,
546            model: "gpt-5-mini".to_string(),
547            models: Vec::new(),
548            profiles: BTreeMap::new(),
549        }]);
550
551        let provider = create_provider_with_config(
552            "mycorp",
553            ProviderConfig {
554                api_key: None,
555                openai_chatgpt_auth: None,
556                copilot_auth: None,
557                base_url: None,
558                model: Some("gpt-5-mini".to_string()),
559                prompt_cache: None,
560                timeouts: None,
561                openai: Some(OpenAIConfig::default()),
562                anthropic: None,
563                model_behavior: None,
564                workspace_root: None,
565            },
566        )
567        .expect("custom provider should register");
568
569        assert_eq!(provider.name(), "mycorp");
570        assert_eq!(provider.supported_models(), vec!["gpt-5-mini".to_string()]);
571
572        register_custom_providers(&[]);
573    }
574
575    #[test]
576    #[serial_test::serial(global_llm_factory)]
577    fn custom_openai_compatible_provider_uses_configured_context_window() {
578        register_custom_providers(&[CustomProviderConfig {
579            temperature: None,
580            top_p: None,
581            top_k: None,
582            presence_penalty: None,
583            frequency_penalty: None,
584            max_tokens: None,
585            reasoning_effort: None,
586            name: "mycorp".to_string(),
587            display_name: "MyCorporateName".to_string(),
588            base_url: "https://llm.corp.example/v1".to_string(),
589            api_format: CustomProviderApiFormat::Auto,
590            context_window: Some(256_000),
591            supports_tools: None,
592            supports_reasoning: None,
593            supports_reasoning_effort: None,
594            supports_vision: None,
595            supports_structured_output: None,
596            supports_parallel_tool_calls: None,
597            supports_context_caching: None,
598            supports_responses_compaction: None,
599            supports_context_edits: None,
600            api_key_env: "MYCORP_API_KEY".to_string(),
601            auth: None,
602            model: "gpt-5-mini".to_string(),
603            models: Vec::new(),
604            profiles: BTreeMap::new(),
605        }]);
606
607        let provider = create_provider_with_config(
608            "mycorp",
609            ProviderConfig {
610                api_key: None,
611                openai_chatgpt_auth: None,
612                copilot_auth: None,
613                base_url: None,
614                model: Some("gpt-5-mini".to_string()),
615                prompt_cache: None,
616                timeouts: None,
617                openai: Some(OpenAIConfig::default()),
618                anthropic: None,
619                model_behavior: None,
620                workspace_root: None,
621            },
622        )
623        .expect("custom provider should register");
624
625        assert_eq!(provider.effective_context_size("gpt-5-mini"), 256_000);
626
627        register_custom_providers(&[]);
628    }
629
630    fn profile_config(
631        api_format: CustomProviderApiFormat,
632        context_window: Option<usize>,
633        supports_tools: Option<bool>,
634    ) -> CustomProviderProfileConfig {
635        CustomProviderProfileConfig {
636            temperature: None,
637            top_p: None,
638            top_k: None,
639            presence_penalty: None,
640            frequency_penalty: None,
641            max_tokens: None,
642            reasoning_effort: None,
643            api_format,
644            context_window,
645            supports_tools,
646            supports_reasoning: None,
647            supports_reasoning_effort: None,
648            supports_vision: None,
649            supports_structured_output: None,
650            supports_parallel_tool_calls: None,
651            supports_context_caching: None,
652            supports_responses_compaction: None,
653            supports_context_edits: None,
654        }
655    }
656
657    fn mixed_profile_provider_config() -> CustomProviderConfig {
658        let mut profiles = BTreeMap::new();
659        profiles.insert(
660            "gpt-5-mini".to_string(),
661            profile_config(CustomProviderApiFormat::OpenAIChat, Some(256_000), Some(false)),
662        );
663        profiles.insert(
664            "claude-sonnet-5".to_string(),
665            profile_config(CustomProviderApiFormat::AnthropicMessages, Some(512_000), Some(true)),
666        );
667
668        CustomProviderConfig {
669            temperature: None,
670            top_p: None,
671            top_k: None,
672            presence_penalty: None,
673            frequency_penalty: None,
674            max_tokens: None,
675            reasoning_effort: None,
676            name: "mixed".to_string(),
677            display_name: "Mixed".to_string(),
678            base_url: "https://llm.corp.example/v1".to_string(),
679            api_format: CustomProviderApiFormat::Auto,
680            context_window: None,
681            supports_tools: None,
682            supports_reasoning: None,
683            supports_reasoning_effort: None,
684            supports_vision: None,
685            supports_structured_output: None,
686            supports_parallel_tool_calls: None,
687            supports_context_caching: None,
688            supports_responses_compaction: None,
689            supports_context_edits: None,
690            api_key_env: "MIXED_API_KEY".to_string(),
691            auth: None,
692            model: "gpt-5-mini".to_string(),
693            models: vec!["gpt-5-mini".to_string(), "claude-sonnet-5".to_string()],
694            profiles,
695        }
696    }
697
698    #[test]
699    #[serial_test::serial(global_llm_factory)]
700    fn custom_provider_backend_kind_and_identity_follow_selected_default_model() {
701        register_custom_providers(&[mixed_profile_provider_config()]);
702
703        let provider = create_provider_with_config(
704            "mixed",
705            ProviderConfig {
706                api_key: Some("test-key".to_string()),
707                openai_chatgpt_auth: None,
708                copilot_auth: None,
709                base_url: None,
710                model: Some("claude-sonnet-5".to_string()),
711                prompt_cache: None,
712                timeouts: None,
713                openai: Some(OpenAIConfig::default()),
714                anthropic: Some(AnthropicConfig::default()),
715                model_behavior: None,
716                workspace_root: None,
717            },
718        )
719        .expect("mixed provider should build");
720
721        assert_eq!(provider.name(), "mixed");
722        assert_eq!(provider.backend_kind(), vtcode_commons::llm::BackendKind::Anthropic);
723
724        register_custom_providers(&[]);
725    }
726
727    #[test]
728    #[serial_test::serial(global_llm_factory)]
729    fn custom_provider_profile_overrides_context_and_capabilities() {
730        register_custom_providers(&[mixed_profile_provider_config()]);
731
732        let provider = create_provider_with_config(
733            "mixed",
734            ProviderConfig {
735                api_key: Some("test-key".to_string()),
736                openai_chatgpt_auth: None,
737                copilot_auth: None,
738                base_url: None,
739                model: Some("gpt-5-mini".to_string()),
740                prompt_cache: None,
741                timeouts: None,
742                openai: Some(OpenAIConfig::default()),
743                anthropic: Some(AnthropicConfig::default()),
744                model_behavior: None,
745                workspace_root: None,
746            },
747        )
748        .expect("mixed provider should build");
749
750        assert!(!provider.supports_tools("gpt-5-mini"));
751        assert_eq!(provider.effective_context_size("gpt-5-mini"), 256_000);
752        assert!(provider.supports_tools("claude-sonnet-5"));
753        assert_eq!(provider.effective_context_size("claude-sonnet-5"), 512_000);
754
755        register_custom_providers(&[]);
756    }
757
758    #[test]
759    #[serial_test::serial(global_llm_factory)]
760    fn custom_provider_validation_errors_use_custom_display_name() {
761        register_custom_providers(&[mixed_profile_provider_config()]);
762
763        let provider = create_provider_with_config(
764            "mixed",
765            ProviderConfig {
766                api_key: Some("test-key".to_string()),
767                openai_chatgpt_auth: None,
768                copilot_auth: None,
769                base_url: None,
770                model: Some("gpt-5-mini".to_string()),
771                prompt_cache: None,
772                timeouts: None,
773                openai: Some(OpenAIConfig::default()),
774                anthropic: Some(AnthropicConfig::default()),
775                model_behavior: None,
776                workspace_root: None,
777            },
778        )
779        .expect("mixed provider should build");
780
781        let error = provider
782            .validate_request(&crate::llm::provider::LLMRequest {
783                messages: std::sync::Arc::new(Vec::new()),
784                model: "gpt-5-mini".to_string(),
785                ..Default::default()
786            })
787            .expect_err("empty request should be rejected");
788
789        assert!(format!("{error:?}").contains("Mixed"));
790
791        register_custom_providers(&[]);
792    }
793
794    /// Sample Atlas Cloud config used across custom-provider tests.
795    /// Matches the snippet documented in `docs/providers/atlascloud.md` and
796    /// `vtcode.toml.example`.
797    fn atlas_cloud_provider_config() -> CustomProviderConfig {
798        CustomProviderConfig {
799            temperature: None,
800            top_p: None,
801            top_k: None,
802            presence_penalty: None,
803            frequency_penalty: None,
804            max_tokens: None,
805            reasoning_effort: None,
806            name: "atlascloud".to_string(),
807            display_name: "Atlas Cloud".to_string(),
808            base_url: "https://api.atlascloud.ai/v1".to_string(),
809            api_format: CustomProviderApiFormat::Auto,
810            context_window: None,
811            supports_tools: None,
812            supports_reasoning: None,
813            supports_reasoning_effort: None,
814            supports_vision: None,
815            supports_structured_output: None,
816            supports_parallel_tool_calls: None,
817            supports_context_caching: None,
818            supports_responses_compaction: None,
819            supports_context_edits: None,
820            api_key_env: "ATLASCLOUD_API_KEY".to_string(),
821            auth: None,
822            model: "deepseek-ai/deepseek-flash".to_string(),
823            models: vec![
824                "deepseek-ai/deepseek-flash".to_string(),
825                "deepseek-ai/deepseek-v4-pro".to_string(),
826                "deepseek-ai/DeepSeek-V3-0324".to_string(),
827                "deepseek-ai/DeepSeek-V3.1".to_string(),
828                "deepseek-ai/deepseek-r1-0528".to_string(),
829                "deepseek-ai/deepseek-ocr".to_string(),
830                "qwen/qwen3.6-35b-a3b".to_string(),
831                "qwen/qwen3.6-plus".to_string(),
832                "qwen/qwen3.5-122b-a10b".to_string(),
833                "qwen/qwen3.5-35b-a3b".to_string(),
834                "qwen/qwen3-coder-next".to_string(),
835                "qwen/qwen3.5-397b-a17b".to_string(),
836                "qwen/qwen3-max-2026-01-23".to_string(),
837                "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
838                "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
839                "qwen/qwen3-next-80b-a3b-thinking".to_string(),
840                "qwen/qwen3-next-80b-a3b-instruct".to_string(),
841                "moonshotai/kimi-k2.7-code".to_string(),
842                "moonshotai/kimi-k2.6".to_string(),
843                "moonshotai/kimi-k2.5".to_string(),
844                "moonshotai/Kimi-K2-Thinking".to_string(),
845                "moonshotai/Kimi-K2-Instruct".to_string(),
846                "moonshotai/Kimi-K2-Instruct-0905".to_string(),
847                "zai-org/glm-5.2".to_string(),
848                "zai-org/glm-5.1".to_string(),
849                "zai-org/glm-5v-turbo".to_string(),
850                "zai-org/glm-5-turbo".to_string(),
851                "zai-org/glm-4.7".to_string(),
852                "minimaxai/minimax-m3".to_string(),
853                "minimaxai/minimax-m2.7".to_string(),
854                "minimaxai/minimax-m2.5".to_string(),
855                "minimaxai/minimax-m2.1".to_string(),
856                "kwaipilot/kat-coder-pro-v2".to_string(),
857                "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
858            ],
859            profiles: BTreeMap::new(),
860        }
861    }
862
863    /// Atlas Cloud is OpenAI-compatible and configured via `[[custom_providers]]`.
864    /// This test mirrors what `src/cli/dispatch/commands.rs` does for non-interactive
865    /// flows (`ask`, `review`, `benchmark`, …): register custom providers from
866    /// config before resolving the provider, then resolve it through the same
867    /// factory path the CLI uses, with `model: None` to exercise the
868    /// `default_model` fallback.
869    #[test]
870    #[serial_test::serial(global_llm_factory)]
871    fn atlas_cloud_registers_as_openai_compatible_custom_provider() {
872        register_custom_providers(&[atlas_cloud_provider_config()]);
873
874        let provider = create_provider_with_config(
875            "atlascloud",
876            ProviderConfig {
877                api_key: None,
878                openai_chatgpt_auth: None,
879                copilot_auth: None,
880                base_url: None,
881                model: None,
882                prompt_cache: None,
883                timeouts: None,
884                openai: Some(OpenAIConfig::default()),
885                anthropic: None,
886                model_behavior: None,
887                workspace_root: None,
888            },
889        )
890        .expect("atlas cloud should resolve as an OpenAI-compatible custom provider");
891
892        assert_eq!(provider.name(), "atlascloud");
893        assert_eq!(
894            provider.supported_models(),
895            vec![
896                "deepseek-ai/deepseek-flash".to_string(),
897                "deepseek-ai/deepseek-v4-pro".to_string(),
898                "deepseek-ai/DeepSeek-V3-0324".to_string(),
899                "deepseek-ai/DeepSeek-V3.1".to_string(),
900                "deepseek-ai/deepseek-r1-0528".to_string(),
901                "deepseek-ai/deepseek-ocr".to_string(),
902                "qwen/qwen3.6-35b-a3b".to_string(),
903                "qwen/qwen3.6-plus".to_string(),
904                "qwen/qwen3.5-122b-a10b".to_string(),
905                "qwen/qwen3.5-35b-a3b".to_string(),
906                "qwen/qwen3-coder-next".to_string(),
907                "qwen/qwen3.5-397b-a17b".to_string(),
908                "qwen/qwen3-max-2026-01-23".to_string(),
909                "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
910                "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
911                "qwen/qwen3-next-80b-a3b-thinking".to_string(),
912                "qwen/qwen3-next-80b-a3b-instruct".to_string(),
913                "moonshotai/kimi-k2.7-code".to_string(),
914                "moonshotai/kimi-k2.6".to_string(),
915                "moonshotai/kimi-k2.5".to_string(),
916                "moonshotai/Kimi-K2-Thinking".to_string(),
917                "moonshotai/Kimi-K2-Instruct".to_string(),
918                "moonshotai/Kimi-K2-Instruct-0905".to_string(),
919                "zai-org/glm-5.2".to_string(),
920                "zai-org/glm-5.1".to_string(),
921                "zai-org/glm-5v-turbo".to_string(),
922                "zai-org/glm-5-turbo".to_string(),
923                "zai-org/glm-4.7".to_string(),
924                "minimaxai/minimax-m3".to_string(),
925                "minimaxai/minimax-m2.7".to_string(),
926                "minimaxai/minimax-m2.5".to_string(),
927                "minimaxai/minimax-m2.1".to_string(),
928                "kwaipilot/kat-coder-pro-v2".to_string(),
929                "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
930            ]
931        );
932
933        register_custom_providers(&[]);
934    }
935
936    /// Calling `register_custom_providers(&[])` must clear any previously
937    /// registered custom providers while leaving built-ins intact. This guards
938    /// the sync/replace contract that the CLI dispatch path depends on (so a
939    /// user removing Atlas Cloud from `vtcode.toml` does not leave a stale
940    /// registration in the global factory).
941    #[test]
942    #[serial_test::serial(global_llm_factory)]
943    fn register_custom_providers_with_empty_input_clears_custom_but_keeps_builtins() {
944        register_custom_providers(&[atlas_cloud_provider_config()]);
945
946        {
947            let factory = get_factory().lock().expect("factory lock");
948            assert!(
949                factory.list_providers().iter().any(|k| k == "atlascloud"),
950                "custom provider should be registered before clearing"
951            );
952        }
953
954        register_custom_providers(&[]);
955
956        let factory = get_factory().lock().expect("factory lock");
957        let providers = factory.list_providers();
958        assert!(
959            !providers.iter().any(|k| k == "atlascloud"),
960            "custom provider should be unregistered after sync with empty input"
961        );
962        for builtin in BUILTIN_PROVIDER_KEYS {
963            assert!(
964                providers.iter().any(|k| k == builtin),
965                "built-in provider {builtin} must survive custom-provider sync"
966            );
967        }
968    }
969
970    #[test]
971    fn create_provider_for_bare_minimax_model_uses_minimax_provider() {
972        let provider = create_provider_for_model("MiniMax-M3", "test-key".to_string(), None, None)
973            .expect("bare minimax model should resolve to minimax provider");
974
975        assert_eq!(provider.name(), "minimax");
976    }
977
978    #[test]
979    fn create_provider_for_mistral_model_uses_mistral_provider() {
980        let provider = create_provider_for_model("mistral-large-2512", "test-key".to_string(), None, None)
981            .expect("mistral models should resolve through mistral provider");
982
983        assert_eq!(provider.name(), "mistral");
984    }
985
986    #[test]
987    fn create_provider_for_nvidia_model_uses_nvidia_provider() {
988        let provider =
989            create_provider_for_model("nvidia/nemotron-3-ultra-550b-a55b", "test-key".to_string(), None, None)
990                .expect("NVIDIA model should resolve to NVIDIA provider");
991
992        assert_eq!(provider.name(), "nvidia");
993    }
994
995    #[test]
996    fn create_provider_with_config_registers_merge_gateway() {
997        let provider = create_provider_with_config(
998            "merge-gateway",
999            ProviderConfig {
1000                api_key: Some("test-key".to_string()),
1001                openai_chatgpt_auth: None,
1002                copilot_auth: None,
1003                base_url: None,
1004                model: Some("deepseek/deepseek-v4-pro".to_string()),
1005                prompt_cache: None,
1006                timeouts: None,
1007                openai: None,
1008                anthropic: None,
1009                model_behavior: None,
1010                workspace_root: None,
1011            },
1012        )
1013        .expect("Merge Gateway should be registered in the factory");
1014
1015        assert_eq!(provider.name(), "merge-gateway");
1016        assert_eq!(provider.backend_kind(), vtcode_commons::llm::BackendKind::MergeGateway);
1017    }
1018
1019    #[test]
1020    fn create_provider_for_openai_repo_id_uses_openrouter_provider() {
1021        let provider = create_provider_for_model("openai/gpt-oss-20b", "test-key".to_string(), None, None)
1022            .expect("repo identifiers should preserve openrouter routing");
1023
1024        assert_eq!(provider.name(), "openrouter");
1025    }
1026
1027    #[test]
1028    fn create_provider_for_unknown_model_returns_error() {
1029        match create_provider_for_model("totally-unknown-model", "test-key".to_string(), None, None) {
1030            Err(LLMError::InvalidRequest { .. }) => {}
1031            Err(error) => panic!("expected invalid request error, got {error:?}"),
1032            Ok(_) => panic!("unknown models should remain rejected"),
1033        }
1034    }
1035
1036    #[test]
1037    fn unknown_provider_error_lists_available_providers() {
1038        let factory = LLMFactory::new();
1039        let error = match factory.create_provider(
1040            "merge-api",
1041            ProviderConfig {
1042                api_key: None,
1043                openai_chatgpt_auth: None,
1044                copilot_auth: None,
1045                base_url: None,
1046                model: None,
1047                prompt_cache: None,
1048                timeouts: None,
1049                openai: None,
1050                anthropic: None,
1051                model_behavior: None,
1052                workspace_root: None,
1053            },
1054            &[],
1055        ) {
1056            Err(error) => error,
1057            Ok(_) => panic!("unknown provider should be rejected"),
1058        };
1059
1060        let message = format!("{error:?}");
1061        assert!(message.contains("Unknown provider: merge-api"), "{message}");
1062        assert!(message.contains("Available providers:"), "{message}");
1063        assert!(message.contains("merge-gateway"), "{message}");
1064    }
1065
1066    #[test]
1067    fn provider_lookup_is_case_insensitive() {
1068        let factory = LLMFactory::new();
1069        let provider = factory
1070            .create_provider(
1071                "OpenAI",
1072                ProviderConfig {
1073                    api_key: Some("test-key".to_string()),
1074                    openai_chatgpt_auth: None,
1075                    copilot_auth: None,
1076                    base_url: None,
1077                    model: Some(vtcode_config::constants::models::openai::DEFAULT_MODEL.to_string()),
1078                    prompt_cache: None,
1079                    timeouts: None,
1080                    openai: None,
1081                    anthropic: None,
1082                    model_behavior: None,
1083                    workspace_root: None,
1084                },
1085                &[],
1086            )
1087            .expect("provider lookup should be case-insensitive");
1088
1089        assert_eq!(provider.name(), "openai");
1090    }
1091}