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 openrouter: None,
225 model_behavior,
226 workspace_root: None,
227 },
228 &[],
229 )
230}
231
232pub 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
244pub 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 let registered: Vec<String> = factory.list_providers();
257 for key in ®istered {
258 if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
259 factory.remove_provider(key);
260 }
261 }
262
263 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(®_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 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 #[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 #[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}