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