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
11pub use vtcode_llm::provider_config_types::ProviderConfig;
14
15type 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
67pub struct LLMFactory {
69 providers: HashMap<String, ProviderFactory>,
70}
71
72impl LLMFactory {
73 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 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 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 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 pub fn list_providers(&self) -> Vec<String> {
134 self.providers.keys().cloned().collect()
135 }
136
137 pub fn remove_provider(&mut self, name: &str) {
139 self.providers.remove(name);
140 }
141
142 pub fn provider_from_model(&self, model: &str) -> Option<String> {
144 heuristic_provider_from_model(model).map(|provider| provider.to_string())
145 }
146}
147
148pub 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
172pub fn get_factory() -> &'static Mutex<LLMFactory> {
174 &FACTORY
175}
176
177pub fn get_models_manager() -> &'static ModelsManager {
179 &MODELS_MANAGER
180}
181
182pub use vtcode_llm::factory_types::infer_provider_from_model;
188
189pub 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 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
231pub 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
243pub 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 let registered: Vec<String> = factory.list_providers();
256 for key in ®istered {
257 if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
258 factory.remove_provider(key);
259 }
260 }
261
262 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(®_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 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 #[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 #[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}