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