Skip to main content

vtcode_core/cli/
models_commands.rs

1//! Model management command handlers with concise, actionable output
2
3use super::args::{Cli, ModelCommands};
4use crate::config::models::supported_models_for_provider;
5use crate::llm::factory::{ProviderConfig, create_provider_with_config, get_factory};
6use crate::utils::colors::{bold, cyan, dimmed, green, red, underline, yellow};
7use crate::utils::dot_config::{DotConfig, get_dot_manager, load_user_config};
8use anyhow::{Context, Result, anyhow};
9
10/// Handle model management commands with concise output
11pub async fn handle_models_command(cli: &Cli, command: &ModelCommands) -> Result<()> {
12    match command {
13        ModelCommands::List => handle_list_models(cli).await,
14        ModelCommands::SetProvider { provider } => handle_set_provider(cli, provider).await,
15        ModelCommands::SetModel { model } => handle_set_model(cli, model).await,
16        ModelCommands::Config { provider, api_key, base_url, model } => {
17            handle_config_provider(cli, provider, api_key.as_deref(), base_url.as_deref(), model.as_deref()).await
18        }
19        ModelCommands::Test { provider } => handle_test_provider(cli, provider).await,
20        ModelCommands::Compare => handle_compare_models(cli).await,
21        ModelCommands::Info { model } => handle_model_info(cli, model).await,
22    }
23}
24
25/// Display available providers and models with status
26async fn handle_list_models(_cli: &Cli) -> Result<()> {
27    println!("{}", underline(&bold("Available Providers & Models")));
28    println!();
29    println!("{}", dimmed("Loading available providers and models..."));
30
31    let config = load_user_config().await.unwrap_or_default();
32    let factory = {
33        let guard = get_factory()
34            .lock()
35            .map_err(|err| anyhow!("LLM factory lock poisoned while listing providers: {err}"))?;
36        guard.list_providers()
37    }; // Lock is released here when guard goes out of scope
38    let providers = factory;
39
40    for provider_name in &providers {
41        let is_current = config.preferences.default_provider == *provider_name;
42        let status = if is_current { "✦" } else { "  " };
43        let provider_display = format!("{}{}", status, provider_name.to_uppercase());
44
45        let colored_provider = if is_current {
46            green(&bold(&provider_display))
47        } else {
48            bold(&provider_display)
49        };
50        println!("{colored_provider}");
51
52        if let Some(models) = supported_models_for_provider(provider_name) {
53            let current_model = &config.preferences.default_model;
54
55            for model in models.iter().take(3) {
56                let is_current_model = current_model == model;
57                let model_status = if is_current_model { "*" } else { "  " };
58                let colored_model = if is_current_model {
59                    cyan(&bold(model))
60                } else {
61                    cyan(model)
62                };
63                println!("  {model_status}{colored_model}");
64            }
65            if models.len() > 3 {
66                println!("  {} +{} more models", dimmed("..."), models.len() - 3);
67            }
68        } else {
69            println!("  {}", yellow("・  Setup required"));
70        }
71
72        let configured = is_provider_configured(&config, provider_name);
73        let config_status = if configured {
74            green("✓ Configured")
75        } else {
76            yellow("・  Not configured")
77        };
78        println!("  {config_status}");
79        println!();
80    }
81
82    println!("{}", underline(&bold("・ Current Config")));
83    println!("Provider: {}", cyan(&config.preferences.default_provider));
84    println!("Model: {}", cyan(&config.preferences.default_model));
85
86    Ok(())
87}
88
89/// Check if provider is configured
90fn is_provider_configured(config: &DotConfig, provider: &str) -> bool {
91    let (provider_config, default_enabled) = match provider {
92        "openai" => (config.providers.openai.as_ref(), false),
93        "anthropic" => (config.providers.anthropic.as_ref(), false),
94        "gemini" => (config.providers.gemini.as_ref(), false),
95        "deepseek" => (config.providers.deepseek.as_ref(), false),
96        "meta" => (config.providers.meta.as_ref(), false),
97        "openrouter" => (config.providers.openrouter.as_ref(), false),
98        "ollama" => (config.providers.ollama.as_ref(), true),
99        "lmstudio" => (config.providers.lmstudio.as_ref(), true),
100        "llamacpp" => (config.providers.llamacpp.as_ref(), true),
101        "stepfun" => (config.providers.stepfun.as_ref(), false),
102        "evolink" => (config.providers.evolink.as_ref(), false),
103        "merge-gateway" => (config.providers.merge_gateway.as_ref(), false),
104        _ => return false,
105    };
106    provider_config.map(|p| p.enabled).unwrap_or(default_enabled)
107}
108
109/// Set default provider
110async fn handle_set_provider(_cli: &Cli, provider: &str) -> Result<()> {
111    let available = {
112        let factory = get_factory()
113            .lock()
114            .map_err(|err| anyhow!("LLM factory lock poisoned while setting provider: {err}"))?;
115        factory.list_providers()
116    }; // Lock is released here when factory guard goes out of scope
117
118    if !available.iter().any(|p| p == provider) {
119        return Err(anyhow!("Unknown provider '{}'. Available: {}", provider, available.join(", ")));
120    }
121
122    let manager = {
123        let guard = get_dot_manager()
124            .context("Failed to initialize dot manager while setting provider")?
125            .lock()
126            .map_err(|err| anyhow!("Dot manager lock poisoned while setting provider: {err}"))?;
127        guard.clone()
128    }; // Lock is released here when guard goes out of scope
129    manager
130        .update_config(|config| {
131            config.preferences.default_provider = provider.to_string();
132        })
133        .await?;
134
135    println!("{} Provider set to: {}", green("✓"), green(&bold(provider)));
136    println!(
137        "{} Configure: {}",
138        cyan("・"),
139        dimmed(&format!("vtcode models config {provider} --api-key YOUR_KEY"))
140    );
141
142    Ok(())
143}
144
145/// Set default model
146async fn handle_set_model(_cli: &Cli, model: &str) -> Result<()> {
147    let manager = {
148        let guard = get_dot_manager()
149            .context("Failed to initialize dot manager while setting model")?
150            .lock()
151            .map_err(|err| anyhow!("Dot manager lock poisoned while setting model: {err}"))?;
152        guard.clone()
153    }; // Lock is released here when guard goes out of scope
154    manager
155        .update_config(|config| {
156            config.preferences.default_model = model.to_string();
157        })
158        .await?;
159
160    println!("{} Model set to: {}", green("✓"), green(&bold(model)));
161    Ok(())
162}
163
164/// Configure provider settings
165async fn handle_config_provider(
166    _cli: &Cli,
167    provider: &str,
168    api_key: Option<&str>,
169    base_url: Option<&str>,
170    model: Option<&str>,
171) -> Result<()> {
172    // Clone manager once and reuse for both operations
173    let manager = {
174        let guard = get_dot_manager()
175            .context("Failed to initialize dot manager while configuring provider")?
176            .lock()
177            .map_err(|err| anyhow!("Dot manager lock poisoned while configuring provider: {err}"))?;
178        guard.clone()
179    };
180
181    let mut config = manager.load_config().await?;
182
183    match provider {
184        "openai" | "anthropic" | "gemini" | "deepseek" | "meta" | "openrouter" | "ollama" | "lmstudio" | "llamacpp"
185        | "stepfun" | "evolink" | "merge-gateway" => {
186            configure_standard_provider(&mut config, provider, api_key, base_url, model)?;
187        }
188        _ => return Err(anyhow!("Unsupported provider: {provider}")),
189    }
190
191    // Reuse the same manager instance
192    manager.save_config(&config).await?;
193
194    Ok(())
195}
196
197/// Configure standard providers
198fn configure_standard_provider(
199    config: &mut DotConfig,
200    provider: &str,
201    api_key: Option<&str>,
202    base_url: Option<&str>,
203    model: Option<&str>,
204) -> Result<()> {
205    // Helper macro to reduce boilerplate
206    macro_rules! get_provider_config {
207        ($field:ident) => {
208            config.providers.$field.get_or_insert_with(Default::default)
209        };
210    }
211
212    let provider_config = match provider {
213        "openai" => get_provider_config!(openai),
214        "anthropic" => get_provider_config!(anthropic),
215        "gemini" => get_provider_config!(gemini),
216        "deepseek" => get_provider_config!(deepseek),
217        "meta" => get_provider_config!(meta),
218        "openrouter" => get_provider_config!(openrouter),
219        "ollama" => get_provider_config!(ollama),
220        "lmstudio" => get_provider_config!(lmstudio),
221        "llamacpp" => get_provider_config!(llamacpp),
222        "minimax" => get_provider_config!(anthropic), // Note: maps to anthropic
223        "stepfun" => get_provider_config!(stepfun),
224        "evolink" => get_provider_config!(evolink),
225        "merge-gateway" => get_provider_config!(merge_gateway),
226        _ => return Err(anyhow!("Unknown provider: {provider}")),
227    };
228
229    if let Some(key) = api_key {
230        provider_config.api_key = Some(key.to_owned());
231    }
232    if let Some(url) = base_url {
233        provider_config.base_url = Some(url.to_owned());
234    }
235    if let Some(m) = model {
236        provider_config.model = Some(m.to_owned());
237    }
238
239    // Local providers are enabled by default; others require an API key
240    provider_config.enabled = matches!(provider, "ollama" | "lmstudio" | "llamacpp")
241        || api_key.is_some()
242        || provider_config.api_key.is_some();
243
244    Ok(())
245}
246
247/// Test provider connectivity
248async fn handle_test_provider(_cli: &Cli, provider: &str) -> Result<()> {
249    println!("{} Testing {}...", cyan("・"), bold(provider));
250
251    let config = load_user_config().await?;
252    let (api_key, base_url, model) = get_provider_credentials(&config, provider)?;
253
254    let provider_instance = create_provider_with_config(
255        provider,
256        ProviderConfig {
257            api_key,
258            openai_chatgpt_auth: None,
259            copilot_auth: None,
260            base_url,
261            model: model.clone(),
262            prompt_cache: None,
263            timeouts: None,
264            openai: None,
265            anthropic: None,
266            model_behavior: None,
267            workspace_root: None,
268        },
269    )?;
270
271    let test_request = crate::llm::provider::LLMRequest {
272        messages: std::sync::Arc::new(vec![crate::llm::provider::Message::user("test".to_owned())]),
273        model: model.clone().unwrap_or_else(|| "test".to_owned()),
274        max_tokens: Some(10),
275        temperature: Some(0.0),
276        ..Default::default()
277    };
278
279    match provider_instance.generate(test_request).await {
280        Ok(response) => {
281            let content = response.content.unwrap_or_default();
282            if content.to_lowercase().contains("ok") {
283                println!("{} {} test successful!", green("✓"), green(&bold(provider)));
284            } else {
285                println!("{} {} responded unexpectedly", yellow("・"), yellow(&bold(provider)));
286            }
287        }
288        Err(e) => {
289            println!("{} {} test failed: {}", red("✦"), red(&bold(provider)), e);
290        }
291    }
292
293    Ok(())
294}
295
296/// Get provider credentials
297fn get_provider_credentials(
298    config: &DotConfig,
299    provider: &str,
300) -> Result<(Option<String>, Option<String>, Option<String>)> {
301    let provider_config = match provider {
302        "openai" => config.providers.openai.as_ref(),
303        "anthropic" => config.providers.anthropic.as_ref(),
304        "gemini" => config.providers.gemini.as_ref(),
305        "deepseek" => config.providers.deepseek.as_ref(),
306        "meta" => config.providers.meta.as_ref(),
307        "openrouter" => config.providers.openrouter.as_ref(),
308        "ollama" => config.providers.ollama.as_ref(),
309        "lmstudio" => config.providers.lmstudio.as_ref(),
310        "llamacpp" => config.providers.llamacpp.as_ref(),
311        "stepfun" => config.providers.stepfun.as_ref(),
312        "evolink" => config.providers.evolink.as_ref(),
313        "merge-gateway" => config.providers.merge_gateway.as_ref(),
314        _ => return Err(anyhow!("Unknown provider: {provider}")),
315    };
316
317    Ok(provider_config
318        .map(|c| (c.api_key.clone(), c.base_url.clone(), c.model.clone()))
319        .unwrap_or((None, None, None)))
320}
321
322/// Compare model performance (placeholder)
323async fn handle_compare_models(_cli: &Cli) -> Result<()> {
324    println!("{}", underline(&bold("✦ Model Performance Comparison")));
325    println!();
326    println!("{} Coming soon! Will compare:", yellow("✦"));
327    println!("• Response times • Token usage • Cost • Quality");
328    println!();
329    println!("{} Use 'vtcode models list' for available models", cyan("・"));
330
331    Ok(())
332}
333
334/// Show model information
335async fn handle_model_info(_cli: &Cli, model: &str) -> Result<()> {
336    let resolved = crate::llm::ModelResolver::resolve(None, model, &[], None);
337    println!("{} Model Info: {}", cyan("・"), underline(&bold(model)));
338    println!();
339
340    println!("Model: {}", cyan(model));
341    if let Some(resolved) = resolved {
342        println!("Provider: {}", resolved.provider.label());
343        if let Some(context_window) = resolved.context_window() {
344            println!("Context: {context_window}");
345        }
346        println!(
347            "Reasoning: {}",
348            if resolved.reasoning_supported() {
349                green("Yes")
350            } else {
351                yellow("No")
352            }
353        );
354        println!(
355            "Tools: {}",
356            if resolved.supports_tool_calls() {
357                green("Yes")
358            } else {
359                yellow("No")
360            }
361        );
362        println!("Availability: {}", model_availability_label(&resolved.availability));
363    } else {
364        println!("Provider: {}", infer_provider_from_model(model));
365        println!("Availability: {}", yellow("Unknown"));
366    }
367    println!();
368    println!("{} Check docs/models.json for specs", cyan("・"));
369
370    Ok(())
371}
372
373/// Infer provider from model name
374fn infer_provider_from_model(model: &str) -> &'static str {
375    crate::llm::factory::infer_provider(None, model)
376        .map(|provider| provider.label())
377        .unwrap_or("Unknown")
378}
379
380fn model_availability_label(availability: &crate::llm::ModelAvailability) -> &'static str {
381    match availability {
382        crate::llm::ModelAvailability::Available => "Available",
383        crate::llm::ModelAvailability::MissingCredential => "Missing credential",
384        crate::llm::ModelAvailability::ManagedAuthAvailable => "Managed auth",
385        crate::llm::ModelAvailability::Misconfigured => "Misconfigured",
386        crate::llm::ModelAvailability::LocalOnly => "Local only",
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn legacy_model_commands_configure_merge_gateway() {
396        let mut config = DotConfig::default();
397
398        configure_standard_provider(
399            &mut config,
400            "merge-gateway",
401            Some("merge-key"),
402            Some("https://merge-proxy.example/v1/openai"),
403            Some("default_routing"),
404        )
405        .expect("Merge Gateway should be accepted by model configuration");
406
407        let provider = config.providers.merge_gateway.as_ref().expect("Merge Gateway config");
408        assert!(provider.enabled);
409        assert_eq!(provider.api_key.as_deref(), Some("merge-key"));
410        assert_eq!(provider.base_url.as_deref(), Some("https://merge-proxy.example/v1/openai"));
411        assert_eq!(provider.model.as_deref(), Some("default_routing"));
412        assert_eq!(
413            get_provider_credentials(&config, "merge-gateway").expect("Merge Gateway credentials"),
414            (
415                Some("merge-key".to_owned()),
416                Some("https://merge-proxy.example/v1/openai".to_owned()),
417                Some("default_routing".to_owned()),
418            )
419        );
420        assert!(is_provider_configured(&config, "merge-gateway"));
421    }
422}