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