1use clap::{Args, Subcommand};
4use leviath_providers::{ModelCapabilities, ModelInfo};
5
6use super::run::build_provider_registry_from_config;
7use crate::config::Config;
8
9#[derive(Args)]
12pub struct ModelsArgs {
13 #[command(subcommand)]
14 pub command: ModelsCommand,
15}
16
17#[derive(Subcommand)]
18pub enum ModelsCommand {
19 List(ListArgs),
21 Show(ShowArgs),
23}
24
25#[derive(Args)]
26pub struct ListArgs {
27 #[arg(short, long)]
29 pub provider: Option<String>,
30 #[arg(short = 'r', long)]
32 pub remote: bool,
33 #[arg(short = 'a', long)]
35 pub all: bool,
36}
37
38#[derive(Args)]
39pub struct ShowArgs {
40 pub model: String,
42 #[arg(short, long)]
44 pub provider: Option<String>,
45 #[arg(short = 'r', long)]
47 pub remote: bool,
48}
49
50pub async fn execute(args: ModelsArgs) -> anyhow::Result<()> {
53 match args.command {
54 ModelsCommand::List(a) => list_with_registry(a, &build_provider_registry_from_config).await,
55 ModelsCommand::Show(a) => show_with_registry(a, &build_provider_registry_from_config).await,
56 }
57}
58
59struct BuiltinEntry {
63 provider: &'static str,
64 model_id: &'static str,
65 display_name: &'static str,
66 caps: ModelCapabilities,
67}
68
69const CLOSED_CATALOG_PROVIDERS: &[&str] = &["anthropic", "openai", "google"];
78
79pub fn closed_catalog_models() -> Vec<(String, String)> {
82 builtin_table()
83 .into_iter()
84 .filter(|e| CLOSED_CATALOG_PROVIDERS.contains(&e.provider))
85 .map(|e| (e.provider.to_string(), e.model_id.to_string()))
86 .collect()
87}
88
89fn builtin_table() -> Vec<BuiltinEntry> {
94 macro_rules! entry {
95 ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
97 temp=$t:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
98 entry!(
99 $provider,
100 $id,
101 $name,
102 temp = $t,
103 tools = true,
104 ctx = $ctx,
105 out = $out
106 )
107 };
108 ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
110 temp=$t:expr_2021, tools=$to:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
111 BuiltinEntry {
112 provider: $provider,
113 model_id: $id,
114 display_name: $name,
115 caps: ModelCapabilities {
116 supports_temperature: $t,
117 supports_streaming: true,
118 supports_tools: $to,
119 supports_system_prompt: true,
120 max_context_tokens: $ctx,
121 max_output_tokens: $out,
122 },
123 }
124 };
125 }
126
127 vec![
128 entry!(
130 "anthropic",
131 "claude-opus-5",
132 "Claude Opus 5",
133 temp = false,
134 ctx = 1_000_000,
135 out = 128_000
136 ),
137 entry!(
138 "anthropic",
139 "claude-sonnet-5",
140 "Claude Sonnet 5",
141 temp = false,
142 ctx = 1_000_000,
143 out = 128_000
144 ),
145 entry!(
146 "anthropic",
147 "claude-fable-5",
148 "Claude Fable 5",
149 temp = false,
150 ctx = 1_000_000,
151 out = 128_000
152 ),
153 entry!(
154 "anthropic",
155 "claude-opus-4-8",
156 "Claude Opus 4.8",
157 temp = false,
158 ctx = 1_000_000,
159 out = 128_000
160 ),
161 entry!(
162 "anthropic",
163 "claude-opus-4-7",
164 "Claude Opus 4.7",
165 temp = false,
166 ctx = 1_000_000,
167 out = 128_000
168 ),
169 entry!(
170 "anthropic",
171 "claude-opus-4-6",
172 "Claude Opus 4.6",
173 temp = true,
174 ctx = 1_000_000,
175 out = 128_000
176 ),
177 entry!(
178 "anthropic",
179 "claude-sonnet-4-6",
180 "Claude Sonnet 4.6",
181 temp = true,
182 ctx = 1_000_000,
183 out = 128_000
184 ),
185 entry!(
186 "anthropic",
187 "claude-haiku-4-5-20251001",
188 "Claude Haiku 4.5",
189 temp = true,
190 ctx = 200_000,
191 out = 65_536
192 ),
193 entry!(
196 "openai",
197 "gpt-5.5",
198 "GPT-5.5",
199 temp = true,
200 ctx = 1_050_000,
201 out = 128_000
202 ),
203 entry!(
204 "openai",
205 "gpt-5.4",
206 "GPT-5.4",
207 temp = true,
208 ctx = 1_050_000,
209 out = 128_000
210 ),
211 entry!(
212 "openai",
213 "gpt-5.4-mini",
214 "GPT-5.4 Mini",
215 temp = true,
216 ctx = 400_000,
217 out = 128_000
218 ),
219 entry!(
220 "openai",
221 "gpt-5.4-nano",
222 "GPT-5.4 Nano",
223 temp = true,
224 ctx = 400_000,
225 out = 128_000
226 ),
227 entry!(
232 "google",
233 "gemini-3.5-flash",
234 "Gemini 3.5 Flash",
235 temp = true,
236 ctx = 1_048_576,
237 out = 65_535
238 ),
239 entry!(
240 "google",
241 "gemini-3.1-pro-preview",
242 "Gemini 3.1 Pro (preview)",
243 temp = true,
244 ctx = 1_048_576,
245 out = 65_535
246 ),
247 entry!(
248 "google",
249 "gemini-3-flash",
250 "Gemini 3 Flash",
251 temp = true,
252 ctx = 1_048_576,
253 out = 65_535
254 ),
255 entry!(
256 "google",
257 "gemini-3.1-flash-lite",
258 "Gemini 3.1 Flash Lite",
259 temp = true,
260 ctx = 1_048_576,
261 out = 65_535
262 ),
263 entry!(
265 "openrouter",
266 "google/gemini-3.5-flash",
267 "Gemini 3.5 Flash",
268 temp = true,
269 ctx = 1_048_576,
270 out = 65_536
271 ),
272 entry!(
273 "openrouter",
274 "google/gemini-2.5-pro",
275 "Gemini 2.5 Pro",
276 temp = true,
277 ctx = 1_048_576,
278 out = 65_536
279 ),
280 entry!(
281 "openrouter",
282 "google/gemini-2.5-flash",
283 "Gemini 2.5 Flash",
284 temp = true,
285 ctx = 1_048_576,
286 out = 65_536
287 ),
288 entry!(
289 "openrouter",
290 "google/gemini-2.5-flash-lite",
291 "Gemini 2.5 Flash Lite",
292 temp = true,
293 ctx = 1_048_576,
294 out = 65_536
295 ),
296 entry!(
298 "openrouter",
299 "meta-llama/llama-4-maverick",
300 "Llama 4 Maverick",
301 temp = true,
302 ctx = 1_048_576,
303 out = 32_768
304 ),
305 entry!(
306 "openrouter",
307 "meta-llama/llama-4-scout",
308 "Llama 4 Scout",
309 temp = true,
310 ctx = 10_000_000,
311 out = 32_768
312 ),
313 entry!(
315 "openrouter",
316 "deepseek/deepseek-v4-pro",
317 "DeepSeek V4 Pro",
318 temp = true,
319 ctx = 1_048_576,
320 out = 393_216
321 ),
322 entry!(
323 "openrouter",
324 "deepseek/deepseek-v4-flash",
325 "DeepSeek V4 Flash",
326 temp = true,
327 ctx = 1_048_576,
328 out = 65_536
329 ),
330 entry!(
331 "openrouter",
332 "deepseek/deepseek-v3.2",
333 "DeepSeek V3.2",
334 temp = true,
335 ctx = 131_072,
336 out = 65_536
337 ),
338 entry!(
339 "openrouter",
340 "deepseek/deepseek-r1-0528",
341 "DeepSeek R1 (0528)",
342 temp = false,
343 tools = false,
344 ctx = 163_840,
345 out = 32_768
346 ),
347 entry!(
348 "openrouter",
349 "deepseek/deepseek-r1",
350 "DeepSeek R1",
351 temp = false,
352 tools = false,
353 ctx = 163_840,
354 out = 16_384
355 ),
356 entry!(
358 "openrouter",
359 "mistralai/mistral-large-2512",
360 "Mistral Large 3",
361 temp = true,
362 ctx = 262_144,
363 out = 32_768
364 ),
365 entry!(
366 "openrouter",
367 "mistralai/mistral-medium-3-5",
368 "Mistral Medium 3.5",
369 temp = true,
370 ctx = 256_000,
371 out = 32_768
372 ),
373 entry!(
374 "openrouter",
375 "mistralai/mistral-small-2603",
376 "Mistral Small 4",
377 temp = true,
378 ctx = 128_000,
379 out = 32_768
380 ),
381 entry!(
383 "openrouter",
384 "qwen/qwen3.6-plus",
385 "Qwen 3.6 Plus",
386 temp = true,
387 ctx = 1_048_576,
388 out = 65_536
389 ),
390 entry!(
391 "openrouter",
392 "qwen/qwen3-max",
393 "Qwen3 Max",
394 temp = true,
395 ctx = 131_072,
396 out = 32_768
397 ),
398 entry!(
399 "openrouter",
400 "qwen/qwen3-coder",
401 "Qwen3 Coder 480B",
402 temp = true,
403 ctx = 1_048_576,
404 out = 262_144
405 ),
406 ]
407}
408
409async fn list_with_registry(
431 args: ListArgs,
432 build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
433) -> anyhow::Result<()> {
434 let config = Config::load()?;
435 for warning in config.validate_keys() {
436 eprintln!("Warning: {}", warning);
437 }
438
439 let mut entries: Vec<ModelInfo> = builtin_table()
441 .into_iter()
442 .map(|e| ModelInfo {
443 id: e.model_id.to_string(),
444 display_name: Some(e.display_name.to_string()),
445 provider: e.provider.to_string(),
446 capabilities: e.caps,
447 })
448 .collect();
449
450 let registry = build_registry(&config);
455 let available: std::collections::HashSet<String> = registry
456 .provider_names()
457 .into_iter()
458 .map(str::to_string)
459 .collect();
460 if !args.all {
461 entries.retain(|e| available.contains(&e.provider));
462 }
463
464 if args.remote {
466 for provider_name in registry.provider_names() {
467 if let Some(ref filter) = args.provider
469 && filter != provider_name
470 {
471 continue;
472 }
473
474 let provider = registry
486 .get(provider_name)
487 .expect("provider_names returns registered names");
488 match provider.list_models().await {
489 Ok(remote_models) => {
490 for rm in remote_models {
491 if let Some(existing) = entries.iter_mut().find(|e| e.id == rm.id) {
493 *existing = rm;
494 } else {
495 entries.push(rm);
496 }
497 }
498 }
499 Err(e) => {
500 eprintln!(
501 "Warning: could not fetch models from '{}': {}",
502 provider_name, e
503 );
504 }
505 }
506 }
507 }
508
509 if let Some(ref filter) = args.provider {
511 entries.retain(|e| &e.provider == filter);
512 }
513
514 let overridden: std::collections::HashSet<String> =
516 config.model_capabilities.keys().cloned().collect();
517
518 for entry in entries.iter_mut() {
519 if let Some(user_caps) = config.model_capabilities.get(&entry.id) {
520 entry.capabilities = user_caps.clone();
521 }
522 }
523
524 if entries.is_empty() {
525 println!("No models available.");
529 println!(
530 "(configure a provider with `lev setup`, or pass --all to see every \
531 model Leviath knows about)"
532 );
533 return Ok(());
534 }
535
536 println!(
538 "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
539 "PROVIDER", "MODEL ID", "TEMP", "TOOLS", "CTX", "OUTPUT"
540 );
541 println!("{}", "-".repeat(85));
542
543 for entry in &entries {
544 let provider_col = if overridden.contains(&entry.id) {
545 format!("*{}", entry.provider)
546 } else {
547 entry.provider.clone()
548 };
549
550 let temp = bool_icon(entry.capabilities.supports_temperature);
551 let tools = bool_icon(entry.capabilities.supports_tools);
552 let ctx = fmt_tokens(entry.capabilities.max_context_tokens);
553 let out = fmt_tokens(entry.capabilities.max_output_tokens);
554
555 println!(
556 "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
557 provider_col, entry.id, temp, tools, ctx, out
558 );
559 }
560
561 if overridden
562 .iter()
563 .any(|id| entries.iter().any(|e| &e.id == id))
564 {
565 println!("\n* = capabilities overridden via [model_capabilities] in config");
566 }
567
568 Ok(())
569}
570
571async fn show_with_registry(
576 args: ShowArgs,
577 build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
578) -> anyhow::Result<()> {
579 let config = Config::load()?;
580 for warning in config.validate_keys() {
581 eprintln!("Warning: {}", warning);
582 }
583
584 let model_id = &args.model;
585
586 if let Some(user_caps) = config.model_capabilities.get(model_id) {
588 print_model_detail(model_id, None, "config (user override)", user_caps, true);
589 return Ok(());
590 }
591
592 let builtin = builtin_table();
594 if let Some(entry) = builtin.iter().find(|e| e.model_id == model_id) {
595 print_model_detail(
596 model_id,
597 Some(entry.display_name),
598 entry.provider,
599 &entry.caps,
600 false,
601 );
602 return Ok(());
603 }
604
605 if args.remote
607 && let Some(ref provider_name) = args.provider
608 {
609 let registry = build_registry(&config);
610 if let Some(provider) = registry.get(provider_name) {
611 match provider.list_models().await {
612 Ok(models) => {
613 if let Some(info) = models.iter().find(|m| &m.id == model_id) {
614 print_model_detail(
615 model_id,
616 info.display_name.as_deref(),
617 &info.provider,
618 &info.capabilities,
619 false,
620 );
621 return Ok(());
622 }
623 }
624 Err(e) => {
625 eprintln!(
626 "Warning: could not fetch models from '{}': {}",
627 provider_name, e
628 );
629 }
630 }
631 } else {
632 eprintln!(
633 "Warning: provider '{}' is not configured (missing API key?)",
634 provider_name
635 );
636 }
637 }
638
639 println!("Model '{}' not found.", model_id);
641 println!(
642 "Add it to {} under [model_capabilities.'{}']",
643 Config::config_path().display(),
644 model_id
645 );
646 println!();
647 println!("Example:");
648 println!("[model_capabilities.'{}']", model_id);
649 println!("supports_temperature = true");
650 println!("supports_streaming = true");
651 println!("supports_tools = true");
652 println!("supports_system_prompt = true");
653 println!("max_context_tokens = 8192");
654 println!("max_output_tokens = 4096");
655
656 Ok(())
657}
658
659fn bool_icon(b: bool) -> &'static str {
662 if b { "✓" } else { "✗" }
663}
664
665fn fmt_tokens(n: usize) -> String {
667 if n >= 1_000_000 {
668 format!("{}M", n / 1_000_000)
669 } else if n >= 1_000 {
670 format!("{}K", n / 1_000)
671 } else {
672 n.to_string()
673 }
674}
675
676fn print_model_detail(
678 id: &str,
679 display_name: Option<&str>,
680 provider: &str,
681 caps: &ModelCapabilities,
682 is_user_override: bool,
683) {
684 println!("Model: {}", id);
685 if let Some(name) = display_name {
686 println!("Name: {}", name);
687 }
688 println!("Provider: {}", provider);
689 if is_user_override {
690 println!("Source: user override (config)");
691 }
692 println!();
693 println!("Capabilities");
694 println!("------------");
695 println!(" Temperature: {}", bool_icon(caps.supports_temperature));
696 println!(" Streaming: {}", bool_icon(caps.supports_streaming));
697 println!(" Tool calling: {}", bool_icon(caps.supports_tools));
698 println!(
699 " System prompt: {}",
700 bool_icon(caps.supports_system_prompt)
701 );
702 println!(
703 " Context window: {} tokens ({})",
704 caps.max_context_tokens,
705 fmt_tokens(caps.max_context_tokens)
706 );
707 println!(
708 " Max output: {} tokens ({})",
709 caps.max_output_tokens,
710 fmt_tokens(caps.max_output_tokens)
711 );
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
721 fn fmt_tokens_millions() {
722 assert_eq!(fmt_tokens(1_000_000), "1M");
723 assert_eq!(fmt_tokens(2_000_000), "2M");
724 }
725
726 #[test]
727 fn fmt_tokens_thousands() {
728 assert_eq!(fmt_tokens(128_000), "128K");
729 assert_eq!(fmt_tokens(4_096), "4K");
730 assert_eq!(fmt_tokens(1_000), "1K");
731 }
732
733 #[test]
734 fn fmt_tokens_small() {
735 assert_eq!(fmt_tokens(512), "512");
736 assert_eq!(fmt_tokens(0), "0");
737 }
738
739 #[test]
742 fn bool_icon_values() {
743 assert_eq!(bool_icon(true), "✓");
744 assert_eq!(bool_icon(false), "✗");
745 }
746
747 #[test]
750 fn builtin_table_is_not_empty() {
751 let table = builtin_table();
752 assert!(!table.is_empty());
753 }
754
755 #[test]
756 fn builtin_table_has_anthropic_models() {
757 let table = builtin_table();
758 let anthropic: Vec<_> = table.iter().filter(|e| e.provider == "anthropic").collect();
759 assert!(!anthropic.is_empty());
760 }
761
762 #[test]
763 fn builtin_table_has_openai_models() {
764 let table = builtin_table();
765 let openai: Vec<_> = table.iter().filter(|e| e.provider == "openai").collect();
766 assert!(!openai.is_empty());
767 }
768
769 #[test]
770 fn builtin_table_has_openrouter_models() {
771 let table = builtin_table();
772 let openrouter: Vec<_> = table
773 .iter()
774 .filter(|e| e.provider == "openrouter")
775 .collect();
776 assert!(!openrouter.is_empty());
777 }
778
779 #[test]
780 fn builtin_entries_have_valid_capabilities() {
781 for entry in builtin_table() {
782 assert!(entry.caps.max_context_tokens > 0);
783 assert!(entry.caps.max_output_tokens > 0);
784 assert!(entry.caps.supports_streaming);
785 assert!(entry.caps.supports_system_prompt);
786 }
787 }
788
789 #[test]
790 fn builtin_entries_have_unique_model_ids() {
791 let table = builtin_table();
792 let ids: Vec<&str> = table.iter().map(|e| e.model_id).collect();
793 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
794 assert_eq!(ids.len(), unique.len());
795 }
796
797 #[test]
798 fn deepseek_r1_models_no_tools() {
799 let table = builtin_table();
800 for entry in &table {
801 if entry.model_id.contains("deepseek-r1") {
802 assert!(!entry.caps.supports_tools);
803 }
804 }
805 }
806
807 #[test]
810 fn print_model_detail_does_not_panic() {
811 let caps = ModelCapabilities {
812 supports_temperature: true,
813 supports_streaming: true,
814 supports_tools: true,
815 supports_system_prompt: true,
816 max_context_tokens: 100_000,
817 max_output_tokens: 8_192,
818 };
819 print_model_detail("test-model", Some("Test Model"), "test", &caps, false);
821 print_model_detail("test-model", None, "test", &caps, true);
822 }
823
824 #[test]
827 fn fmt_tokens_exact_boundary() {
828 assert_eq!(fmt_tokens(999), "999");
829 assert_eq!(fmt_tokens(999_999), "999K");
830 }
831
832 #[test]
833 fn fmt_tokens_large_millions() {
834 assert_eq!(fmt_tokens(10_000_000), "10M");
835 }
836
837 #[test]
840 fn builtin_table_claude_opus_no_temperature() {
841 let table = builtin_table();
842 for entry in &table {
843 if entry.model_id == "claude-opus-4-8" || entry.model_id == "claude-opus-4-7" {
844 assert!(!entry.caps.supports_temperature);
845 }
846 }
847 }
848
849 #[test]
850 fn builtin_table_claude_sonnet_supports_temperature() {
851 let table = builtin_table();
852 let sonnet = table
853 .iter()
854 .find(|e| e.model_id == "claude-sonnet-4-6")
855 .expect("claude-sonnet-4-6 should be in table");
856 assert!(sonnet.caps.supports_temperature);
857 }
858
859 #[test]
860 fn builtin_table_has_display_names() {
861 let table = builtin_table();
862 for entry in &table {
863 assert!(!entry.display_name.is_empty());
864 }
865 }
866
867 #[test]
868 fn builtin_table_context_larger_than_output() {
869 let table = builtin_table();
870 for entry in &table {
871 assert!(entry.caps.max_context_tokens >= entry.caps.max_output_tokens);
872 }
873 }
874
875 #[test]
878 fn bool_icon_returns_unicode() {
879 assert!(!bool_icon(true).is_empty());
880 assert!(!bool_icon(false).is_empty());
881 assert_ne!(bool_icon(true), bool_icon(false));
882 }
883
884 #[test]
887 fn builtin_table_openai_models_support_temperature() {
888 let table = builtin_table();
889 for entry in &table {
890 if entry.provider == "openai" {
891 assert!(entry.caps.supports_temperature);
892 }
893 }
894 }
895
896 #[test]
901 fn builtin_table_offers_native_google_models() {
902 let table = builtin_table();
903 let native: Vec<&str> = table
904 .iter()
905 .filter(|e| e.provider == "google")
906 .map(|e| e.model_id)
907 .collect();
908 assert!(
909 !native.is_empty(),
910 "the native google provider must offer models of its own"
911 );
912 for id in &native {
914 assert!(
915 !id.contains('/'),
916 "native google model id must not be vendor-prefixed: {id}"
917 );
918 }
919 }
920
921 #[test]
922 fn builtin_table_gemini_flash_models_exist() {
923 let table = builtin_table();
924 let flash: Vec<_> = table
925 .iter()
926 .filter(|e| e.model_id.contains("gemini") && e.model_id.contains("flash"))
927 .collect();
928 assert!(!flash.is_empty());
929 }
930
931 #[test]
932 fn builtin_table_deepseek_r1_no_temperature() {
933 let table = builtin_table();
934 for entry in &table {
935 if entry.model_id.contains("deepseek-r1") {
936 assert!(!entry.caps.supports_temperature);
937 }
938 }
939 }
940
941 #[test]
942 fn builtin_table_qwen_models_exist() {
943 let table = builtin_table();
944 let qwen: Vec<_> = table
945 .iter()
946 .filter(|e| e.model_id.contains("qwen"))
947 .collect();
948 assert!(!qwen.is_empty());
949 }
950
951 #[test]
952 fn builtin_table_mistral_models_exist() {
953 let table = builtin_table();
954 let mistral: Vec<_> = table
955 .iter()
956 .filter(|e| e.model_id.contains("mistral"))
957 .collect();
958 assert!(!mistral.is_empty());
959 }
960
961 #[test]
962 fn builtin_table_all_entries_have_provider() {
963 let table = builtin_table();
964 for entry in &table {
965 assert!(!entry.provider.is_empty());
966 }
967 }
968
969 #[test]
970 fn builtin_table_all_entries_have_model_id() {
971 let table = builtin_table();
972 for entry in &table {
973 assert!(!entry.model_id.is_empty());
974 }
975 }
976
977 #[tokio::test]
980 async fn execute_list_command_runs_without_error() {
981 crate::config::with_isolated_config_path_async(
982 "models-execute_list_command_runs_without_error",
983 |_fake_dir| async move {
984 let args = ModelsArgs {
985 command: ModelsCommand::List(ListArgs {
986 provider: None,
987 remote: false,
988 all: false,
989 }),
990 };
991 let result = execute(args).await;
993 assert!(result.is_ok());
994 },
995 )
996 .await;
997 }
998
999 #[tokio::test]
1000 async fn execute_list_with_provider_filter_runs_without_error() {
1001 crate::config::with_isolated_config_path_async(
1002 "models-execute_list_with_provider_filter_runs_without_error",
1003 |_fake_dir| async move {
1004 let args = ModelsArgs {
1005 command: ModelsCommand::List(ListArgs {
1006 provider: Some("anthropic".to_string()),
1007 remote: false,
1008 all: false,
1009 }),
1010 };
1011 let result = execute(args).await;
1012 assert!(result.is_ok());
1013 },
1014 )
1015 .await;
1016 }
1017
1018 #[tokio::test]
1019 async fn execute_list_with_nonexistent_provider_filter() {
1020 crate::config::with_isolated_config_path_async(
1021 "models-execute_list_with_nonexistent_provider_filter",
1022 |_fake_dir| async move {
1023 let args = ModelsArgs {
1024 command: ModelsCommand::List(ListArgs {
1025 provider: Some("nonexistent_provider".to_string()),
1026 remote: false,
1027 all: false,
1028 }),
1029 };
1030 let result = execute(args).await;
1032 assert!(result.is_ok());
1033 },
1034 )
1035 .await;
1036 }
1037
1038 #[tokio::test]
1039 async fn execute_show_known_model_runs_without_error() {
1040 crate::config::with_isolated_config_path_async(
1041 "models-execute_show_known_model_runs_without_error",
1042 |_fake_dir| async move {
1043 let args = ModelsArgs {
1044 command: ModelsCommand::Show(ShowArgs {
1045 model: "claude-sonnet-4-6".to_string(),
1046 provider: None,
1047 remote: false,
1048 }),
1049 };
1050 let result = execute(args).await;
1052 assert!(result.is_ok());
1053 },
1054 )
1055 .await;
1056 }
1057
1058 #[tokio::test]
1059 async fn execute_show_unknown_model_runs_without_error() {
1060 crate::config::with_isolated_config_path_async(
1061 "models-execute_show_unknown_model_runs_without_error",
1062 |_fake_dir| async move {
1063 let args = ModelsArgs {
1064 command: ModelsCommand::Show(ShowArgs {
1065 model: "totally-unknown-model-xyz".to_string(),
1066 provider: None,
1067 remote: false,
1068 }),
1069 };
1070 let result = execute(args).await;
1072 assert!(result.is_ok());
1073 },
1074 )
1075 .await;
1076 }
1077
1078 #[tokio::test]
1079 async fn execute_show_unknown_model_with_remote_no_provider() {
1080 crate::config::with_isolated_config_path_async(
1081 "models-execute_show_unknown_model_with_remote_no_provider",
1082 |_fake_dir| async move {
1083 let args = ModelsArgs {
1084 command: ModelsCommand::Show(ShowArgs {
1085 model: "totally-unknown-model-xyz".to_string(),
1086 provider: None,
1087 remote: true, }),
1089 };
1090 let result = execute(args).await;
1091 assert!(result.is_ok());
1092 },
1093 )
1094 .await;
1095 }
1096
1097 #[tokio::test]
1098 async fn execute_show_unknown_model_with_remote_unconfigured_provider() {
1099 crate::config::with_isolated_config_path_async(
1100 "models-execute_show_unknown_model_with_remote_unconfigured_provider",
1101 |_fake_dir| async move {
1102 let args = ModelsArgs {
1103 command: ModelsCommand::Show(ShowArgs {
1104 model: "totally-unknown-model-xyz".to_string(),
1105 provider: Some("anthropic".to_string()),
1106 remote: true,
1107 }),
1109 };
1110 let result = execute(args).await;
1112 assert!(result.is_ok());
1113 },
1114 )
1115 .await;
1116 }
1117
1118 #[tokio::test]
1121 async fn list_with_openrouter_filter() {
1122 crate::config::with_isolated_config_path_async(
1130 "models-list-openrouter-filter",
1131 |_fake_dir| async move {
1132 let args = ModelsArgs {
1133 command: ModelsCommand::List(ListArgs {
1134 provider: Some("openrouter".to_string()),
1135 remote: false,
1136 all: false,
1137 }),
1138 };
1139 let result = execute(args).await;
1140 assert!(result.is_ok());
1141 },
1142 )
1143 .await;
1144 }
1145
1146 #[tokio::test]
1147 async fn list_with_openai_filter() {
1148 crate::config::with_isolated_config_path_async(
1151 "models-list-openai-filter",
1152 |_fake_dir| async move {
1153 let args = ModelsArgs {
1154 command: ModelsCommand::List(ListArgs {
1155 provider: Some("openai".to_string()),
1156 remote: false,
1157 all: false,
1158 }),
1159 };
1160 let result = execute(args).await;
1161 assert!(result.is_ok());
1162 },
1163 )
1164 .await;
1165 }
1166
1167 #[tokio::test]
1168 async fn show_builtin_anthropic_opus() {
1169 crate::config::with_isolated_config_path_async(
1172 "models-show-anthropic-opus",
1173 |_fake_dir| async move {
1174 let args = ModelsArgs {
1175 command: ModelsCommand::Show(ShowArgs {
1176 model: "claude-opus-4-6".to_string(),
1177 provider: None,
1178 remote: false,
1179 }),
1180 };
1181 let result = execute(args).await;
1182 assert!(result.is_ok());
1183 },
1184 )
1185 .await;
1186 }
1187
1188 #[tokio::test]
1189 async fn show_builtin_openai_model() {
1190 crate::config::with_isolated_config_path_async(
1193 "models-show-openai-model",
1194 |_fake_dir| async move {
1195 let args = ModelsArgs {
1196 command: ModelsCommand::Show(ShowArgs {
1197 model: "gpt-5.5".to_string(),
1198 provider: None,
1199 remote: false,
1200 }),
1201 };
1202 let result = execute(args).await;
1203 assert!(result.is_ok());
1204 },
1205 )
1206 .await;
1207 }
1208
1209 #[tokio::test]
1210 async fn show_builtin_deepseek_r1() {
1211 crate::config::with_isolated_config_path_async(
1214 "models-show-deepseek-r1",
1215 |_fake_dir| async move {
1216 let args = ModelsArgs {
1217 command: ModelsCommand::Show(ShowArgs {
1218 model: "deepseek/deepseek-r1".to_string(),
1219 provider: None,
1220 remote: false,
1221 }),
1222 };
1223 let result = execute(args).await;
1224 assert!(result.is_ok());
1225 },
1226 )
1227 .await;
1228 }
1229
1230 #[test]
1233 fn builtin_table_to_model_info_preserves_data() {
1234 let table = builtin_table();
1235 let infos: Vec<ModelInfo> = table
1236 .into_iter()
1237 .map(|e| ModelInfo {
1238 id: e.model_id.to_string(),
1239 display_name: Some(e.display_name.to_string()),
1240 provider: e.provider.to_string(),
1241 capabilities: e.caps,
1242 })
1243 .collect();
1244
1245 assert!(!infos.is_empty());
1246 for info in &infos {
1247 assert!(!info.id.is_empty());
1248 assert!(info.display_name.is_some());
1249 assert!(!info.provider.is_empty());
1250 }
1251 }
1252
1253 #[test]
1256 fn print_model_detail_with_no_tools_no_temp() {
1257 let caps = ModelCapabilities {
1258 supports_temperature: false,
1259 supports_streaming: false,
1260 supports_tools: false,
1261 supports_system_prompt: false,
1262 max_context_tokens: 1000,
1263 max_output_tokens: 500,
1264 };
1265 print_model_detail("test-model", Some("Test"), "test", &caps, false);
1267 }
1268
1269 #[test]
1270 fn print_model_detail_user_override_source() {
1271 let caps = ModelCapabilities::default();
1272 print_model_detail("override-model", None, "custom", &caps, true);
1274 }
1275
1276 #[test]
1279 fn fmt_tokens_just_below_thousand() {
1280 assert_eq!(fmt_tokens(999), "999");
1281 }
1282
1283 #[test]
1284 fn fmt_tokens_just_at_thousand() {
1285 assert_eq!(fmt_tokens(1000), "1K");
1286 }
1287
1288 #[test]
1289 fn fmt_tokens_just_below_million() {
1290 assert_eq!(fmt_tokens(999_999), "999K");
1291 }
1292
1293 #[test]
1294 fn fmt_tokens_just_at_million() {
1295 assert_eq!(fmt_tokens(1_000_000), "1M");
1296 }
1297
1298 #[test]
1299 fn fmt_tokens_non_round_thousands() {
1300 assert_eq!(fmt_tokens(1500), "1K");
1302 assert_eq!(fmt_tokens(65_536), "65K");
1303 }
1304
1305 #[tokio::test]
1314 async fn list_builtin_no_filter_succeeds() {
1315 crate::config::with_isolated_config_path_async(
1316 "models-list_builtin_no_filter_succeeds",
1317 |_fake_dir| async move {
1318 let args = ListArgs {
1319 remote: false,
1320 provider: None,
1321 all: false,
1322 };
1323 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1324 assert!(result.is_ok());
1325 },
1326 )
1327 .await;
1328 }
1329
1330 #[tokio::test]
1331 async fn list_builtin_with_provider_filter_succeeds() {
1332 crate::config::with_isolated_config_path_async(
1333 "models-list_builtin_with_provider_filter_succeeds",
1334 |_fake_dir| async move {
1335 let args = ListArgs {
1336 remote: false,
1337 provider: Some("anthropic".to_string()),
1338 all: false,
1339 };
1340 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1341 assert!(result.is_ok());
1342 },
1343 )
1344 .await;
1345 }
1346
1347 #[tokio::test]
1348 async fn list_unknown_provider_filter_finds_nothing() {
1349 crate::config::with_isolated_config_path_async(
1350 "models-list_unknown_provider_filter_finds_nothing",
1351 |_fake_dir| async move {
1352 let args = ListArgs {
1353 remote: false,
1354 provider: Some("no-such-provider".to_string()),
1355 all: false,
1356 };
1357 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1359 assert!(result.is_ok());
1360 },
1361 )
1362 .await;
1363 }
1364
1365 #[tokio::test]
1366 async fn show_builtin_model_succeeds() {
1367 crate::config::with_isolated_config_path_async(
1368 "models-show_builtin_model_succeeds",
1369 |_fake_dir| async move {
1370 let known_id = builtin_table()[0].model_id.to_string();
1372 let args = ShowArgs {
1373 model: known_id,
1374 remote: false,
1375 provider: None,
1376 };
1377 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1378 assert!(result.is_ok());
1379 },
1380 )
1381 .await;
1382 }
1383
1384 #[tokio::test]
1385 async fn show_unknown_model_without_remote_succeeds_with_warning() {
1386 crate::config::with_isolated_config_path_async(
1387 "models-show_unknown_model_without_remote_succeeds_with_warning",
1388 |_fake_dir| async move {
1389 let args = ShowArgs {
1390 model: "totally-unknown-model-xyz".to_string(),
1391 remote: false,
1392 provider: None,
1393 };
1394 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1396 assert!(result.is_ok());
1397 },
1398 )
1399 .await;
1400 }
1401
1402 #[tokio::test]
1403 async fn show_remote_without_provider_falls_through_gracefully() {
1404 crate::config::with_isolated_config_path_async(
1405 "models-show_remote_without_provider_falls_through_gracefully",
1406 |_fake_dir| async move {
1407 let args = ShowArgs {
1410 model: "totally-unknown-model-xyz".to_string(),
1411 remote: true,
1412 provider: None,
1413 };
1414 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1415 assert!(result.is_ok());
1416 },
1417 )
1418 .await;
1419 }
1420
1421 struct MockProvider {
1433 models: Vec<ModelInfo>,
1434 fail: bool,
1435 }
1436
1437 #[async_trait::async_trait]
1438 impl leviath_providers::Provider for MockProvider {
1439 async fn infer(
1440 &self,
1441 _request: leviath_providers::InferenceRequest,
1442 ) -> Result<leviath_providers::InferenceResponse, leviath_providers::ProviderError>
1443 {
1444 Err(leviath_providers::ProviderError::Other(
1445 "MockProvider does not support infer".to_string(),
1446 ))
1447 }
1448
1449 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1450 leviath_core::estimate_tokens(text)
1451 }
1452
1453 fn max_context_tokens(&self, _model: &str) -> usize {
1454 100_000
1455 }
1456
1457 fn name(&self) -> &str {
1458 "mock"
1459 }
1460
1461 fn capabilities(&self, _model: &str) -> ModelCapabilities {
1462 ModelCapabilities::default()
1463 }
1464
1465 async fn list_models(&self) -> Result<Vec<ModelInfo>, leviath_providers::ProviderError> {
1466 if self.fail {
1467 Err(leviath_providers::ProviderError::Other(
1468 "mock provider failure".to_string(),
1469 ))
1470 } else {
1471 Ok(self.models.clone())
1472 }
1473 }
1474 }
1475
1476 fn mock_registry(
1477 provider_name: &'static str,
1478 models: Vec<ModelInfo>,
1479 fail: bool,
1480 ) -> impl Fn(&Config) -> leviath_runtime::ProviderRegistry {
1481 move |_config: &Config| {
1488 let mut registry = leviath_runtime::ProviderRegistry::new();
1489 registry.register(
1490 provider_name.to_string(),
1491 std::sync::Arc::new(MockProvider {
1492 models: models.clone(),
1493 fail,
1494 }),
1495 );
1496 registry
1497 }
1498 }
1499
1500 #[tokio::test]
1501 async fn list_remote_merges_new_model_from_provider() {
1502 crate::config::with_isolated_config_path_async(
1503 "models-list_remote_merges_new_model_from_provider",
1504 |_fake_dir| async move {
1505 let args = ListArgs {
1506 remote: true,
1507 provider: Some("mock".to_string()),
1508 all: false,
1509 };
1510 let new_model = ModelInfo {
1511 id: "mock-brand-new-model".to_string(),
1512 display_name: Some("Mock Brand New Model".to_string()),
1513 provider: "mock".to_string(),
1514 capabilities: ModelCapabilities::default(),
1515 };
1516 let result =
1517 list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1518 assert!(result.is_ok());
1519 },
1520 )
1521 .await;
1522 }
1523
1524 #[tokio::test]
1525 async fn list_remote_without_provider_filter_queries_all_providers() {
1526 crate::config::with_isolated_config_path_async(
1531 "models-list_remote_without_provider_filter_queries_all_providers",
1532 |_fake_dir| async move {
1533 let args = ListArgs {
1534 remote: true,
1535 provider: None,
1536 all: false,
1537 };
1538 let new_model = ModelInfo {
1539 id: "mock-brand-new-model".to_string(),
1540 display_name: Some("Mock Brand New Model".to_string()),
1541 provider: "mock".to_string(),
1542 capabilities: ModelCapabilities::default(),
1543 };
1544 let result =
1545 list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1546 assert!(result.is_ok());
1547 },
1548 )
1549 .await;
1550 }
1551
1552 #[tokio::test]
1553 async fn list_remote_overrides_builtin_entry_with_same_id() {
1554 crate::config::with_isolated_config_path_async(
1555 "models-list_remote_overrides_builtin_entry_with_same_id",
1556 |_fake_dir| async move {
1557 let known_id = builtin_table()[0].model_id.to_string();
1558 let args = ListArgs {
1559 remote: true,
1560 provider: Some("mock".to_string()),
1561 all: false,
1562 };
1563 let overriding_model = ModelInfo {
1564 id: known_id,
1565 display_name: Some("Overridden".to_string()),
1566 provider: "mock".to_string(),
1567 capabilities: ModelCapabilities::default(),
1568 };
1569 let result =
1570 list_with_registry(args, &mock_registry("mock", vec![overriding_model], false))
1571 .await;
1572 assert!(result.is_ok());
1573 },
1574 )
1575 .await;
1576 }
1577
1578 #[tokio::test]
1583 async fn list_shows_only_providers_the_install_has_credentials_for() {
1584 crate::config::with_isolated_config_path_async(
1585 "models-list_only_available",
1586 |_fake_dir| async move {
1587 let args = ListArgs {
1588 remote: false,
1589 provider: None,
1590 all: false,
1591 };
1592 let result =
1595 list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1596 assert!(result.is_ok());
1597 },
1598 )
1599 .await;
1600 }
1601
1602 #[tokio::test]
1606 async fn list_remote_overrides_a_builtin_entry_with_the_same_id() {
1607 crate::config::with_isolated_config_path_async(
1608 "models-list_remote_override",
1609 |_fake_dir| async move {
1610 let remote = vec![ModelInfo {
1611 id: "claude-sonnet-5".to_string(),
1612 display_name: Some("Claude Sonnet 5 (remote)".to_string()),
1613 provider: "anthropic".to_string(),
1614 capabilities: leviath_providers::ModelCapabilities::default(),
1615 }];
1616 let args = ListArgs {
1617 remote: true,
1618 provider: None,
1619 all: false,
1620 };
1621 let result =
1622 list_with_registry(args, &mock_registry("anthropic", remote, false)).await;
1623 assert!(result.is_ok());
1624 },
1625 )
1626 .await;
1627 }
1628
1629 #[tokio::test]
1632 async fn list_all_includes_providers_without_credentials() {
1633 crate::config::with_isolated_config_path_async(
1634 "models-list_all_includes_everything",
1635 |_fake_dir| async move {
1636 let args = ListArgs {
1637 remote: false,
1638 provider: None,
1639 all: true,
1640 };
1641 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1642 assert!(result.is_ok());
1643 },
1644 )
1645 .await;
1646 }
1647
1648 #[tokio::test]
1651 async fn list_marks_overridden_capabilities_for_an_available_provider() {
1652 crate::config::with_isolated_config_path_async(
1653 "models-list_overridden_available",
1654 |_fake_dir| async move {
1655 let mut config = Config::default();
1656 config.model_capabilities.insert(
1657 "claude-sonnet-5".to_string(),
1658 leviath_providers::ModelCapabilities::default(),
1659 );
1660 config
1661 .save_to_path(&Config::config_path())
1662 .expect("the isolated config path is writable");
1663 let args = ListArgs {
1664 remote: false,
1665 provider: None,
1666 all: false,
1667 };
1668 let result =
1669 list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1670 assert!(result.is_ok());
1671 },
1672 )
1673 .await;
1674 }
1675
1676 #[tokio::test]
1677 async fn list_remote_provider_error_warns_and_continues() {
1678 crate::config::with_isolated_config_path_async(
1679 "models-list_remote_provider_error_warns_and_continues",
1680 |_fake_dir| async move {
1681 let args = ListArgs {
1682 remote: true,
1683 provider: Some("mock".to_string()),
1684 all: false,
1685 };
1686 let result = list_with_registry(args, &mock_registry("mock", vec![], true)).await;
1687 assert!(result.is_ok());
1688 },
1689 )
1690 .await;
1691 }
1692
1693 #[tokio::test]
1694 async fn list_remote_skips_providers_not_matching_filter() {
1695 crate::config::with_isolated_config_path_async(
1696 "models-list_remote_skips_providers_not_matching_filter",
1697 |_fake_dir| async move {
1698 let args = ListArgs {
1702 remote: true,
1703 provider: Some("mock-other".to_string()),
1704 all: false,
1705 };
1706 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1707 assert!(result.is_ok());
1708 },
1709 )
1710 .await;
1711 }
1712
1713 #[tokio::test]
1714 async fn show_remote_finds_model_from_provider() {
1715 crate::config::with_isolated_config_path_async(
1716 "models-show_remote_finds_model_from_provider",
1717 |_fake_dir| async move {
1718 let args = ShowArgs {
1719 model: "mock-remote-model".to_string(),
1720 remote: true,
1721 provider: Some("mock".to_string()),
1722 };
1723 let remote_model = ModelInfo {
1724 id: "mock-remote-model".to_string(),
1725 display_name: Some("Mock Remote Model".to_string()),
1726 provider: "mock".to_string(),
1727 capabilities: ModelCapabilities::default(),
1728 };
1729 let result =
1730 show_with_registry(args, &mock_registry("mock", vec![remote_model], false))
1731 .await;
1732 assert!(result.is_ok());
1733 },
1734 )
1735 .await;
1736 }
1737
1738 #[tokio::test]
1739 async fn show_remote_model_not_found_in_provider_list_falls_through() {
1740 crate::config::with_isolated_config_path_async(
1741 "models-show_remote_model_not_found_in_provider_list_falls_through",
1742 |_fake_dir| async move {
1743 let args = ShowArgs {
1744 model: "totally-unknown-model-xyz".to_string(),
1745 remote: true,
1746 provider: Some("mock".to_string()),
1747 };
1748 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1749 assert!(result.is_ok());
1750 },
1751 )
1752 .await;
1753 }
1754
1755 #[tokio::test]
1756 async fn show_remote_provider_error_warns_and_falls_through() {
1757 crate::config::with_isolated_config_path_async(
1758 "models-show_remote_provider_error_warns_and_falls_through",
1759 |_fake_dir| async move {
1760 let args = ShowArgs {
1761 model: "totally-unknown-model-xyz".to_string(),
1762 remote: true,
1763 provider: Some("mock".to_string()),
1764 };
1765 let result = show_with_registry(args, &mock_registry("mock", vec![], true)).await;
1766 assert!(result.is_ok());
1767 },
1768 )
1769 .await;
1770 }
1771
1772 #[tokio::test]
1773 async fn show_remote_unconfigured_provider_warns_and_falls_through() {
1774 crate::config::with_isolated_config_path_async(
1775 "models-show_remote_unconfigured_provider_warns_and_falls_through",
1776 |_fake_dir| async move {
1777 let args = ShowArgs {
1780 model: "totally-unknown-model-xyz".to_string(),
1781 remote: true,
1782 provider: Some("nonexistent-provider".to_string()),
1783 };
1784 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1785 assert!(result.is_ok());
1786 },
1787 )
1788 .await;
1789 }
1790
1791 #[tokio::test]
1800 async fn list_prints_warning_and_applies_model_capabilities_override() {
1801 crate::config::with_isolated_config_path_async(
1802 "models-list-override",
1803 |_fake_dir| async move {
1804 let known_id = builtin_table()[0].model_id.to_string();
1805 let mut fake_config = Config::default();
1806 fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1807 fake_config.model_capabilities.insert(
1808 known_id,
1809 ModelCapabilities {
1810 supports_temperature: false,
1811 supports_streaming: false,
1812 supports_tools: false,
1813 supports_system_prompt: false,
1814 max_context_tokens: 1,
1815 max_output_tokens: 1,
1816 },
1817 );
1818 std::fs::write(
1819 Config::config_path(),
1820 toml::to_string(&fake_config).unwrap(),
1821 )
1822 .unwrap();
1823
1824 let args = ListArgs {
1825 remote: false,
1826 provider: None,
1827 all: false,
1828 };
1829 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1830 assert!(result.is_ok());
1831 },
1832 )
1833 .await;
1834 }
1835
1836 #[tokio::test]
1837 async fn show_prints_warning_and_uses_model_capabilities_override() {
1838 crate::config::with_isolated_config_path_async(
1839 "models-show-override",
1840 |_fake_dir| async move {
1841 let known_id = builtin_table()[0].model_id.to_string();
1842 let mut fake_config = Config::default();
1843 fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1844 fake_config.model_capabilities.insert(
1845 known_id.clone(),
1846 ModelCapabilities {
1847 supports_temperature: false,
1848 supports_streaming: false,
1849 supports_tools: false,
1850 supports_system_prompt: false,
1851 max_context_tokens: 1,
1852 max_output_tokens: 1,
1853 },
1854 );
1855 std::fs::write(
1856 Config::config_path(),
1857 toml::to_string(&fake_config).unwrap(),
1858 )
1859 .unwrap();
1860
1861 let args = ShowArgs {
1862 model: known_id,
1863 remote: false,
1864 provider: None,
1865 };
1866 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1867 assert!(result.is_ok());
1868 },
1869 )
1870 .await;
1871 }
1872
1873 #[tokio::test]
1874 async fn mock_provider_trivial_trait_methods() {
1875 use leviath_providers::Provider;
1876 let provider = MockProvider {
1877 models: vec![],
1878 fail: false,
1879 };
1880 assert_eq!(provider.count_tokens("abcd", "mock-model").await, 1);
1881 assert_eq!(provider.max_context_tokens("mock-model"), 100_000);
1882 assert_eq!(provider.name(), "mock");
1883 let _ = provider.capabilities("mock-model");
1884 }
1885
1886 #[tokio::test]
1887 async fn mock_provider_infer_returns_err() {
1888 use leviath_providers::Provider;
1889 let provider = MockProvider {
1890 models: vec![],
1891 fail: false,
1892 };
1893 let request = leviath_providers::InferenceRequest {
1894 system: vec![],
1895 messages: vec![],
1896 model: "mock".to_string(),
1897 max_tokens: 100,
1898 temperature: 0.0,
1899 tools: vec![],
1900 extra: serde_json::Value::Null,
1901 request_timeout_secs: None,
1902 };
1903 let result = provider.infer(request).await;
1904 assert!(result.is_err());
1905 }
1906
1907 #[tokio::test]
1908 async fn list_with_registry_propagates_config_load_error() {
1909 crate::config::with_isolated_config_path_async(
1910 "models-list_with_registry_propagates_config_load_error",
1911 |fake_dir| async move {
1912 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
1913 let args = ListArgs {
1914 remote: false,
1915 provider: None,
1916 all: false,
1917 };
1918 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1919 assert!(result.is_err());
1920 },
1921 )
1922 .await;
1923 }
1924
1925 use clap::Parser as _;
1939
1940 #[derive(clap::Parser)]
1941 struct TestCli {
1942 #[command(flatten)]
1943 models: ModelsArgs,
1944 }
1945
1946 fn expect_list(cmd: ModelsCommand) -> ListArgs {
1952 match cmd {
1953 ModelsCommand::List(args) => args,
1954 ModelsCommand::Show(_) => panic!("expected List"),
1955 }
1956 }
1957
1958 #[test]
1959 #[should_panic(expected = "expected List")]
1960 fn expect_list_panics_on_show() {
1961 expect_list(ModelsCommand::Show(ShowArgs {
1962 model: "x".to_string(),
1963 provider: None,
1964 remote: false,
1965 }));
1966 }
1967
1968 fn expect_show(cmd: ModelsCommand) -> ShowArgs {
1970 match cmd {
1971 ModelsCommand::Show(args) => args,
1972 ModelsCommand::List(_) => panic!("expected Show"),
1973 }
1974 }
1975
1976 #[test]
1977 #[should_panic(expected = "expected Show")]
1978 fn expect_show_panics_on_list() {
1979 expect_show(ModelsCommand::List(ListArgs {
1980 provider: None,
1981 remote: false,
1982 all: false,
1983 }));
1984 }
1985
1986 #[test]
1987 fn parses_list_with_no_flags() {
1988 let cli = TestCli::try_parse_from(["lev", "list"]).unwrap();
1989 let args = expect_list(cli.models.command);
1990 assert!(args.provider.is_none());
1991 assert!(!args.remote);
1992 }
1993
1994 #[test]
1995 fn parses_list_with_long_flags() {
1996 let cli = TestCli::try_parse_from(["lev", "list", "--provider", "anthropic", "--remote"])
1997 .unwrap();
1998 let args = expect_list(cli.models.command);
1999 assert_eq!(args.provider.as_deref(), Some("anthropic"));
2000 assert!(args.remote);
2001 }
2002
2003 #[test]
2004 fn parses_list_with_short_flags() {
2005 let cli = TestCli::try_parse_from(["lev", "list", "-p", "openai", "-r"]).unwrap();
2006 let args = expect_list(cli.models.command);
2007 assert_eq!(args.provider.as_deref(), Some("openai"));
2008 assert!(args.remote);
2009 }
2010
2011 #[test]
2012 fn parses_show_with_positional_model_and_long_flags() {
2013 let cli = TestCli::try_parse_from([
2014 "lev",
2015 "show",
2016 "claude-sonnet-4-6",
2017 "--provider",
2018 "anthropic",
2019 "--remote",
2020 ])
2021 .unwrap();
2022 let args = expect_show(cli.models.command);
2023 assert_eq!(args.model, "claude-sonnet-4-6");
2024 assert_eq!(args.provider.as_deref(), Some("anthropic"));
2025 assert!(args.remote);
2026 }
2027
2028 #[test]
2029 fn parses_show_with_short_flags() {
2030 let cli =
2031 TestCli::try_parse_from(["lev", "show", "gpt-5.5", "-p", "openai", "-r"]).unwrap();
2032 let args = expect_show(cli.models.command);
2033 assert_eq!(args.model, "gpt-5.5");
2034 assert_eq!(args.provider.as_deref(), Some("openai"));
2035 assert!(args.remote);
2036 }
2037
2038 #[test]
2039 fn parses_show_missing_required_positional_errors() {
2040 let result = TestCli::try_parse_from(["lev", "show"]);
2041 assert!(result.is_err());
2042 }
2043
2044 #[test]
2045 fn parses_unknown_subcommand_errors() {
2046 let result = TestCli::try_parse_from(["lev", "not-a-subcommand"]);
2047 assert!(result.is_err());
2048 }
2049
2050 #[tokio::test]
2051 async fn show_with_registry_propagates_config_load_error() {
2052 crate::config::with_isolated_config_path_async(
2053 "models-show_with_registry_propagates_config_load_error",
2054 |fake_dir| async move {
2055 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2056 let args = ShowArgs {
2057 model: "any-model".to_string(),
2058 remote: false,
2059 provider: None,
2060 };
2061 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
2062 assert!(result.is_err());
2063 },
2064 )
2065 .await;
2066 }
2067}