1use super::cgp::{CanBuildProvider, CanDescribeProvider, register_builtin_cgp_providers};
2use super::model_resolver::{ModelResolver, heuristic_provider_from_model};
3use crate::config::TimeoutsConfig;
4use crate::config::core::{AnthropicConfig, ModelConfig, OpenAIConfig, PromptCachingConfig};
5use crate::config::models::Provider;
6use crate::ctx_err;
7use crate::llm::provider::{LLMError, LLMProvider};
8use crate::llm::providers::OpenAIProvider;
9use crate::llm::providers::openai::CustomProviderAuthHandle;
10use hashbrown::HashMap;
11use std::path::PathBuf;
12use vtcode_config::auth::CopilotAuthConfig;
13use vtcode_config::auth::OpenAIChatGptAuthHandle;
14
15type ProviderFactory = Box<dyn Fn(ProviderConfig) -> Box<dyn LLMProvider> + Send + Sync>;
16
17const BUILTIN_PROVIDER_KEYS: &[&str] = &[
18 "openai",
19 "anthropic",
20 "gemini",
21 "copilot",
22 "deepseek",
23 "openrouter",
24 "ollama",
25 "lmstudio",
26 "llamacpp",
27 "moonshot",
28 "zai",
29 "minimax",
30 "mimo",
31 "mistral",
32 "huggingface",
33 "openresponses",
34 "opencode-zen",
35 "opencode-go",
36 "qwen",
37 "stepfun",
38 "evolink",
39 "poolside",
40];
41
42pub struct LLMFactory {
44 providers: HashMap<String, ProviderFactory>,
45}
46
47#[derive(Debug, Clone)]
48pub struct ProviderConfig {
49 pub api_key: Option<String>,
50 pub openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
51 pub copilot_auth: Option<CopilotAuthConfig>,
52 pub base_url: Option<String>,
53 pub model: Option<String>,
54 pub prompt_cache: Option<PromptCachingConfig>,
55 pub timeouts: Option<TimeoutsConfig>,
56 pub openai: Option<OpenAIConfig>,
57 pub anthropic: Option<AnthropicConfig>,
58 pub model_behavior: Option<ModelConfig>,
59 pub workspace_root: Option<PathBuf>,
60}
61
62impl LLMFactory {
63 pub fn new() -> Self {
64 let mut factory = Self {
65 providers: HashMap::new(),
66 };
67
68 register_builtin_cgp_providers(&mut factory);
69
70 factory
71 }
72
73 pub fn register_cgp_provider<Ctx>(&mut self)
74 where
75 Ctx: CanDescribeProvider + CanBuildProvider + 'static,
76 {
77 self.register_provider(Ctx::PROVIDER_KEY, Ctx::build_provider);
78 }
79
80 pub fn register_provider<F>(&mut self, name: &str, factory_fn: F)
82 where
83 F: Fn(ProviderConfig) -> Box<dyn LLMProvider> + Send + Sync + 'static,
84 {
85 self.providers
86 .insert(name.to_string(), Box::new(factory_fn));
87 }
88
89 pub fn create_provider(
91 &self,
92 provider_name: &str,
93 config: ProviderConfig,
94 ) -> Result<Box<dyn LLMProvider>, LLMError> {
95 let factory_fn =
96 self.providers
97 .get(provider_name)
98 .ok_or_else(|| LLMError::InvalidRequest {
99 message: format!("Unknown provider: {}", provider_name),
100 metadata: None,
101 })?;
102
103 Ok(factory_fn(config))
104 }
105
106 pub fn list_providers(&self) -> Vec<String> {
108 self.providers.keys().cloned().collect()
109 }
110
111 pub fn remove_provider(&mut self, name: &str) {
113 self.providers.remove(name);
114 }
115
116 pub fn provider_from_model(&self, model: &str) -> Option<String> {
118 heuristic_provider_from_model(model).map(|provider| provider.to_string())
119 }
120}
121
122pub fn infer_provider(override_provider: Option<&str>, model: &str) -> Option<Provider> {
129 ModelResolver::resolve_provider(override_provider, model, &[])
130}
131
132impl Default for LLMFactory {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138use std::sync::{LazyLock, Mutex};
139
140use crate::models_manager::ModelsManager;
141
142static FACTORY: LazyLock<Mutex<LLMFactory>> = LazyLock::new(|| Mutex::new(LLMFactory::new()));
143
144static MODELS_MANAGER: LazyLock<ModelsManager> = LazyLock::new(ModelsManager::new);
145
146pub fn get_factory() -> &'static Mutex<LLMFactory> {
148 &FACTORY
149}
150
151pub fn get_models_manager() -> &'static ModelsManager {
153 &MODELS_MANAGER
154}
155
156pub fn infer_provider_from_model(model: &str) -> Option<Provider> {
161 ModelResolver::resolve_provider(None, model, &[]).or_else(|| {
162 let family = crate::models_manager::find_family_for_model(model);
163 (family.family != "unknown").then_some(family.provider)
164 })
165}
166
167pub fn create_provider_for_model(
169 model: &str,
170 api_key: String,
171 prompt_cache: Option<PromptCachingConfig>,
172 model_behavior: Option<ModelConfig>,
173) -> Result<Box<dyn LLMProvider>, LLMError> {
174 if !get_models_manager().model_exists_sync(model) {
176 tracing::warn!(
177 model = model,
178 "Model not found in ModelsManager presets, proceeding with factory heuristics"
179 );
180 }
181
182 let provider_name = infer_provider_from_model(model)
183 .map(|provider| provider.to_string())
184 .ok_or_else(|| LLMError::InvalidRequest {
185 message: format!("Cannot determine provider for model: {}", model),
186 metadata: None,
187 })?;
188 let factory = get_factory().lock().map_err(|_| LLMError::Provider {
189 message: ctx_err!("llm factory", "lock poisoned"),
190 metadata: None,
191 })?;
192
193 factory.create_provider(
194 &provider_name,
195 ProviderConfig {
196 api_key: Some(api_key),
197 openai_chatgpt_auth: None,
198 copilot_auth: None,
199 base_url: None,
200 model: Some(model.to_string()),
201 prompt_cache,
202 timeouts: None,
203 openai: None,
204 anthropic: None,
205 model_behavior,
206 workspace_root: None,
207 },
208 )
209}
210
211pub fn create_provider_with_config(
213 provider_name: &str,
214 config: ProviderConfig,
215) -> Result<Box<dyn LLMProvider>, LLMError> {
216 let factory = get_factory().lock().map_err(|_| LLMError::Provider {
217 message: ctx_err!("llm factory", "lock poisoned"),
218 metadata: None,
219 })?;
220 factory.create_provider(provider_name, config)
221}
222
223pub fn register_custom_providers(custom_providers: &[vtcode_config::core::CustomProviderConfig]) {
229 let Ok(mut factory) = get_factory().lock() else {
230 tracing::error!("Failed to lock LLM factory for custom provider registration");
231 return;
232 };
233
234 let registered: Vec<String> = factory.list_providers();
236 for key in ®istered {
237 if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
238 factory.remove_provider(key);
239 }
240 }
241
242 for cp in custom_providers {
244 if let Err(msg) = cp.validate() {
245 tracing::warn!("Skipping invalid custom provider: {msg}");
246 continue;
247 }
248
249 let key = cp.name.to_lowercase();
250 let display_name = cp.display_name.clone();
251 let default_base_url = cp.base_url.clone();
252 let default_model = cp.model.clone();
253 let supported_models = cp.effective_models();
254 let auth_config = cp.auth.clone();
255 let api_key_env = cp.resolved_api_key_env();
256 let reg_key = key.clone();
257
258 factory.register_provider(®_key, move |config: ProviderConfig| {
259 let ProviderConfig {
260 api_key,
261 base_url,
262 model,
263 prompt_cache,
264 timeouts,
265 openai,
266 model_behavior,
267 workspace_root,
268 ..
269 } = config;
270
271 let api_key = if auth_config.is_some() {
272 None
273 } else {
274 api_key.or_else(|| std::env::var(&api_key_env).ok())
275 };
276
277 let model = model
278 .filter(|m| !m.trim().is_empty())
279 .unwrap_or_else(|| default_model.clone());
280
281 let base_url = base_url
282 .clone()
283 .filter(|u| !u.trim().is_empty())
284 .unwrap_or_else(|| default_base_url.clone());
285 let custom_provider_auth = auth_config
286 .clone()
287 .map(|auth| CustomProviderAuthHandle::new(auth, workspace_root.clone()));
288
289 let models_override = if supported_models.len() > 1
290 || (supported_models.len() == 1 && supported_models[0] != model)
291 {
292 Some(supported_models.clone())
293 } else {
294 None
295 };
296
297 Box::new(OpenAIProvider::from_custom_config(
298 key.clone(),
299 display_name.clone(),
300 api_key,
301 Some(model),
302 Some(base_url),
303 prompt_cache,
304 timeouts,
305 openai,
306 model_behavior,
307 custom_provider_auth,
308 models_override,
309 ))
310 });
311
312 tracing::trace!(
313 provider = cp.name,
314 display_name = cp.display_name,
315 "Registered custom OpenAI-compatible provider"
316 );
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::config::core::CustomProviderConfig;
324 use crate::config::core::{AnthropicConfig, OpenAIConfig};
325 use crate::llm::provider_config::{
326 AnthropicProviderConfig, GeminiProviderConfig, OpenAIProviderConfig,
327 };
328 use crate::llm::providers::OllamaProvider;
329
330 #[test]
331 fn builtin_cgp_registration_exposes_expected_provider_keys() {
332 let factory = LLMFactory::new();
333 let mut providers = factory.list_providers();
334 providers.sort();
335
336 assert_eq!(
337 providers,
338 vec![
339 "anthropic",
340 "copilot",
341 "deepseek",
342 "evolink",
343 "gemini",
344 "huggingface",
345 "llamacpp",
346 "lmstudio",
347 "mimo",
348 "minimax",
349 "mistral",
350 "moonshot",
351 "ollama",
352 "openai",
353 "opencode-go",
354 "opencode-zen",
355 "openresponses",
356 "openrouter",
357 "poolside",
358 "qwen",
359 "stepfun",
360 "zai",
361 ]
362 );
363 }
364
365 #[test]
366 fn standard_provider_builds_through_cgp_registration() {
367 let factory = LLMFactory::new();
368 let provider = factory
369 .create_provider(
370 <GeminiProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
371 ProviderConfig {
372 api_key: Some("test-key".to_string()),
373 openai_chatgpt_auth: None,
374 copilot_auth: None,
375 base_url: None,
376 model: Some(
377 crate::config::constants::models::google::GEMINI_3_FLASH_PREVIEW
378 .to_string(),
379 ),
380 prompt_cache: None,
381 timeouts: None,
382 openai: None,
383 anthropic: None,
384 model_behavior: None,
385 workspace_root: None,
386 },
387 )
388 .expect("built-in cgp registration should build");
389
390 assert_eq!(provider.name(), "gemini");
391 }
392
393 #[test]
394 fn openai_build_preserves_provider_specific_config_path() {
395 let factory = LLMFactory::new();
396 let provider = factory
397 .create_provider(
398 <OpenAIProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
399 ProviderConfig {
400 api_key: Some("test-key".to_string()),
401 openai_chatgpt_auth: None,
402 copilot_auth: None,
403 base_url: None,
404 model: Some(
405 crate::config::constants::models::openai::DEFAULT_MODEL.to_string(),
406 ),
407 prompt_cache: None,
408 timeouts: None,
409 openai: Some(OpenAIConfig {
410 websocket_mode: true,
411 ..OpenAIConfig::default()
412 }),
413 anthropic: Some(AnthropicConfig::default()),
414 model_behavior: None,
415 workspace_root: None,
416 },
417 )
418 .expect("openai cgp registration should build");
419
420 assert_eq!(provider.name(), "openai");
421 }
422
423 #[test]
424 fn anthropic_build_preserves_provider_specific_config_path() {
425 let factory = LLMFactory::new();
426 let provider = factory
427 .create_provider(
428 <AnthropicProviderConfig as CanDescribeProvider>::PROVIDER_KEY,
429 ProviderConfig {
430 api_key: Some("test-key".to_string()),
431 openai_chatgpt_auth: None,
432 copilot_auth: None,
433 base_url: None,
434 model: Some(
435 crate::config::constants::models::anthropic::DEFAULT_MODEL.to_string(),
436 ),
437 prompt_cache: None,
438 timeouts: None,
439 openai: None,
440 anthropic: Some(AnthropicConfig {
441 count_tokens_enabled: true,
442 ..AnthropicConfig::default()
443 }),
444 model_behavior: None,
445 workspace_root: None,
446 },
447 )
448 .expect("anthropic cgp registration should build");
449
450 assert_eq!(provider.name(), "anthropic");
451 }
452
453 #[test]
454 fn custom_provider_registration_still_coexists_with_cgp_builtins() {
455 let mut factory = LLMFactory::new();
456 factory.register_provider("custom-test", |_config| {
457 Box::new(OllamaProvider::from_config(
458 None,
459 Some("gpt-oss:20b".to_string()),
460 Some("http://localhost:11434".to_string()),
461 None,
462 None,
463 None,
464 None,
465 ))
466 });
467
468 let custom = factory
469 .create_provider(
470 "custom-test",
471 ProviderConfig {
472 api_key: None,
473 openai_chatgpt_auth: None,
474 copilot_auth: None,
475 base_url: None,
476 model: None,
477 prompt_cache: None,
478 timeouts: None,
479 openai: None,
480 anthropic: None,
481 model_behavior: None,
482 workspace_root: None,
483 },
484 )
485 .expect("custom provider should still register");
486 let builtin = factory
487 .create_provider(
488 "openai",
489 ProviderConfig {
490 api_key: Some("test-key".to_string()),
491 openai_chatgpt_auth: None,
492 copilot_auth: None,
493 base_url: None,
494 model: Some(
495 crate::config::constants::models::openai::DEFAULT_MODEL.to_string(),
496 ),
497 prompt_cache: None,
498 timeouts: None,
499 openai: None,
500 anthropic: None,
501 model_behavior: None,
502 workspace_root: None,
503 },
504 )
505 .expect("builtin provider should still build");
506
507 assert_eq!(custom.name(), "ollama");
508 assert_eq!(builtin.name(), "openai");
509 }
510
511 #[test]
512 #[serial_test::serial(global_llm_factory)]
513 fn custom_openai_compatible_provider_uses_configured_display_name() {
514 register_custom_providers(&[CustomProviderConfig {
515 name: "mycorp".to_string(),
516 display_name: "MyCorporateName".to_string(),
517 base_url: "https://llm.corp.example/v1".to_string(),
518 api_key_env: "MYCORP_API_KEY".to_string(),
519 auth: None,
520 model: "gpt-5-mini".to_string(),
521 models: Vec::new(),
522 }]);
523
524 let provider = create_provider_with_config(
525 "mycorp",
526 ProviderConfig {
527 api_key: None,
528 openai_chatgpt_auth: None,
529 copilot_auth: None,
530 base_url: None,
531 model: Some("gpt-5-mini".to_string()),
532 prompt_cache: None,
533 timeouts: None,
534 openai: Some(OpenAIConfig::default()),
535 anthropic: None,
536 model_behavior: None,
537 workspace_root: None,
538 },
539 )
540 .expect("custom provider should register");
541
542 assert_eq!(provider.name(), "mycorp");
543 assert_eq!(provider.supported_models(), vec!["gpt-5-mini".to_string()]);
544
545 register_custom_providers(&[]);
546 }
547
548 fn atlas_cloud_provider_config() -> CustomProviderConfig {
552 CustomProviderConfig {
553 name: "atlascloud".to_string(),
554 display_name: "Atlas Cloud".to_string(),
555 base_url: "https://api.atlascloud.ai/v1".to_string(),
556 api_key_env: "ATLASCLOUD_API_KEY".to_string(),
557 auth: None,
558 model: "deepseek-ai/deepseek-v4-flash".to_string(),
559 models: vec![
560 "deepseek-ai/deepseek-v4-flash".to_string(),
561 "deepseek-ai/deepseek-v4-pro".to_string(),
562 "deepseek-ai/DeepSeek-V3-0324".to_string(),
563 "deepseek-ai/DeepSeek-V3.1".to_string(),
564 "deepseek-ai/deepseek-r1-0528".to_string(),
565 "deepseek-ai/deepseek-ocr".to_string(),
566 "qwen/qwen3.6-35b-a3b".to_string(),
567 "qwen/qwen3.6-plus".to_string(),
568 "qwen/qwen3.5-122b-a10b".to_string(),
569 "qwen/qwen3.5-35b-a3b".to_string(),
570 "qwen/qwen3-coder-next".to_string(),
571 "qwen/qwen3.5-397b-a17b".to_string(),
572 "qwen/qwen3-max-2026-01-23".to_string(),
573 "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
574 "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
575 "qwen/qwen3-next-80b-a3b-thinking".to_string(),
576 "qwen/qwen3-next-80b-a3b-instruct".to_string(),
577 "moonshotai/kimi-k2.6".to_string(),
578 "moonshotai/kimi-k2.5".to_string(),
579 "moonshotai/Kimi-K2-Thinking".to_string(),
580 "moonshotai/Kimi-K2-Instruct".to_string(),
581 "moonshotai/Kimi-K2-Instruct-0905".to_string(),
582 "zai-org/glm-5.1".to_string(),
583 "zai-org/glm-5v-turbo".to_string(),
584 "zai-org/glm-5-turbo".to_string(),
585 "zai-org/glm-5".to_string(),
586 "zai-org/glm-4.7".to_string(),
587 "minimaxai/minimax-m2.7".to_string(),
588 "minimaxai/minimax-m2.5".to_string(),
589 "minimaxai/minimax-m2.1".to_string(),
590 "kwaipilot/kat-coder-pro-v2".to_string(),
591 "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
592 ],
593 }
594 }
595
596 #[test]
603 #[serial_test::serial(global_llm_factory)]
604 fn atlas_cloud_registers_as_openai_compatible_custom_provider() {
605 register_custom_providers(&[atlas_cloud_provider_config()]);
606
607 let provider = create_provider_with_config(
608 "atlascloud",
609 ProviderConfig {
610 api_key: None,
611 openai_chatgpt_auth: None,
612 copilot_auth: None,
613 base_url: None,
614 model: None,
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("atlas cloud should resolve as an OpenAI-compatible custom provider");
624
625 assert_eq!(provider.name(), "atlascloud");
626 assert_eq!(
627 provider.supported_models(),
628 vec![
629 "deepseek-ai/deepseek-v4-flash".to_string(),
630 "deepseek-ai/deepseek-v4-pro".to_string(),
631 "deepseek-ai/DeepSeek-V3-0324".to_string(),
632 "deepseek-ai/DeepSeek-V3.1".to_string(),
633 "deepseek-ai/deepseek-r1-0528".to_string(),
634 "deepseek-ai/deepseek-ocr".to_string(),
635 "qwen/qwen3.6-35b-a3b".to_string(),
636 "qwen/qwen3.6-plus".to_string(),
637 "qwen/qwen3.5-122b-a10b".to_string(),
638 "qwen/qwen3.5-35b-a3b".to_string(),
639 "qwen/qwen3-coder-next".to_string(),
640 "qwen/qwen3.5-397b-a17b".to_string(),
641 "qwen/qwen3-max-2026-01-23".to_string(),
642 "qwen/qwen3-235b-a22b-thinking-2507".to_string(),
643 "qwen/qwen3-30b-a3b-thinking-2507".to_string(),
644 "qwen/qwen3-next-80b-a3b-thinking".to_string(),
645 "qwen/qwen3-next-80b-a3b-instruct".to_string(),
646 "moonshotai/kimi-k2.6".to_string(),
647 "moonshotai/kimi-k2.5".to_string(),
648 "moonshotai/Kimi-K2-Thinking".to_string(),
649 "moonshotai/Kimi-K2-Instruct".to_string(),
650 "moonshotai/Kimi-K2-Instruct-0905".to_string(),
651 "zai-org/glm-5.1".to_string(),
652 "zai-org/glm-5v-turbo".to_string(),
653 "zai-org/glm-5-turbo".to_string(),
654 "zai-org/glm-5".to_string(),
655 "zai-org/glm-4.7".to_string(),
656 "minimaxai/minimax-m2.7".to_string(),
657 "minimaxai/minimax-m2.5".to_string(),
658 "minimaxai/minimax-m2.1".to_string(),
659 "kwaipilot/kat-coder-pro-v2".to_string(),
660 "Alibaba-NLP/Tongyi-DeepResearch-30B-A3B".to_string(),
661 ]
662 );
663
664 register_custom_providers(&[]);
665 }
666
667 #[test]
673 #[serial_test::serial(global_llm_factory)]
674 fn register_custom_providers_with_empty_input_clears_custom_but_keeps_builtins() {
675 register_custom_providers(&[atlas_cloud_provider_config()]);
676
677 {
678 let factory = get_factory().lock().expect("factory lock");
679 assert!(
680 factory.list_providers().iter().any(|k| k == "atlascloud"),
681 "custom provider should be registered before clearing"
682 );
683 }
684
685 register_custom_providers(&[]);
686
687 let factory = get_factory().lock().expect("factory lock");
688 let providers = factory.list_providers();
689 assert!(
690 !providers.iter().any(|k| k == "atlascloud"),
691 "custom provider should be unregistered after sync with empty input"
692 );
693 for builtin in BUILTIN_PROVIDER_KEYS {
694 assert!(
695 providers.iter().any(|k| k == builtin),
696 "built-in provider {builtin} must survive custom-provider sync"
697 );
698 }
699 }
700
701 #[test]
702 fn create_provider_for_bare_minimax_model_uses_minimax_provider() {
703 let provider =
704 create_provider_for_model("MiniMax-M2.5", "test-key".to_string(), None, None)
705 .expect("bare minimax model should resolve to minimax provider");
706
707 assert_eq!(provider.name(), "minimax");
708 }
709
710 #[test]
711 fn create_provider_for_mistral_model_uses_mistral_provider() {
712 let provider =
713 create_provider_for_model("mistral-large-2512", "test-key".to_string(), None, None)
714 .expect("mistral models should resolve through mistral provider");
715
716 assert_eq!(provider.name(), "mistral");
717 }
718
719 #[test]
720 fn create_provider_for_openai_repo_id_uses_openrouter_provider() {
721 let provider =
722 create_provider_for_model("openai/gpt-oss-20b", "test-key".to_string(), None, None)
723 .expect("repo identifiers should preserve openrouter routing");
724
725 assert_eq!(provider.name(), "openrouter");
726 }
727
728 #[test]
729 fn create_provider_for_unknown_model_returns_error() {
730 match create_provider_for_model("totally-unknown-model", "test-key".to_string(), None, None)
731 {
732 Err(LLMError::InvalidRequest { .. }) => {}
733 Err(error) => panic!("expected invalid request error, got {error:?}"),
734 Ok(_) => panic!("unknown models should remain rejected"),
735 }
736 }
737}