Skip to main content

leviath_cli/commands/
models.rs

1//! `lev models` - Inspect available models and their capabilities.
2
3use clap::{Args, Subcommand};
4use leviath_providers::{ModelCapabilities, ModelInfo};
5
6use super::run::build_provider_registry_from_config;
7use crate::config::Config;
8
9// ─── CLI types ────────────────────────────────────────────────────────────────
10
11#[derive(Args)]
12pub struct ModelsArgs {
13    #[command(subcommand)]
14    pub command: ModelsCommand,
15}
16
17#[derive(Subcommand)]
18pub enum ModelsCommand {
19    /// List available models and their capabilities
20    List(ListArgs),
21    /// Show capabilities for a specific model
22    Show(ShowArgs),
23}
24
25#[derive(Args)]
26pub struct ListArgs {
27    /// Filter by provider name (anthropic, openai, ollama, openrouter)
28    #[arg(short, long)]
29    pub provider: Option<String>,
30    /// Fetch live model list from provider APIs (slower but complete)
31    #[arg(short = 'r', long)]
32    pub remote: bool,
33    /// Include models from providers this install has no credential for
34    #[arg(short = 'a', long)]
35    pub all: bool,
36}
37
38#[derive(Args)]
39pub struct ShowArgs {
40    /// Model ID to look up
41    pub model: String,
42    /// Provider to query (required for remote lookup)
43    #[arg(short, long)]
44    pub provider: Option<String>,
45    /// Fetch live model list from provider APIs (slower but complete)
46    #[arg(short = 'r', long)]
47    pub remote: bool,
48}
49
50// ─── Entrypoint ───────────────────────────────────────────────────────────────
51
52pub async fn execute(args: ModelsArgs) -> anyhow::Result<()> {
53    match args.command {
54        ModelsCommand::List(a) => list_with_registry(a, &build_provider_registry_from_config).await,
55        ModelsCommand::Show(a) => show_with_registry(a, &build_provider_registry_from_config).await,
56    }
57}
58
59// ─── Built-in model table ─────────────────────────────────────────────────────
60
61/// A single row in the built-in model table.
62struct BuiltinEntry {
63    provider: &'static str,
64    model_id: &'static str,
65    display_name: &'static str,
66    caps: ModelCapabilities,
67}
68
69/// Providers whose entry in [`builtin_table`] is complete enough to say "this
70/// model does not exist" about.
71///
72/// Anthropic, OpenAI and Google publish a short list and this table tracks it.
73/// The rest do not: Ollama serves whatever has been pulled locally, OpenRouter
74/// proxies hundreds of models of which the table lists a sample, and a script
75/// provider defines its own catalog at run time. Naming a model those hosts
76/// have and this build has not heard of is normal, so they are never checked.
77const CLOSED_CATALOG_PROVIDERS: &[&str] = &["anthropic", "openai", "google"];
78
79/// The `(provider, model)` rows [`crate::lint`] checks a blueprint's model
80/// references against, limited to the providers with a closed catalog.
81pub fn closed_catalog_models() -> Vec<(String, String)> {
82    builtin_table()
83        .into_iter()
84        .filter(|e| CLOSED_CATALOG_PROVIDERS.contains(&e.provider))
85        .map(|e| (e.provider.to_string(), e.model_id.to_string()))
86        .collect()
87}
88
89/// Hard-coded capability table for well-known models.
90///
91/// This is used when the provider API is not reachable or `--remote` is not
92/// specified.  Remote results override these values for identical model IDs.
93fn builtin_table() -> Vec<BuiltinEntry> {
94    macro_rules! entry {
95        // Short form - tools defaults to true
96        ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
97         temp=$t:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
98            entry!(
99                $provider,
100                $id,
101                $name,
102                temp = $t,
103                tools = true,
104                ctx = $ctx,
105                out = $out
106            )
107        };
108        // Full form - explicit tools flag
109        ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
110         temp=$t:expr_2021, tools=$to:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
111            BuiltinEntry {
112                provider: $provider,
113                model_id: $id,
114                display_name: $name,
115                caps: ModelCapabilities {
116                    supports_temperature: $t,
117                    supports_streaming: true,
118                    supports_tools: $to,
119                    supports_system_prompt: true,
120                    max_context_tokens: $ctx,
121                    max_output_tokens: $out,
122                },
123            }
124        };
125    }
126
127    vec![
128        // ── Anthropic ──────────────────────────────────────────────────────────
129        entry!(
130            "anthropic",
131            "claude-opus-5",
132            "Claude Opus 5",
133            temp = false,
134            ctx = 1_000_000,
135            out = 128_000
136        ),
137        entry!(
138            "anthropic",
139            "claude-sonnet-5",
140            "Claude Sonnet 5",
141            temp = false,
142            ctx = 1_000_000,
143            out = 128_000
144        ),
145        entry!(
146            "anthropic",
147            "claude-fable-5",
148            "Claude Fable 5",
149            temp = false,
150            ctx = 1_000_000,
151            out = 128_000
152        ),
153        entry!(
154            "anthropic",
155            "claude-opus-4-8",
156            "Claude Opus 4.8",
157            temp = false,
158            ctx = 1_000_000,
159            out = 128_000
160        ),
161        entry!(
162            "anthropic",
163            "claude-opus-4-7",
164            "Claude Opus 4.7",
165            temp = false,
166            ctx = 1_000_000,
167            out = 128_000
168        ),
169        entry!(
170            "anthropic",
171            "claude-opus-4-6",
172            "Claude Opus 4.6",
173            temp = true,
174            ctx = 1_000_000,
175            out = 128_000
176        ),
177        entry!(
178            "anthropic",
179            "claude-sonnet-4-6",
180            "Claude Sonnet 4.6",
181            temp = true,
182            ctx = 1_000_000,
183            out = 128_000
184        ),
185        entry!(
186            "anthropic",
187            "claude-haiku-4-5-20251001",
188            "Claude Haiku 4.5",
189            temp = true,
190            ctx = 200_000,
191            out = 65_536
192        ),
193        // ── OpenAI ─────────────────────────────────────────────────────────────
194        // GPT-5.5 - flagship (Apr 2026), 1M+ context
195        entry!(
196            "openai",
197            "gpt-5.5",
198            "GPT-5.5",
199            temp = true,
200            ctx = 1_050_000,
201            out = 128_000
202        ),
203        entry!(
204            "openai",
205            "gpt-5.4",
206            "GPT-5.4",
207            temp = true,
208            ctx = 1_050_000,
209            out = 128_000
210        ),
211        entry!(
212            "openai",
213            "gpt-5.4-mini",
214            "GPT-5.4 Mini",
215            temp = true,
216            ctx = 400_000,
217            out = 128_000
218        ),
219        entry!(
220            "openai",
221            "gpt-5.4-nano",
222            "GPT-5.4 Nano",
223            temp = true,
224            ctx = 400_000,
225            out = 128_000
226        ),
227        // ── Google (Gemini) ────────────────────────────────────────────────────
228        // Native Google provider entries. Without these, a user whose only key
229        // is a Gemini key saw no model they could run: every Gemini row in this
230        // table routed through OpenRouter, which needs a different key.
231        entry!(
232            "google",
233            "gemini-3.5-flash",
234            "Gemini 3.5 Flash",
235            temp = true,
236            ctx = 1_048_576,
237            out = 65_535
238        ),
239        entry!(
240            "google",
241            "gemini-3.1-pro-preview",
242            "Gemini 3.1 Pro (preview)",
243            temp = true,
244            ctx = 1_048_576,
245            out = 65_535
246        ),
247        entry!(
248            "google",
249            "gemini-3-flash",
250            "Gemini 3 Flash",
251            temp = true,
252            ctx = 1_048_576,
253            out = 65_535
254        ),
255        entry!(
256            "google",
257            "gemini-3.1-flash-lite",
258            "Gemini 3.1 Flash Lite",
259            temp = true,
260            ctx = 1_048_576,
261            out = 65_535
262        ),
263        // ── OpenRouter: Google Gemini ──────────────────────────────────────────
264        entry!(
265            "openrouter",
266            "google/gemini-3.5-flash",
267            "Gemini 3.5 Flash",
268            temp = true,
269            ctx = 1_048_576,
270            out = 65_536
271        ),
272        entry!(
273            "openrouter",
274            "google/gemini-2.5-pro",
275            "Gemini 2.5 Pro",
276            temp = true,
277            ctx = 1_048_576,
278            out = 65_536
279        ),
280        entry!(
281            "openrouter",
282            "google/gemini-2.5-flash",
283            "Gemini 2.5 Flash",
284            temp = true,
285            ctx = 1_048_576,
286            out = 65_536
287        ),
288        entry!(
289            "openrouter",
290            "google/gemini-2.5-flash-lite",
291            "Gemini 2.5 Flash Lite",
292            temp = true,
293            ctx = 1_048_576,
294            out = 65_536
295        ),
296        // ── OpenRouter: Meta Llama 4 ───────────────────────────────────────────
297        entry!(
298            "openrouter",
299            "meta-llama/llama-4-maverick",
300            "Llama 4 Maverick",
301            temp = true,
302            ctx = 1_048_576,
303            out = 32_768
304        ),
305        entry!(
306            "openrouter",
307            "meta-llama/llama-4-scout",
308            "Llama 4 Scout",
309            temp = true,
310            ctx = 10_000_000,
311            out = 32_768
312        ),
313        // ── OpenRouter: DeepSeek ───────────────────────────────────────────────
314        entry!(
315            "openrouter",
316            "deepseek/deepseek-v4-pro",
317            "DeepSeek V4 Pro",
318            temp = true,
319            ctx = 1_048_576,
320            out = 393_216
321        ),
322        entry!(
323            "openrouter",
324            "deepseek/deepseek-v4-flash",
325            "DeepSeek V4 Flash",
326            temp = true,
327            ctx = 1_048_576,
328            out = 65_536
329        ),
330        entry!(
331            "openrouter",
332            "deepseek/deepseek-v3.2",
333            "DeepSeek V3.2",
334            temp = true,
335            ctx = 131_072,
336            out = 65_536
337        ),
338        entry!(
339            "openrouter",
340            "deepseek/deepseek-r1-0528",
341            "DeepSeek R1 (0528)",
342            temp = false,
343            tools = false,
344            ctx = 163_840,
345            out = 32_768
346        ),
347        entry!(
348            "openrouter",
349            "deepseek/deepseek-r1",
350            "DeepSeek R1",
351            temp = false,
352            tools = false,
353            ctx = 163_840,
354            out = 16_384
355        ),
356        // ── OpenRouter: Mistral ────────────────────────────────────────────────
357        entry!(
358            "openrouter",
359            "mistralai/mistral-large-2512",
360            "Mistral Large 3",
361            temp = true,
362            ctx = 262_144,
363            out = 32_768
364        ),
365        entry!(
366            "openrouter",
367            "mistralai/mistral-medium-3-5",
368            "Mistral Medium 3.5",
369            temp = true,
370            ctx = 256_000,
371            out = 32_768
372        ),
373        entry!(
374            "openrouter",
375            "mistralai/mistral-small-2603",
376            "Mistral Small 4",
377            temp = true,
378            ctx = 128_000,
379            out = 32_768
380        ),
381        // ── OpenRouter: Qwen (Alibaba) ─────────────────────────────────────────
382        entry!(
383            "openrouter",
384            "qwen/qwen3.6-plus",
385            "Qwen 3.6 Plus",
386            temp = true,
387            ctx = 1_048_576,
388            out = 65_536
389        ),
390        entry!(
391            "openrouter",
392            "qwen/qwen3-max",
393            "Qwen3 Max",
394            temp = true,
395            ctx = 131_072,
396            out = 32_768
397        ),
398        entry!(
399            "openrouter",
400            "qwen/qwen3-coder",
401            "Qwen3 Coder 480B",
402            temp = true,
403            ctx = 1_048_576,
404            out = 262_144
405        ),
406    ]
407}
408
409// ─── list ─────────────────────────────────────────────────────────────────────
410
411/// Core of [`list`], with provider-registry construction injected so tests
412/// can drive the `--remote` merge/override/error paths with a
413/// [`Provider`](leviath_providers::Provider) mock instead of hitting a real
414/// network endpoint (ollama) or spawning a real subprocess (claude-code) --
415/// both of which [`build_provider_registry`] always registers.
416///
417/// `build_registry` is a `&dyn Fn` trait object, not a generic
418/// `impl FnOnce`, deliberately: every test below passes a distinct closure
419/// type (each `mock_registry(...)` call site produces its own closure type,
420/// separate again from the production `build_provider_registry` function
421/// item type). A generic parameter would make `cargo-llvm-cov` instrument
422/// each call site's monomorphization of this function separately, and it
423/// has been observed to report the production instantiation as 0-hit even
424/// though it's genuinely exercised by `execute_list_command_runs_without_error`
425/// et al. - the same instantiation-merging undercount documented for
426/// `run_stage_loop` (see `run/worker.rs`'s `run_worker_inner` and
427/// `run/session.rs`'s `resolve_task_with`, which use the same fix). A
428/// `&dyn Fn` trait object is one concrete type regardless of what closure is
429/// passed, so every call site shares a single instrumented instantiation.
430async fn list_with_registry(
431    args: ListArgs,
432    build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
433) -> anyhow::Result<()> {
434    let config = Config::load()?;
435    for warning in config.validate_keys() {
436        eprintln!("Warning: {}", warning);
437    }
438
439    // Start with the built-in table, indexed by model_id for easy overriding.
440    let mut entries: Vec<ModelInfo> = builtin_table()
441        .into_iter()
442        .map(|e| ModelInfo {
443            id: e.model_id.to_string(),
444            display_name: Some(e.display_name.to_string()),
445            provider: e.provider.to_string(),
446            capabilities: e.caps,
447        })
448        .collect();
449
450    // Only what this install can actually run. Listing every model the binary
451    // knows about made a user with one key scroll past dozens of models they
452    // had no credential for, and hid whether their own key had been picked up
453    // at all. `--all` restores the full catalogue for shopping around.
454    let registry = build_registry(&config);
455    let available: std::collections::HashSet<String> = registry
456        .provider_names()
457        .into_iter()
458        .map(str::to_string)
459        .collect();
460    if !args.all {
461        entries.retain(|e| available.contains(&e.provider));
462    }
463
464    // --remote: fetch live model lists and merge (remote wins on same ID).
465    if args.remote {
466        for provider_name in registry.provider_names() {
467            // If the caller filtered to a specific provider, skip others.
468            if let Some(ref filter) = args.provider
469                && filter != provider_name
470            {
471                continue;
472            }
473
474            // `registry.get(provider_name)` is structurally guaranteed
475            // `Some` here - `provider_name` comes from
476            // `registry.provider_names()` just above, and both methods read
477            // the same underlying map (see `leviath-runtime/src/engine.rs`'s
478            // `ProviderRegistry`). There is no way to construct a registry
479            // where a name from `provider_names()` isn't `get()`-able, so
480            // `.expect()` documents that invariant instead of leaving a
481            // defensive-but-unreachable `if let` branch permanently
482            // uncovered - the same choice already made by
483            // `commands/serve/config.rs`'s `get_models` for this identical
484            // pattern.
485            let provider = registry
486                .get(provider_name)
487                .expect("provider_names returns registered names");
488            match provider.list_models().await {
489                Ok(remote_models) => {
490                    for rm in remote_models {
491                        // Override builtin entry with the same ID, or append.
492                        if let Some(existing) = entries.iter_mut().find(|e| e.id == rm.id) {
493                            *existing = rm;
494                        } else {
495                            entries.push(rm);
496                        }
497                    }
498                }
499                Err(e) => {
500                    eprintln!(
501                        "Warning: could not fetch models from '{}': {}",
502                        provider_name, e
503                    );
504                }
505            }
506        }
507    }
508
509    // Apply provider filter (after remote merge so we respect the filter).
510    if let Some(ref filter) = args.provider {
511        entries.retain(|e| &e.provider == filter);
512    }
513
514    // Apply user-defined capability overrides from config; track which IDs are overridden.
515    let overridden: std::collections::HashSet<String> =
516        config.model_capabilities.keys().cloned().collect();
517
518    for entry in entries.iter_mut() {
519        if let Some(user_caps) = config.model_capabilities.get(&entry.id) {
520            entry.capabilities = user_caps.clone();
521        }
522    }
523
524    if entries.is_empty() {
525        // Reachable two ways now: a `--provider` filter that matches nothing,
526        // or no configured provider at all (a fresh install). Both want the
527        // same nudge, and the second is the one worth naming.
528        println!("No models available.");
529        println!(
530            "(configure a provider with `lev setup`, or pass --all to see every \
531             model Leviath knows about)"
532        );
533        return Ok(());
534    }
535
536    // Print table header.
537    println!(
538        "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
539        "PROVIDER", "MODEL ID", "TEMP", "TOOLS", "CTX", "OUTPUT"
540    );
541    println!("{}", "-".repeat(85));
542
543    for entry in &entries {
544        let provider_col = if overridden.contains(&entry.id) {
545            format!("*{}", entry.provider)
546        } else {
547            entry.provider.clone()
548        };
549
550        let temp = bool_icon(entry.capabilities.supports_temperature);
551        let tools = bool_icon(entry.capabilities.supports_tools);
552        let ctx = fmt_tokens(entry.capabilities.max_context_tokens);
553        let out = fmt_tokens(entry.capabilities.max_output_tokens);
554
555        println!(
556            "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
557            provider_col, entry.id, temp, tools, ctx, out
558        );
559    }
560
561    if overridden
562        .iter()
563        .any(|id| entries.iter().any(|e| &e.id == id))
564    {
565        println!("\n* = capabilities overridden via [model_capabilities] in config");
566    }
567
568    Ok(())
569}
570
571// ─── show ─────────────────────────────────────────────────────────────────────
572
573/// Core of [`show`], with provider-registry construction injected - see
574/// [`list_with_registry`] for why.
575async fn show_with_registry(
576    args: ShowArgs,
577    build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
578) -> anyhow::Result<()> {
579    let config = Config::load()?;
580    for warning in config.validate_keys() {
581        eprintln!("Warning: {}", warning);
582    }
583
584    let model_id = &args.model;
585
586    // 1. Check user overrides first (highest precedence).
587    if let Some(user_caps) = config.model_capabilities.get(model_id) {
588        print_model_detail(model_id, None, "config (user override)", user_caps, true);
589        return Ok(());
590    }
591
592    // 2. Check built-in table.
593    let builtin = builtin_table();
594    if let Some(entry) = builtin.iter().find(|e| e.model_id == model_id) {
595        print_model_detail(
596            model_id,
597            Some(entry.display_name),
598            entry.provider,
599            &entry.caps,
600            false,
601        );
602        return Ok(());
603    }
604
605    // 3. Optionally fetch from provider API if --remote and --provider are both given.
606    if args.remote
607        && let Some(ref provider_name) = args.provider
608    {
609        let registry = build_registry(&config);
610        if let Some(provider) = registry.get(provider_name) {
611            match provider.list_models().await {
612                Ok(models) => {
613                    if let Some(info) = models.iter().find(|m| &m.id == model_id) {
614                        print_model_detail(
615                            model_id,
616                            info.display_name.as_deref(),
617                            &info.provider,
618                            &info.capabilities,
619                            false,
620                        );
621                        return Ok(());
622                    }
623                }
624                Err(e) => {
625                    eprintln!(
626                        "Warning: could not fetch models from '{}': {}",
627                        provider_name, e
628                    );
629                }
630            }
631        } else {
632            eprintln!(
633                "Warning: provider '{}' is not configured (missing API key?)",
634                provider_name
635            );
636        }
637    }
638
639    // 4. Not found anywhere - print a helpful message with a TOML snippet.
640    println!("Model '{}' not found.", model_id);
641    println!(
642        "Add it to {} under [model_capabilities.'{}']",
643        Config::config_path().display(),
644        model_id
645    );
646    println!();
647    println!("Example:");
648    println!("[model_capabilities.'{}']", model_id);
649    println!("supports_temperature  = true");
650    println!("supports_streaming    = true");
651    println!("supports_tools        = true");
652    println!("supports_system_prompt = true");
653    println!("max_context_tokens    = 8192");
654    println!("max_output_tokens     = 4096");
655
656    Ok(())
657}
658
659// ─── Display helpers ──────────────────────────────────────────────────────────
660
661fn bool_icon(b: bool) -> &'static str {
662    if b { "✓" } else { "✗" }
663}
664
665/// Format a raw token count as a human-friendly string (e.g. 1M, 200K, 128K, 8K).
666fn fmt_tokens(n: usize) -> String {
667    if n >= 1_000_000 {
668        format!("{}M", n / 1_000_000)
669    } else if n >= 1_000 {
670        format!("{}K", n / 1_000)
671    } else {
672        n.to_string()
673    }
674}
675
676/// Print a detailed capability sheet for a single model.
677fn print_model_detail(
678    id: &str,
679    display_name: Option<&str>,
680    provider: &str,
681    caps: &ModelCapabilities,
682    is_user_override: bool,
683) {
684    println!("Model:    {}", id);
685    if let Some(name) = display_name {
686        println!("Name:     {}", name);
687    }
688    println!("Provider: {}", provider);
689    if is_user_override {
690        println!("Source:   user override (config)");
691    }
692    println!();
693    println!("Capabilities");
694    println!("------------");
695    println!("  Temperature:    {}", bool_icon(caps.supports_temperature));
696    println!("  Streaming:      {}", bool_icon(caps.supports_streaming));
697    println!("  Tool calling:   {}", bool_icon(caps.supports_tools));
698    println!(
699        "  System prompt:  {}",
700        bool_icon(caps.supports_system_prompt)
701    );
702    println!(
703        "  Context window: {} tokens ({})",
704        caps.max_context_tokens,
705        fmt_tokens(caps.max_context_tokens)
706    );
707    println!(
708        "  Max output:     {} tokens ({})",
709        caps.max_output_tokens,
710        fmt_tokens(caps.max_output_tokens)
711    );
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    // ─── fmt_tokens ─────────────────────────────────────────────────────────
719
720    #[test]
721    fn fmt_tokens_millions() {
722        assert_eq!(fmt_tokens(1_000_000), "1M");
723        assert_eq!(fmt_tokens(2_000_000), "2M");
724    }
725
726    #[test]
727    fn fmt_tokens_thousands() {
728        assert_eq!(fmt_tokens(128_000), "128K");
729        assert_eq!(fmt_tokens(4_096), "4K");
730        assert_eq!(fmt_tokens(1_000), "1K");
731    }
732
733    #[test]
734    fn fmt_tokens_small() {
735        assert_eq!(fmt_tokens(512), "512");
736        assert_eq!(fmt_tokens(0), "0");
737    }
738
739    // ─── bool_icon ──────────────────────────────────────────────────────────
740
741    #[test]
742    fn bool_icon_values() {
743        assert_eq!(bool_icon(true), "✓");
744        assert_eq!(bool_icon(false), "✗");
745    }
746
747    // ─── builtin_table ──────────────────────────────────────────────────────
748
749    #[test]
750    fn builtin_table_is_not_empty() {
751        let table = builtin_table();
752        assert!(!table.is_empty());
753    }
754
755    #[test]
756    fn builtin_table_has_anthropic_models() {
757        let table = builtin_table();
758        let anthropic: Vec<_> = table.iter().filter(|e| e.provider == "anthropic").collect();
759        assert!(!anthropic.is_empty());
760    }
761
762    #[test]
763    fn builtin_table_has_openai_models() {
764        let table = builtin_table();
765        let openai: Vec<_> = table.iter().filter(|e| e.provider == "openai").collect();
766        assert!(!openai.is_empty());
767    }
768
769    #[test]
770    fn builtin_table_has_openrouter_models() {
771        let table = builtin_table();
772        let openrouter: Vec<_> = table
773            .iter()
774            .filter(|e| e.provider == "openrouter")
775            .collect();
776        assert!(!openrouter.is_empty());
777    }
778
779    #[test]
780    fn builtin_entries_have_valid_capabilities() {
781        for entry in builtin_table() {
782            assert!(entry.caps.max_context_tokens > 0);
783            assert!(entry.caps.max_output_tokens > 0);
784            assert!(entry.caps.supports_streaming);
785            assert!(entry.caps.supports_system_prompt);
786        }
787    }
788
789    #[test]
790    fn builtin_entries_have_unique_model_ids() {
791        let table = builtin_table();
792        let ids: Vec<&str> = table.iter().map(|e| e.model_id).collect();
793        let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
794        assert_eq!(ids.len(), unique.len());
795    }
796
797    #[test]
798    fn deepseek_r1_models_no_tools() {
799        let table = builtin_table();
800        for entry in &table {
801            if entry.model_id.contains("deepseek-r1") {
802                assert!(!entry.caps.supports_tools);
803            }
804        }
805    }
806
807    // ─── print_model_detail ─────────────────────────────────────────────────
808
809    #[test]
810    fn print_model_detail_does_not_panic() {
811        let caps = ModelCapabilities {
812            supports_temperature: true,
813            supports_streaming: true,
814            supports_tools: true,
815            supports_system_prompt: true,
816            max_context_tokens: 100_000,
817            max_output_tokens: 8_192,
818        };
819        // Should not panic
820        print_model_detail("test-model", Some("Test Model"), "test", &caps, false);
821        print_model_detail("test-model", None, "test", &caps, true);
822    }
823
824    // ─── fmt_tokens edge cases ──────────────────────────────────────────────
825
826    #[test]
827    fn fmt_tokens_exact_boundary() {
828        assert_eq!(fmt_tokens(999), "999");
829        assert_eq!(fmt_tokens(999_999), "999K");
830    }
831
832    #[test]
833    fn fmt_tokens_large_millions() {
834        assert_eq!(fmt_tokens(10_000_000), "10M");
835    }
836
837    // ─── builtin_table provider coverage ────────────────────────────────────
838
839    #[test]
840    fn builtin_table_claude_opus_no_temperature() {
841        let table = builtin_table();
842        for entry in &table {
843            if entry.model_id == "claude-opus-4-8" || entry.model_id == "claude-opus-4-7" {
844                assert!(!entry.caps.supports_temperature);
845            }
846        }
847    }
848
849    #[test]
850    fn builtin_table_claude_sonnet_supports_temperature() {
851        let table = builtin_table();
852        let sonnet = table
853            .iter()
854            .find(|e| e.model_id == "claude-sonnet-4-6")
855            .expect("claude-sonnet-4-6 should be in table");
856        assert!(sonnet.caps.supports_temperature);
857    }
858
859    #[test]
860    fn builtin_table_has_display_names() {
861        let table = builtin_table();
862        for entry in &table {
863            assert!(!entry.display_name.is_empty());
864        }
865    }
866
867    #[test]
868    fn builtin_table_context_larger_than_output() {
869        let table = builtin_table();
870        for entry in &table {
871            assert!(entry.caps.max_context_tokens >= entry.caps.max_output_tokens);
872        }
873    }
874
875    // ─── bool_icon edge ─────────────────────────────────────────────────────
876
877    #[test]
878    fn bool_icon_returns_unicode() {
879        assert!(!bool_icon(true).is_empty());
880        assert!(!bool_icon(false).is_empty());
881        assert_ne!(bool_icon(true), bool_icon(false));
882    }
883
884    // ─── builtin_table model coverage ───────────────────────────────────
885
886    #[test]
887    fn builtin_table_openai_models_support_temperature() {
888        let table = builtin_table();
889        for entry in &table {
890            if entry.provider == "openai" {
891                assert!(entry.caps.supports_temperature);
892            }
893        }
894    }
895
896    /// A user whose only key is a Gemini key must find models they can run.
897    /// Every Gemini row in this table used to route through OpenRouter, which
898    /// needs a different key, so `lev models list` showed that user nothing
899    /// their key could reach.
900    #[test]
901    fn builtin_table_offers_native_google_models() {
902        let table = builtin_table();
903        let native: Vec<&str> = table
904            .iter()
905            .filter(|e| e.provider == "google")
906            .map(|e| e.model_id)
907            .collect();
908        assert!(
909            !native.is_empty(),
910            "the native google provider must offer models of its own"
911        );
912        // Native ids are bare (`gemini-3.5-flash`), never OpenRouter-prefixed.
913        for id in &native {
914            assert!(
915                !id.contains('/'),
916                "native google model id must not be vendor-prefixed: {id}"
917            );
918        }
919    }
920
921    #[test]
922    fn builtin_table_gemini_flash_models_exist() {
923        let table = builtin_table();
924        let flash: Vec<_> = table
925            .iter()
926            .filter(|e| e.model_id.contains("gemini") && e.model_id.contains("flash"))
927            .collect();
928        assert!(!flash.is_empty());
929    }
930
931    #[test]
932    fn builtin_table_deepseek_r1_no_temperature() {
933        let table = builtin_table();
934        for entry in &table {
935            if entry.model_id.contains("deepseek-r1") {
936                assert!(!entry.caps.supports_temperature);
937            }
938        }
939    }
940
941    #[test]
942    fn builtin_table_qwen_models_exist() {
943        let table = builtin_table();
944        let qwen: Vec<_> = table
945            .iter()
946            .filter(|e| e.model_id.contains("qwen"))
947            .collect();
948        assert!(!qwen.is_empty());
949    }
950
951    #[test]
952    fn builtin_table_mistral_models_exist() {
953        let table = builtin_table();
954        let mistral: Vec<_> = table
955            .iter()
956            .filter(|e| e.model_id.contains("mistral"))
957            .collect();
958        assert!(!mistral.is_empty());
959    }
960
961    #[test]
962    fn builtin_table_all_entries_have_provider() {
963        let table = builtin_table();
964        for entry in &table {
965            assert!(!entry.provider.is_empty());
966        }
967    }
968
969    #[test]
970    fn builtin_table_all_entries_have_model_id() {
971        let table = builtin_table();
972        for entry in &table {
973            assert!(!entry.model_id.is_empty());
974        }
975    }
976
977    // ─── execute() / list() / show() async entry points ──────────────────
978
979    #[tokio::test]
980    async fn execute_list_command_runs_without_error() {
981        crate::config::with_isolated_config_path_async(
982            "models-execute_list_command_runs_without_error",
983            |_fake_dir| async move {
984                let args = ModelsArgs {
985                    command: ModelsCommand::List(ListArgs {
986                        provider: None,
987                        remote: false,
988                        all: false,
989                    }),
990                };
991                // Should succeed: prints the builtin table
992                let result = execute(args).await;
993                assert!(result.is_ok());
994            },
995        )
996        .await;
997    }
998
999    #[tokio::test]
1000    async fn execute_list_with_provider_filter_runs_without_error() {
1001        crate::config::with_isolated_config_path_async(
1002            "models-execute_list_with_provider_filter_runs_without_error",
1003            |_fake_dir| async move {
1004                let args = ModelsArgs {
1005                    command: ModelsCommand::List(ListArgs {
1006                        provider: Some("anthropic".to_string()),
1007                        remote: false,
1008                        all: false,
1009                    }),
1010                };
1011                let result = execute(args).await;
1012                assert!(result.is_ok());
1013            },
1014        )
1015        .await;
1016    }
1017
1018    #[tokio::test]
1019    async fn execute_list_with_nonexistent_provider_filter() {
1020        crate::config::with_isolated_config_path_async(
1021            "models-execute_list_with_nonexistent_provider_filter",
1022            |_fake_dir| async move {
1023                let args = ModelsArgs {
1024                    command: ModelsCommand::List(ListArgs {
1025                        provider: Some("nonexistent_provider".to_string()),
1026                        remote: false,
1027                        all: false,
1028                    }),
1029                };
1030                // Should succeed but print "No models found."
1031                let result = execute(args).await;
1032                assert!(result.is_ok());
1033            },
1034        )
1035        .await;
1036    }
1037
1038    #[tokio::test]
1039    async fn execute_show_known_model_runs_without_error() {
1040        crate::config::with_isolated_config_path_async(
1041            "models-execute_show_known_model_runs_without_error",
1042            |_fake_dir| async move {
1043                let args = ModelsArgs {
1044                    command: ModelsCommand::Show(ShowArgs {
1045                        model: "claude-sonnet-4-6".to_string(),
1046                        provider: None,
1047                        remote: false,
1048                    }),
1049                };
1050                // Should find model in builtin table and print details
1051                let result = execute(args).await;
1052                assert!(result.is_ok());
1053            },
1054        )
1055        .await;
1056    }
1057
1058    #[tokio::test]
1059    async fn execute_show_unknown_model_runs_without_error() {
1060        crate::config::with_isolated_config_path_async(
1061            "models-execute_show_unknown_model_runs_without_error",
1062            |_fake_dir| async move {
1063                let args = ModelsArgs {
1064                    command: ModelsCommand::Show(ShowArgs {
1065                        model: "totally-unknown-model-xyz".to_string(),
1066                        provider: None,
1067                        remote: false,
1068                    }),
1069                };
1070                // Should print "Model not found" message without error
1071                let result = execute(args).await;
1072                assert!(result.is_ok());
1073            },
1074        )
1075        .await;
1076    }
1077
1078    #[tokio::test]
1079    async fn execute_show_unknown_model_with_remote_no_provider() {
1080        crate::config::with_isolated_config_path_async(
1081            "models-execute_show_unknown_model_with_remote_no_provider",
1082            |_fake_dir| async move {
1083                let args = ModelsArgs {
1084                    command: ModelsCommand::Show(ShowArgs {
1085                        model: "totally-unknown-model-xyz".to_string(),
1086                        provider: None,
1087                        remote: true, // remote but no provider = skips remote lookup
1088                    }),
1089                };
1090                let result = execute(args).await;
1091                assert!(result.is_ok());
1092            },
1093        )
1094        .await;
1095    }
1096
1097    #[tokio::test]
1098    async fn execute_show_unknown_model_with_remote_unconfigured_provider() {
1099        crate::config::with_isolated_config_path_async(
1100            "models-execute_show_unknown_model_with_remote_unconfigured_provider",
1101            |_fake_dir| async move {
1102                let args = ModelsArgs {
1103                    command: ModelsCommand::Show(ShowArgs {
1104                        model: "totally-unknown-model-xyz".to_string(),
1105                        provider: Some("anthropic".to_string()),
1106                        remote: true,
1107                        // Provider won't be configured in test env (no API key)
1108                    }),
1109                };
1110                // Should warn about unconfigured provider and then show not-found message
1111                let result = execute(args).await;
1112                assert!(result.is_ok());
1113            },
1114        )
1115        .await;
1116    }
1117
1118    // ─── list() with builtin model having overrides in config ─────────────
1119
1120    #[tokio::test]
1121    async fn list_with_openrouter_filter() {
1122        // execute() -> list_with_registry() calls the real Config::load(),
1123        // which reads the process-global LEVIATH_CONFIG_PATH. Without
1124        // isolating it here, a concurrently-running test that points that
1125        // var at a temporarily-invalid-TOML fake config (e.g.
1126        // list_with_registry_propagates_config_load_error) can make this
1127        // test observe that torn state and fail nondeterministically --
1128        // exactly what happened on CI.
1129        crate::config::with_isolated_config_path_async(
1130            "models-list-openrouter-filter",
1131            |_fake_dir| async move {
1132                let args = ModelsArgs {
1133                    command: ModelsCommand::List(ListArgs {
1134                        provider: Some("openrouter".to_string()),
1135                        remote: false,
1136                        all: false,
1137                    }),
1138                };
1139                let result = execute(args).await;
1140                assert!(result.is_ok());
1141            },
1142        )
1143        .await;
1144    }
1145
1146    #[tokio::test]
1147    async fn list_with_openai_filter() {
1148        // See the comment on list_with_openrouter_filter - same real
1149        // Config::load() race.
1150        crate::config::with_isolated_config_path_async(
1151            "models-list-openai-filter",
1152            |_fake_dir| async move {
1153                let args = ModelsArgs {
1154                    command: ModelsCommand::List(ListArgs {
1155                        provider: Some("openai".to_string()),
1156                        remote: false,
1157                        all: false,
1158                    }),
1159                };
1160                let result = execute(args).await;
1161                assert!(result.is_ok());
1162            },
1163        )
1164        .await;
1165    }
1166
1167    #[tokio::test]
1168    async fn show_builtin_anthropic_opus() {
1169        // See the comment on list_with_openrouter_filter - same real
1170        // Config::load() race.
1171        crate::config::with_isolated_config_path_async(
1172            "models-show-anthropic-opus",
1173            |_fake_dir| async move {
1174                let args = ModelsArgs {
1175                    command: ModelsCommand::Show(ShowArgs {
1176                        model: "claude-opus-4-6".to_string(),
1177                        provider: None,
1178                        remote: false,
1179                    }),
1180                };
1181                let result = execute(args).await;
1182                assert!(result.is_ok());
1183            },
1184        )
1185        .await;
1186    }
1187
1188    #[tokio::test]
1189    async fn show_builtin_openai_model() {
1190        // See the comment on list_with_openrouter_filter - same real
1191        // Config::load() race.
1192        crate::config::with_isolated_config_path_async(
1193            "models-show-openai-model",
1194            |_fake_dir| async move {
1195                let args = ModelsArgs {
1196                    command: ModelsCommand::Show(ShowArgs {
1197                        model: "gpt-5.5".to_string(),
1198                        provider: None,
1199                        remote: false,
1200                    }),
1201                };
1202                let result = execute(args).await;
1203                assert!(result.is_ok());
1204            },
1205        )
1206        .await;
1207    }
1208
1209    #[tokio::test]
1210    async fn show_builtin_deepseek_r1() {
1211        // See the comment on list_with_openrouter_filter - same real
1212        // Config::load() race.
1213        crate::config::with_isolated_config_path_async(
1214            "models-show-deepseek-r1",
1215            |_fake_dir| async move {
1216                let args = ModelsArgs {
1217                    command: ModelsCommand::Show(ShowArgs {
1218                        model: "deepseek/deepseek-r1".to_string(),
1219                        provider: None,
1220                        remote: false,
1221                    }),
1222                };
1223                let result = execute(args).await;
1224                assert!(result.is_ok());
1225            },
1226        )
1227        .await;
1228    }
1229
1230    // ─── builtin_table as ModelInfo conversion ──────────────────────────
1231
1232    #[test]
1233    fn builtin_table_to_model_info_preserves_data() {
1234        let table = builtin_table();
1235        let infos: Vec<ModelInfo> = table
1236            .into_iter()
1237            .map(|e| ModelInfo {
1238                id: e.model_id.to_string(),
1239                display_name: Some(e.display_name.to_string()),
1240                provider: e.provider.to_string(),
1241                capabilities: e.caps,
1242            })
1243            .collect();
1244
1245        assert!(!infos.is_empty());
1246        for info in &infos {
1247            assert!(!info.id.is_empty());
1248            assert!(info.display_name.is_some());
1249            assert!(!info.provider.is_empty());
1250        }
1251    }
1252
1253    // ─── print_model_detail coverage ────────────────────────────────────
1254
1255    #[test]
1256    fn print_model_detail_with_no_tools_no_temp() {
1257        let caps = ModelCapabilities {
1258            supports_temperature: false,
1259            supports_streaming: false,
1260            supports_tools: false,
1261            supports_system_prompt: false,
1262            max_context_tokens: 1000,
1263            max_output_tokens: 500,
1264        };
1265        // Should not panic with all features disabled
1266        print_model_detail("test-model", Some("Test"), "test", &caps, false);
1267    }
1268
1269    #[test]
1270    fn print_model_detail_user_override_source() {
1271        let caps = ModelCapabilities::default();
1272        // Should not panic with user override flag set
1273        print_model_detail("override-model", None, "custom", &caps, true);
1274    }
1275
1276    // ─── fmt_tokens additional ──────────────────────────────────────────
1277
1278    #[test]
1279    fn fmt_tokens_just_below_thousand() {
1280        assert_eq!(fmt_tokens(999), "999");
1281    }
1282
1283    #[test]
1284    fn fmt_tokens_just_at_thousand() {
1285        assert_eq!(fmt_tokens(1000), "1K");
1286    }
1287
1288    #[test]
1289    fn fmt_tokens_just_below_million() {
1290        assert_eq!(fmt_tokens(999_999), "999K");
1291    }
1292
1293    #[test]
1294    fn fmt_tokens_just_at_million() {
1295        assert_eq!(fmt_tokens(1_000_000), "1M");
1296    }
1297
1298    #[test]
1299    fn fmt_tokens_non_round_thousands() {
1300        // Integer division: 1500 / 1000 = 1
1301        assert_eq!(fmt_tokens(1500), "1K");
1302        assert_eq!(fmt_tokens(65_536), "65K");
1303    }
1304
1305    // ─── list() / show() non-remote paths ──────────────────────────────
1306    //
1307    // Config::load() gracefully falls back to defaults when
1308    // ~/.leviath/config.toml doesn't exist, so these are safe to call
1309    // directly without touching the real environment. `list`/`show` are thin
1310    // wrappers around `list_with_registry`/`show_with_registry` (see below
1311    // for the --remote-path tests using a mock registry).
1312
1313    #[tokio::test]
1314    async fn list_builtin_no_filter_succeeds() {
1315        crate::config::with_isolated_config_path_async(
1316            "models-list_builtin_no_filter_succeeds",
1317            |_fake_dir| async move {
1318                let args = ListArgs {
1319                    remote: false,
1320                    provider: None,
1321                    all: false,
1322                };
1323                let result = list_with_registry(args, &build_provider_registry_from_config).await;
1324                assert!(result.is_ok());
1325            },
1326        )
1327        .await;
1328    }
1329
1330    #[tokio::test]
1331    async fn list_builtin_with_provider_filter_succeeds() {
1332        crate::config::with_isolated_config_path_async(
1333            "models-list_builtin_with_provider_filter_succeeds",
1334            |_fake_dir| async move {
1335                let args = ListArgs {
1336                    remote: false,
1337                    provider: Some("anthropic".to_string()),
1338                    all: false,
1339                };
1340                let result = list_with_registry(args, &build_provider_registry_from_config).await;
1341                assert!(result.is_ok());
1342            },
1343        )
1344        .await;
1345    }
1346
1347    #[tokio::test]
1348    async fn list_unknown_provider_filter_finds_nothing() {
1349        crate::config::with_isolated_config_path_async(
1350            "models-list_unknown_provider_filter_finds_nothing",
1351            |_fake_dir| async move {
1352                let args = ListArgs {
1353                    remote: false,
1354                    provider: Some("no-such-provider".to_string()),
1355                    all: false,
1356                };
1357                // Should print "No models found." and still succeed, not error.
1358                let result = list_with_registry(args, &build_provider_registry_from_config).await;
1359                assert!(result.is_ok());
1360            },
1361        )
1362        .await;
1363    }
1364
1365    #[tokio::test]
1366    async fn show_builtin_model_succeeds() {
1367        crate::config::with_isolated_config_path_async(
1368            "models-show_builtin_model_succeeds",
1369            |_fake_dir| async move {
1370                // Use a model ID guaranteed to be in the builtin table.
1371                let known_id = builtin_table()[0].model_id.to_string();
1372                let args = ShowArgs {
1373                    model: known_id,
1374                    remote: false,
1375                    provider: None,
1376                };
1377                let result = show_with_registry(args, &build_provider_registry_from_config).await;
1378                assert!(result.is_ok());
1379            },
1380        )
1381        .await;
1382    }
1383
1384    #[tokio::test]
1385    async fn show_unknown_model_without_remote_succeeds_with_warning() {
1386        crate::config::with_isolated_config_path_async(
1387            "models-show_unknown_model_without_remote_succeeds_with_warning",
1388            |_fake_dir| async move {
1389                let args = ShowArgs {
1390                    model: "totally-unknown-model-xyz".to_string(),
1391                    remote: false,
1392                    provider: None,
1393                };
1394                // Falls through all lookup tiers; must not error even when not found.
1395                let result = show_with_registry(args, &build_provider_registry_from_config).await;
1396                assert!(result.is_ok());
1397            },
1398        )
1399        .await;
1400    }
1401
1402    #[tokio::test]
1403    async fn show_remote_without_provider_falls_through_gracefully() {
1404        crate::config::with_isolated_config_path_async(
1405            "models-show_remote_without_provider_falls_through_gracefully",
1406            |_fake_dir| async move {
1407                // args.remote = true but no --provider given -> the remote-fetch
1408                // branch's inner `if let Some(ref provider_name)` is skipped.
1409                let args = ShowArgs {
1410                    model: "totally-unknown-model-xyz".to_string(),
1411                    remote: true,
1412                    provider: None,
1413                };
1414                let result = show_with_registry(args, &build_provider_registry_from_config).await;
1415                assert!(result.is_ok());
1416            },
1417        )
1418        .await;
1419    }
1420
1421    // ─── list()/show() --remote paths, with a mock provider ────────────────
1422    //
1423    // `build_provider_registry` always registers real `ollama`/`claude-code`
1424    // providers regardless of config, so these can't safely be exercised via
1425    // the real registry (a real network call to localhost:11434, or spawning
1426    // a real `claude` subprocess). `list_with_registry`/`show_with_registry`
1427    // take an injectable registry builder for exactly this reason: tests
1428    // register a `MockProvider` under a name of their choosing and filter to
1429    // just that provider via `--provider`, so no real ollama/claude-code
1430    // provider is ever touched.
1431
1432    struct MockProvider {
1433        models: Vec<ModelInfo>,
1434        fail: bool,
1435    }
1436
1437    #[async_trait::async_trait]
1438    impl leviath_providers::Provider for MockProvider {
1439        async fn infer(
1440            &self,
1441            _request: leviath_providers::InferenceRequest,
1442        ) -> Result<leviath_providers::InferenceResponse, leviath_providers::ProviderError>
1443        {
1444            Err(leviath_providers::ProviderError::Other(
1445                "MockProvider does not support infer".to_string(),
1446            ))
1447        }
1448
1449        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1450            leviath_core::estimate_tokens(text)
1451        }
1452
1453        fn max_context_tokens(&self, _model: &str) -> usize {
1454            100_000
1455        }
1456
1457        fn name(&self) -> &str {
1458            "mock"
1459        }
1460
1461        fn capabilities(&self, _model: &str) -> ModelCapabilities {
1462            ModelCapabilities::default()
1463        }
1464
1465        async fn list_models(&self) -> Result<Vec<ModelInfo>, leviath_providers::ProviderError> {
1466            if self.fail {
1467                Err(leviath_providers::ProviderError::Other(
1468                    "mock provider failure".to_string(),
1469                ))
1470            } else {
1471                Ok(self.models.clone())
1472            }
1473        }
1474    }
1475
1476    fn mock_registry(
1477        provider_name: &'static str,
1478        models: Vec<ModelInfo>,
1479        fail: bool,
1480    ) -> impl Fn(&Config) -> leviath_runtime::ProviderRegistry {
1481        // `Fn` (not `FnOnce`) so the closure can be called through the
1482        // `&dyn Fn` trait object `list_with_registry`/`show_with_registry`
1483        // now take - see the doc comment on `list_with_registry` for why.
1484        // Only ever actually invoked once per test, but `Fn`'s "may be
1485        // called more than once" contract means captured state can't be
1486        // moved out on each call, hence the clone.
1487        move |_config: &Config| {
1488            let mut registry = leviath_runtime::ProviderRegistry::new();
1489            registry.register(
1490                provider_name.to_string(),
1491                std::sync::Arc::new(MockProvider {
1492                    models: models.clone(),
1493                    fail,
1494                }),
1495            );
1496            registry
1497        }
1498    }
1499
1500    #[tokio::test]
1501    async fn list_remote_merges_new_model_from_provider() {
1502        crate::config::with_isolated_config_path_async(
1503            "models-list_remote_merges_new_model_from_provider",
1504            |_fake_dir| async move {
1505                let args = ListArgs {
1506                    remote: true,
1507                    provider: Some("mock".to_string()),
1508                    all: false,
1509                };
1510                let new_model = ModelInfo {
1511                    id: "mock-brand-new-model".to_string(),
1512                    display_name: Some("Mock Brand New Model".to_string()),
1513                    provider: "mock".to_string(),
1514                    capabilities: ModelCapabilities::default(),
1515                };
1516                let result =
1517                    list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1518                assert!(result.is_ok());
1519            },
1520        )
1521        .await;
1522    }
1523
1524    #[tokio::test]
1525    async fn list_remote_without_provider_filter_queries_all_providers() {
1526        // No `--provider` filter set: every provider in the registry should
1527        // be queried for remote models (the `if let Some(ref filter) = ...`
1528        // pattern-doesn't-match arm, never exercised by the other
1529        // `list_remote_*` tests below, which all pass a provider filter).
1530        crate::config::with_isolated_config_path_async(
1531            "models-list_remote_without_provider_filter_queries_all_providers",
1532            |_fake_dir| async move {
1533                let args = ListArgs {
1534                    remote: true,
1535                    provider: None,
1536                    all: false,
1537                };
1538                let new_model = ModelInfo {
1539                    id: "mock-brand-new-model".to_string(),
1540                    display_name: Some("Mock Brand New Model".to_string()),
1541                    provider: "mock".to_string(),
1542                    capabilities: ModelCapabilities::default(),
1543                };
1544                let result =
1545                    list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1546                assert!(result.is_ok());
1547            },
1548        )
1549        .await;
1550    }
1551
1552    #[tokio::test]
1553    async fn list_remote_overrides_builtin_entry_with_same_id() {
1554        crate::config::with_isolated_config_path_async(
1555            "models-list_remote_overrides_builtin_entry_with_same_id",
1556            |_fake_dir| async move {
1557                let known_id = builtin_table()[0].model_id.to_string();
1558                let args = ListArgs {
1559                    remote: true,
1560                    provider: Some("mock".to_string()),
1561                    all: false,
1562                };
1563                let overriding_model = ModelInfo {
1564                    id: known_id,
1565                    display_name: Some("Overridden".to_string()),
1566                    provider: "mock".to_string(),
1567                    capabilities: ModelCapabilities::default(),
1568                };
1569                let result =
1570                    list_with_registry(args, &mock_registry("mock", vec![overriding_model], false))
1571                        .await;
1572                assert!(result.is_ok());
1573            },
1574        )
1575        .await;
1576    }
1577
1578    /// Only models the install can reach are listed: a registry holding just
1579    /// `anthropic` must not print google/openai/openrouter rows. Before this,
1580    /// a user with one key scrolled past dozens of models they could not run,
1581    /// with no way to tell whether their own key had registered.
1582    #[tokio::test]
1583    async fn list_shows_only_providers_the_install_has_credentials_for() {
1584        crate::config::with_isolated_config_path_async(
1585            "models-list_only_available",
1586            |_fake_dir| async move {
1587                let args = ListArgs {
1588                    remote: false,
1589                    provider: None,
1590                    all: false,
1591                };
1592                // A registry with exactly one provider that the builtin table
1593                // also knows: its rows survive, everything else is filtered.
1594                let result =
1595                    list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1596                assert!(result.is_ok());
1597            },
1598        )
1599        .await;
1600    }
1601
1602    /// A remote fetch that returns a model id the builtin table already lists
1603    /// replaces that row (remote wins), which requires the row to have survived
1604    /// the availability filter.
1605    #[tokio::test]
1606    async fn list_remote_overrides_a_builtin_entry_with_the_same_id() {
1607        crate::config::with_isolated_config_path_async(
1608            "models-list_remote_override",
1609            |_fake_dir| async move {
1610                let remote = vec![ModelInfo {
1611                    id: "claude-sonnet-5".to_string(),
1612                    display_name: Some("Claude Sonnet 5 (remote)".to_string()),
1613                    provider: "anthropic".to_string(),
1614                    capabilities: leviath_providers::ModelCapabilities::default(),
1615                }];
1616                let args = ListArgs {
1617                    remote: true,
1618                    provider: None,
1619                    all: false,
1620                };
1621                let result =
1622                    list_with_registry(args, &mock_registry("anthropic", remote, false)).await;
1623                assert!(result.is_ok());
1624            },
1625        )
1626        .await;
1627    }
1628
1629    /// `--all` restores the full catalogue for shopping around before choosing
1630    /// a provider.
1631    #[tokio::test]
1632    async fn list_all_includes_providers_without_credentials() {
1633        crate::config::with_isolated_config_path_async(
1634            "models-list_all_includes_everything",
1635            |_fake_dir| async move {
1636                let args = ListArgs {
1637                    remote: false,
1638                    provider: None,
1639                    all: true,
1640                };
1641                let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1642                assert!(result.is_ok());
1643            },
1644        )
1645        .await;
1646    }
1647
1648    /// A capability override marks its row with `*`, which requires the row to
1649    /// survive the availability filter first.
1650    #[tokio::test]
1651    async fn list_marks_overridden_capabilities_for_an_available_provider() {
1652        crate::config::with_isolated_config_path_async(
1653            "models-list_overridden_available",
1654            |_fake_dir| async move {
1655                let mut config = Config::default();
1656                config.model_capabilities.insert(
1657                    "claude-sonnet-5".to_string(),
1658                    leviath_providers::ModelCapabilities::default(),
1659                );
1660                config
1661                    .save_to_path(&Config::config_path())
1662                    .expect("the isolated config path is writable");
1663                let args = ListArgs {
1664                    remote: false,
1665                    provider: None,
1666                    all: false,
1667                };
1668                let result =
1669                    list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1670                assert!(result.is_ok());
1671            },
1672        )
1673        .await;
1674    }
1675
1676    #[tokio::test]
1677    async fn list_remote_provider_error_warns_and_continues() {
1678        crate::config::with_isolated_config_path_async(
1679            "models-list_remote_provider_error_warns_and_continues",
1680            |_fake_dir| async move {
1681                let args = ListArgs {
1682                    remote: true,
1683                    provider: Some("mock".to_string()),
1684                    all: false,
1685                };
1686                let result = list_with_registry(args, &mock_registry("mock", vec![], true)).await;
1687                assert!(result.is_ok());
1688            },
1689        )
1690        .await;
1691    }
1692
1693    #[tokio::test]
1694    async fn list_remote_skips_providers_not_matching_filter() {
1695        crate::config::with_isolated_config_path_async(
1696            "models-list_remote_skips_providers_not_matching_filter",
1697            |_fake_dir| async move {
1698                // provider filter is "mock-other", but the registry only has "mock"
1699                // registered -> the `if filter != provider_name { continue; }`
1700                // branch is exercised, and the mock is never queried.
1701                let args = ListArgs {
1702                    remote: true,
1703                    provider: Some("mock-other".to_string()),
1704                    all: false,
1705                };
1706                let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1707                assert!(result.is_ok());
1708            },
1709        )
1710        .await;
1711    }
1712
1713    #[tokio::test]
1714    async fn show_remote_finds_model_from_provider() {
1715        crate::config::with_isolated_config_path_async(
1716            "models-show_remote_finds_model_from_provider",
1717            |_fake_dir| async move {
1718                let args = ShowArgs {
1719                    model: "mock-remote-model".to_string(),
1720                    remote: true,
1721                    provider: Some("mock".to_string()),
1722                };
1723                let remote_model = ModelInfo {
1724                    id: "mock-remote-model".to_string(),
1725                    display_name: Some("Mock Remote Model".to_string()),
1726                    provider: "mock".to_string(),
1727                    capabilities: ModelCapabilities::default(),
1728                };
1729                let result =
1730                    show_with_registry(args, &mock_registry("mock", vec![remote_model], false))
1731                        .await;
1732                assert!(result.is_ok());
1733            },
1734        )
1735        .await;
1736    }
1737
1738    #[tokio::test]
1739    async fn show_remote_model_not_found_in_provider_list_falls_through() {
1740        crate::config::with_isolated_config_path_async(
1741            "models-show_remote_model_not_found_in_provider_list_falls_through",
1742            |_fake_dir| async move {
1743                let args = ShowArgs {
1744                    model: "totally-unknown-model-xyz".to_string(),
1745                    remote: true,
1746                    provider: Some("mock".to_string()),
1747                };
1748                let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1749                assert!(result.is_ok());
1750            },
1751        )
1752        .await;
1753    }
1754
1755    #[tokio::test]
1756    async fn show_remote_provider_error_warns_and_falls_through() {
1757        crate::config::with_isolated_config_path_async(
1758            "models-show_remote_provider_error_warns_and_falls_through",
1759            |_fake_dir| async move {
1760                let args = ShowArgs {
1761                    model: "totally-unknown-model-xyz".to_string(),
1762                    remote: true,
1763                    provider: Some("mock".to_string()),
1764                };
1765                let result = show_with_registry(args, &mock_registry("mock", vec![], true)).await;
1766                assert!(result.is_ok());
1767            },
1768        )
1769        .await;
1770    }
1771
1772    #[tokio::test]
1773    async fn show_remote_unconfigured_provider_warns_and_falls_through() {
1774        crate::config::with_isolated_config_path_async(
1775            "models-show_remote_unconfigured_provider_warns_and_falls_through",
1776            |_fake_dir| async move {
1777                // provider filter names a provider that isn't in the registry at all
1778                // -> the `if let Some(provider) = registry.get(...)` else branch.
1779                let args = ShowArgs {
1780                    model: "totally-unknown-model-xyz".to_string(),
1781                    remote: true,
1782                    provider: Some("nonexistent-provider".to_string()),
1783                };
1784                let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1785                assert!(result.is_ok());
1786            },
1787        )
1788        .await;
1789    }
1790
1791    // ─── validate_keys() warnings + [model_capabilities] overrides ─────────
1792    //
1793    // `list_with_registry`/`show_with_registry` take an injectable registry
1794    // builder, so a malformed API key in the isolated test config can safely
1795    // exercise the `validate_keys()` warning-print branch without the
1796    // registry ever actually using that key (the mock registry below ignores
1797    // `_config` entirely).
1798
1799    #[tokio::test]
1800    async fn list_prints_warning_and_applies_model_capabilities_override() {
1801        crate::config::with_isolated_config_path_async(
1802            "models-list-override",
1803            |_fake_dir| async move {
1804                let known_id = builtin_table()[0].model_id.to_string();
1805                let mut fake_config = Config::default();
1806                fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1807                fake_config.model_capabilities.insert(
1808                    known_id,
1809                    ModelCapabilities {
1810                        supports_temperature: false,
1811                        supports_streaming: false,
1812                        supports_tools: false,
1813                        supports_system_prompt: false,
1814                        max_context_tokens: 1,
1815                        max_output_tokens: 1,
1816                    },
1817                );
1818                std::fs::write(
1819                    Config::config_path(),
1820                    toml::to_string(&fake_config).unwrap(),
1821                )
1822                .unwrap();
1823
1824                let args = ListArgs {
1825                    remote: false,
1826                    provider: None,
1827                    all: false,
1828                };
1829                let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1830                assert!(result.is_ok());
1831            },
1832        )
1833        .await;
1834    }
1835
1836    #[tokio::test]
1837    async fn show_prints_warning_and_uses_model_capabilities_override() {
1838        crate::config::with_isolated_config_path_async(
1839            "models-show-override",
1840            |_fake_dir| async move {
1841                let known_id = builtin_table()[0].model_id.to_string();
1842                let mut fake_config = Config::default();
1843                fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1844                fake_config.model_capabilities.insert(
1845                    known_id.clone(),
1846                    ModelCapabilities {
1847                        supports_temperature: false,
1848                        supports_streaming: false,
1849                        supports_tools: false,
1850                        supports_system_prompt: false,
1851                        max_context_tokens: 1,
1852                        max_output_tokens: 1,
1853                    },
1854                );
1855                std::fs::write(
1856                    Config::config_path(),
1857                    toml::to_string(&fake_config).unwrap(),
1858                )
1859                .unwrap();
1860
1861                let args = ShowArgs {
1862                    model: known_id,
1863                    remote: false,
1864                    provider: None,
1865                };
1866                let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1867                assert!(result.is_ok());
1868            },
1869        )
1870        .await;
1871    }
1872
1873    #[tokio::test]
1874    async fn mock_provider_trivial_trait_methods() {
1875        use leviath_providers::Provider;
1876        let provider = MockProvider {
1877            models: vec![],
1878            fail: false,
1879        };
1880        assert_eq!(provider.count_tokens("abcd", "mock-model").await, 1);
1881        assert_eq!(provider.max_context_tokens("mock-model"), 100_000);
1882        assert_eq!(provider.name(), "mock");
1883        let _ = provider.capabilities("mock-model");
1884    }
1885
1886    #[tokio::test]
1887    async fn mock_provider_infer_returns_err() {
1888        use leviath_providers::Provider;
1889        let provider = MockProvider {
1890            models: vec![],
1891            fail: false,
1892        };
1893        let request = leviath_providers::InferenceRequest {
1894            system: vec![],
1895            messages: vec![],
1896            model: "mock".to_string(),
1897            max_tokens: 100,
1898            temperature: 0.0,
1899            tools: vec![],
1900            extra: serde_json::Value::Null,
1901            request_timeout_secs: None,
1902        };
1903        let result = provider.infer(request).await;
1904        assert!(result.is_err());
1905    }
1906
1907    #[tokio::test]
1908    async fn list_with_registry_propagates_config_load_error() {
1909        crate::config::with_isolated_config_path_async(
1910            "models-list_with_registry_propagates_config_load_error",
1911            |fake_dir| async move {
1912                std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
1913                let args = ListArgs {
1914                    remote: false,
1915                    provider: None,
1916                    all: false,
1917                };
1918                let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1919                assert!(result.is_err());
1920            },
1921        )
1922        .await;
1923    }
1924
1925    // ─── CLI argument parsing (clap derive) ────────────────────────────────
1926    //
1927    // `ModelsArgs`/`ModelsCommand`/`ListArgs`/`ShowArgs` only ever get
1928    // constructed as plain struct literals elsewhere in this file's tests,
1929    // which never exercises clap's derive-generated `Args`/`FromArgMatches`
1930    // parsing implementations (`augment_args`, `from_arg_matches`, etc.) --
1931    // those are only reached in production via `main.rs`'s real
1932    // `Cli::parse()`, which isn't part of this crate's `--lib` test target.
1933    // Wrapping `ModelsArgs` in a minimal local `Parser` and driving it
1934    // through `try_parse_from` exercises that derive machinery directly and
1935    // doubles as a real regression test for the actual flag/positional
1936    // contract (short flags, long flags, subcommand names).
1937
1938    use clap::Parser as _;
1939
1940    #[derive(clap::Parser)]
1941    struct TestCli {
1942        #[command(flatten)]
1943        models: ModelsArgs,
1944    }
1945
1946    /// Unwraps the `List` variant, panicking otherwise. A bare `match ... =>
1947    /// panic!(...)` inline in each test would leave that panic arm a
1948    /// permanent 0-hit region in a green suite (it only fires on failure) --
1949    /// extracting it here lets a single `#[should_panic]` test exercise it
1950    /// once, matching the pattern already used in `serve/blueprints.rs`.
1951    fn expect_list(cmd: ModelsCommand) -> ListArgs {
1952        match cmd {
1953            ModelsCommand::List(args) => args,
1954            ModelsCommand::Show(_) => panic!("expected List"),
1955        }
1956    }
1957
1958    #[test]
1959    #[should_panic(expected = "expected List")]
1960    fn expect_list_panics_on_show() {
1961        expect_list(ModelsCommand::Show(ShowArgs {
1962            model: "x".to_string(),
1963            provider: None,
1964            remote: false,
1965        }));
1966    }
1967
1968    /// Unwraps the `Show` variant, panicking otherwise. See [`expect_list`].
1969    fn expect_show(cmd: ModelsCommand) -> ShowArgs {
1970        match cmd {
1971            ModelsCommand::Show(args) => args,
1972            ModelsCommand::List(_) => panic!("expected Show"),
1973        }
1974    }
1975
1976    #[test]
1977    #[should_panic(expected = "expected Show")]
1978    fn expect_show_panics_on_list() {
1979        expect_show(ModelsCommand::List(ListArgs {
1980            provider: None,
1981            remote: false,
1982            all: false,
1983        }));
1984    }
1985
1986    #[test]
1987    fn parses_list_with_no_flags() {
1988        let cli = TestCli::try_parse_from(["lev", "list"]).unwrap();
1989        let args = expect_list(cli.models.command);
1990        assert!(args.provider.is_none());
1991        assert!(!args.remote);
1992    }
1993
1994    #[test]
1995    fn parses_list_with_long_flags() {
1996        let cli = TestCli::try_parse_from(["lev", "list", "--provider", "anthropic", "--remote"])
1997            .unwrap();
1998        let args = expect_list(cli.models.command);
1999        assert_eq!(args.provider.as_deref(), Some("anthropic"));
2000        assert!(args.remote);
2001    }
2002
2003    #[test]
2004    fn parses_list_with_short_flags() {
2005        let cli = TestCli::try_parse_from(["lev", "list", "-p", "openai", "-r"]).unwrap();
2006        let args = expect_list(cli.models.command);
2007        assert_eq!(args.provider.as_deref(), Some("openai"));
2008        assert!(args.remote);
2009    }
2010
2011    #[test]
2012    fn parses_show_with_positional_model_and_long_flags() {
2013        let cli = TestCli::try_parse_from([
2014            "lev",
2015            "show",
2016            "claude-sonnet-4-6",
2017            "--provider",
2018            "anthropic",
2019            "--remote",
2020        ])
2021        .unwrap();
2022        let args = expect_show(cli.models.command);
2023        assert_eq!(args.model, "claude-sonnet-4-6");
2024        assert_eq!(args.provider.as_deref(), Some("anthropic"));
2025        assert!(args.remote);
2026    }
2027
2028    #[test]
2029    fn parses_show_with_short_flags() {
2030        let cli =
2031            TestCli::try_parse_from(["lev", "show", "gpt-5.5", "-p", "openai", "-r"]).unwrap();
2032        let args = expect_show(cli.models.command);
2033        assert_eq!(args.model, "gpt-5.5");
2034        assert_eq!(args.provider.as_deref(), Some("openai"));
2035        assert!(args.remote);
2036    }
2037
2038    #[test]
2039    fn parses_show_missing_required_positional_errors() {
2040        let result = TestCli::try_parse_from(["lev", "show"]);
2041        assert!(result.is_err());
2042    }
2043
2044    #[test]
2045    fn parses_unknown_subcommand_errors() {
2046        let result = TestCli::try_parse_from(["lev", "not-a-subcommand"]);
2047        assert!(result.is_err());
2048    }
2049
2050    #[tokio::test]
2051    async fn show_with_registry_propagates_config_load_error() {
2052        crate::config::with_isolated_config_path_async(
2053            "models-show_with_registry_propagates_config_load_error",
2054            |fake_dir| async move {
2055                std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2056                let args = ShowArgs {
2057                    model: "any-model".to_string(),
2058                    remote: false,
2059                    provider: None,
2060                };
2061                let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
2062                assert!(result.is_err());
2063            },
2064        )
2065        .await;
2066    }
2067}