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 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
42/// LLM provider factory and registry
43pub 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    /// Register a new provider
81    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    /// Create provider instance
90    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    /// List available providers
107    pub fn list_providers(&self) -> Vec<String> {
108        self.providers.keys().cloned().collect()
109    }
110
111    /// Remove a provider registration by name.
112    pub fn remove_provider(&mut self, name: &str) {
113        self.providers.remove(name);
114    }
115
116    /// Determine provider name from model string
117    pub fn provider_from_model(&self, model: &str) -> Option<String> {
118        heuristic_provider_from_model(model).map(|provider| provider.to_string())
119    }
120}
121
122/// Infer a [`Provider`] from an optional override and model string.
123///
124/// Attempts, in order:
125/// 1. Parse the override if provided.
126/// 2. Parse the model into a [`crate::config::models::ModelId`] and return its provider.
127/// 3. Fall back to heuristic detection via [`LLMFactory::provider_from_model`].
128pub 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
146/// Get global factory instance
147pub fn get_factory() -> &'static Mutex<LLMFactory> {
148    &FACTORY
149}
150
151/// Get global models manager instance
152pub fn get_models_manager() -> &'static ModelsManager {
153    &MODELS_MANAGER
154}
155
156/// Infer provider from model slug using ModelsManager presets.
157///
158/// This provides a more accurate provider resolution than heuristic-based
159/// `provider_from_model` by checking against known model presets first.
160pub 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
167/// Create provider from model name and API key
168pub 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    // Validate model exists in ModelsManager (non-blocking check using local presets)
175    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
211/// Create provider with full configuration
212pub 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
223/// Register custom OpenAI-compatible providers from config into the global factory.
224///
225/// This performs a sync/replace: previously registered custom providers are
226/// removed first, then the new set is registered. Built-in providers are
227/// never touched.
228pub 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    // Remove previously registered custom providers (anything not built-in)
235    let registered: Vec<String> = factory.list_providers();
236    for key in &registered {
237        if !BUILTIN_PROVIDER_KEYS.contains(&key.as_str()) {
238            factory.remove_provider(key);
239        }
240    }
241
242    // Register each custom provider
243    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(&reg_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    /// Sample Atlas Cloud config used across custom-provider tests.
549    /// Matches the snippet documented in `docs/providers/atlascloud.md` and
550    /// `vtcode.toml.example`.
551    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    /// Atlas Cloud is OpenAI-compatible and configured via `[[custom_providers]]`.
597    /// This test mirrors what `src/cli/dispatch/commands.rs` does for non-interactive
598    /// flows (`ask`, `review`, `benchmark`, …): register custom providers from
599    /// config before resolving the provider, then resolve it through the same
600    /// factory path the CLI uses, with `model: None` to exercise the
601    /// `default_model` fallback.
602    #[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    /// Calling `register_custom_providers(&[])` must clear any previously
668    /// registered custom providers while leaving built-ins intact. This guards
669    /// the sync/replace contract that the CLI dispatch path depends on (so a
670    /// user removing Atlas Cloud from `vtcode.toml` does not leave a stale
671    /// registration in the global factory).
672    #[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}