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