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 #[arg(long)]
38 pub json: bool,
39}
40
41#[derive(serde::Serialize)]
48struct ModelRow {
49 id: String,
50 provider: String,
51 display_name: Option<String>,
52 capabilities: ModelCapabilities,
53 capabilities_overridden: bool,
56}
57
58#[derive(Args)]
59pub struct ShowArgs {
60 pub model: String,
62 #[arg(short, long)]
64 pub provider: Option<String>,
65 #[arg(short = 'r', long)]
67 pub remote: bool,
68}
69
70pub async fn execute(args: ModelsArgs) -> anyhow::Result<()> {
73 match args.command {
74 ModelsCommand::List(a) => list_with_registry(a, &build_provider_registry_from_config).await,
75 ModelsCommand::Show(a) => show_with_registry(a, &build_provider_registry_from_config).await,
76 }
77}
78
79struct BuiltinEntry {
83 provider: &'static str,
84 model_id: &'static str,
85 display_name: &'static str,
86 caps: ModelCapabilities,
87}
88
89const CLOSED_CATALOG_PROVIDERS: &[&str] = &["anthropic", "openai", "google"];
98
99pub fn closed_catalog_models() -> Vec<(String, String)> {
102 builtin_table()
103 .into_iter()
104 .filter(|e| CLOSED_CATALOG_PROVIDERS.contains(&e.provider))
105 .map(|e| (e.provider.to_string(), e.model_id.to_string()))
106 .collect()
107}
108
109fn builtin_table() -> Vec<BuiltinEntry> {
114 macro_rules! entry {
115 ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
117 temp=$t:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
118 entry!(
119 $provider,
120 $id,
121 $name,
122 temp = $t,
123 tools = true,
124 ctx = $ctx,
125 out = $out
126 )
127 };
128 ($provider:expr_2021, $id:expr_2021, $name:expr_2021,
130 temp=$t:expr_2021, tools=$to:expr_2021, ctx=$ctx:expr_2021, out=$out:expr_2021) => {
131 BuiltinEntry {
132 provider: $provider,
133 model_id: $id,
134 display_name: $name,
135 caps: ModelCapabilities {
136 supports_temperature: $t,
137 supports_streaming: true,
138 supports_tools: $to,
139 supports_system_prompt: true,
140 max_context_tokens: $ctx,
141 max_output_tokens: $out,
142 },
143 }
144 };
145 }
146
147 vec![
148 entry!(
150 "anthropic",
151 "claude-opus-5",
152 "Claude Opus 5",
153 temp = false,
154 ctx = 1_000_000,
155 out = 128_000
156 ),
157 entry!(
158 "anthropic",
159 "claude-sonnet-5",
160 "Claude Sonnet 5",
161 temp = false,
162 ctx = 1_000_000,
163 out = 128_000
164 ),
165 entry!(
166 "anthropic",
167 "claude-fable-5",
168 "Claude Fable 5",
169 temp = false,
170 ctx = 1_000_000,
171 out = 128_000
172 ),
173 entry!(
174 "anthropic",
175 "claude-opus-4-8",
176 "Claude Opus 4.8",
177 temp = false,
178 ctx = 1_000_000,
179 out = 128_000
180 ),
181 entry!(
182 "anthropic",
183 "claude-opus-4-7",
184 "Claude Opus 4.7",
185 temp = false,
186 ctx = 1_000_000,
187 out = 128_000
188 ),
189 entry!(
190 "anthropic",
191 "claude-opus-4-6",
192 "Claude Opus 4.6",
193 temp = true,
194 ctx = 1_000_000,
195 out = 128_000
196 ),
197 entry!(
198 "anthropic",
199 "claude-sonnet-4-6",
200 "Claude Sonnet 4.6",
201 temp = true,
202 ctx = 1_000_000,
203 out = 128_000
204 ),
205 entry!(
206 "anthropic",
207 "claude-haiku-4-5-20251001",
208 "Claude Haiku 4.5",
209 temp = true,
210 ctx = 200_000,
211 out = 65_536
212 ),
213 entry!(
216 "openai",
217 "gpt-5.5",
218 "GPT-5.5",
219 temp = true,
220 ctx = 1_050_000,
221 out = 128_000
222 ),
223 entry!(
224 "openai",
225 "gpt-5.4",
226 "GPT-5.4",
227 temp = true,
228 ctx = 1_050_000,
229 out = 128_000
230 ),
231 entry!(
232 "openai",
233 "gpt-5.4-mini",
234 "GPT-5.4 Mini",
235 temp = true,
236 ctx = 400_000,
237 out = 128_000
238 ),
239 entry!(
240 "openai",
241 "gpt-5.4-nano",
242 "GPT-5.4 Nano",
243 temp = true,
244 ctx = 400_000,
245 out = 128_000
246 ),
247 entry!(
252 "google",
253 "gemini-3.5-flash",
254 "Gemini 3.5 Flash",
255 temp = true,
256 ctx = 1_048_576,
257 out = 65_535
258 ),
259 entry!(
260 "google",
261 "gemini-3.1-pro-preview",
262 "Gemini 3.1 Pro (preview)",
263 temp = true,
264 ctx = 1_048_576,
265 out = 65_535
266 ),
267 entry!(
268 "google",
269 "gemini-3-flash",
270 "Gemini 3 Flash",
271 temp = true,
272 ctx = 1_048_576,
273 out = 65_535
274 ),
275 entry!(
276 "google",
277 "gemini-3.1-flash-lite",
278 "Gemini 3.1 Flash Lite",
279 temp = true,
280 ctx = 1_048_576,
281 out = 65_535
282 ),
283 entry!(
285 "openrouter",
286 "google/gemini-3.5-flash",
287 "Gemini 3.5 Flash",
288 temp = true,
289 ctx = 1_048_576,
290 out = 65_536
291 ),
292 entry!(
293 "openrouter",
294 "google/gemini-2.5-pro",
295 "Gemini 2.5 Pro",
296 temp = true,
297 ctx = 1_048_576,
298 out = 65_536
299 ),
300 entry!(
301 "openrouter",
302 "google/gemini-2.5-flash",
303 "Gemini 2.5 Flash",
304 temp = true,
305 ctx = 1_048_576,
306 out = 65_536
307 ),
308 entry!(
309 "openrouter",
310 "google/gemini-2.5-flash-lite",
311 "Gemini 2.5 Flash Lite",
312 temp = true,
313 ctx = 1_048_576,
314 out = 65_536
315 ),
316 entry!(
318 "openrouter",
319 "meta-llama/llama-4-maverick",
320 "Llama 4 Maverick",
321 temp = true,
322 ctx = 1_048_576,
323 out = 32_768
324 ),
325 entry!(
326 "openrouter",
327 "meta-llama/llama-4-scout",
328 "Llama 4 Scout",
329 temp = true,
330 ctx = 10_000_000,
331 out = 32_768
332 ),
333 entry!(
335 "openrouter",
336 "deepseek/deepseek-v4-pro",
337 "DeepSeek V4 Pro",
338 temp = true,
339 ctx = 1_048_576,
340 out = 393_216
341 ),
342 entry!(
343 "openrouter",
344 "deepseek/deepseek-v4-flash",
345 "DeepSeek V4 Flash",
346 temp = true,
347 ctx = 1_048_576,
348 out = 65_536
349 ),
350 entry!(
351 "openrouter",
352 "deepseek/deepseek-v3.2",
353 "DeepSeek V3.2",
354 temp = true,
355 ctx = 131_072,
356 out = 65_536
357 ),
358 entry!(
359 "openrouter",
360 "deepseek/deepseek-r1-0528",
361 "DeepSeek R1 (0528)",
362 temp = false,
363 tools = false,
364 ctx = 163_840,
365 out = 32_768
366 ),
367 entry!(
368 "openrouter",
369 "deepseek/deepseek-r1",
370 "DeepSeek R1",
371 temp = false,
372 tools = false,
373 ctx = 163_840,
374 out = 16_384
375 ),
376 entry!(
378 "openrouter",
379 "mistralai/mistral-large-2512",
380 "Mistral Large 3",
381 temp = true,
382 ctx = 262_144,
383 out = 32_768
384 ),
385 entry!(
386 "openrouter",
387 "mistralai/mistral-medium-3-5",
388 "Mistral Medium 3.5",
389 temp = true,
390 ctx = 256_000,
391 out = 32_768
392 ),
393 entry!(
394 "openrouter",
395 "mistralai/mistral-small-2603",
396 "Mistral Small 4",
397 temp = true,
398 ctx = 128_000,
399 out = 32_768
400 ),
401 entry!(
403 "openrouter",
404 "qwen/qwen3.6-plus",
405 "Qwen 3.6 Plus",
406 temp = true,
407 ctx = 1_048_576,
408 out = 65_536
409 ),
410 entry!(
411 "openrouter",
412 "qwen/qwen3-max",
413 "Qwen3 Max",
414 temp = true,
415 ctx = 131_072,
416 out = 32_768
417 ),
418 entry!(
419 "openrouter",
420 "qwen/qwen3-coder",
421 "Qwen3 Coder 480B",
422 temp = true,
423 ctx = 1_048_576,
424 out = 262_144
425 ),
426 ]
427}
428
429async fn list_with_registry(
451 args: ListArgs,
452 build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
453) -> anyhow::Result<()> {
454 let config = Config::load()?;
455 for warning in config.validate_keys() {
456 eprintln!("Warning: {}", warning);
457 }
458
459 let mut entries: Vec<ModelInfo> = builtin_table()
461 .into_iter()
462 .map(|e| ModelInfo {
463 id: e.model_id.to_string(),
464 display_name: Some(e.display_name.to_string()),
465 provider: e.provider.to_string(),
466 capabilities: e.caps,
467 })
468 .collect();
469
470 let registry = build_registry(&config);
475 let available: std::collections::HashSet<String> = registry
476 .provider_names()
477 .into_iter()
478 .map(str::to_string)
479 .collect();
480 if !args.all {
481 entries.retain(|e| available.contains(&e.provider));
482 }
483
484 if args.remote {
486 for provider_name in registry.provider_names() {
487 if let Some(ref filter) = args.provider
489 && filter != provider_name
490 {
491 continue;
492 }
493
494 let provider = registry
506 .get(provider_name)
507 .expect("provider_names returns registered names");
508 match provider.list_models().await {
509 Ok(remote_models) => {
510 for rm in remote_models {
511 if let Some(existing) = entries.iter_mut().find(|e| e.id == rm.id) {
513 *existing = rm;
514 } else {
515 entries.push(rm);
516 }
517 }
518 }
519 Err(e) => {
520 eprintln!(
521 "Warning: could not fetch models from '{}': {}",
522 provider_name, e
523 );
524 }
525 }
526 }
527 }
528
529 if let Some(ref filter) = args.provider {
531 entries.retain(|e| &e.provider == filter);
532 }
533
534 let overridden: std::collections::HashSet<String> =
536 config.model_capabilities.keys().cloned().collect();
537
538 for entry in entries.iter_mut() {
539 if let Some(user_caps) = config.model_capabilities.get(&entry.id) {
540 entry.capabilities = user_caps.clone();
541 }
542 }
543
544 if args.json {
547 let rows: Vec<ModelRow> = entries
548 .into_iter()
549 .map(|e| ModelRow {
550 capabilities_overridden: overridden.contains(&e.id),
551 id: e.id,
552 provider: e.provider,
553 display_name: e.display_name,
554 capabilities: e.capabilities,
555 })
556 .collect();
557 println!(
559 "{}",
560 serde_json::to_string_pretty(&rows).expect("a model listing serializes")
561 );
562 return Ok(());
563 }
564
565 if entries.is_empty() {
566 println!("No models available.");
570 println!(
571 "(configure a provider with `lev setup`, or pass --all to see every \
572 model Leviath knows about)"
573 );
574 return Ok(());
575 }
576
577 println!(
579 "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
580 "PROVIDER", "MODEL ID", "TEMP", "TOOLS", "CTX", "OUTPUT"
581 );
582 println!("{}", "-".repeat(85));
583
584 for entry in &entries {
585 let provider_col = if overridden.contains(&entry.id) {
586 format!("*{}", entry.provider)
587 } else {
588 entry.provider.clone()
589 };
590
591 let temp = bool_icon(entry.capabilities.supports_temperature);
592 let tools = bool_icon(entry.capabilities.supports_tools);
593 let ctx = fmt_tokens(entry.capabilities.max_context_tokens);
594 let out = fmt_tokens(entry.capabilities.max_output_tokens);
595
596 println!(
597 "{:<12} {:<40} {:<6} {:<7} {:<8} {:<8}",
598 provider_col, entry.id, temp, tools, ctx, out
599 );
600 }
601
602 if overridden
603 .iter()
604 .any(|id| entries.iter().any(|e| &e.id == id))
605 {
606 println!("\n* = capabilities overridden via [model_capabilities] in config");
607 }
608
609 Ok(())
610}
611
612async fn show_with_registry(
617 args: ShowArgs,
618 build_registry: &dyn Fn(&Config) -> leviath_runtime::ProviderRegistry,
619) -> anyhow::Result<()> {
620 let config = Config::load()?;
621 for warning in config.validate_keys() {
622 eprintln!("Warning: {}", warning);
623 }
624
625 let model_id = &args.model;
626
627 if let Some(user_caps) = config.model_capabilities.get(model_id) {
629 print_model_detail(model_id, None, "config (user override)", user_caps, true);
630 return Ok(());
631 }
632
633 let builtin = builtin_table();
635 if let Some(entry) = builtin.iter().find(|e| e.model_id == model_id) {
636 print_model_detail(
637 model_id,
638 Some(entry.display_name),
639 entry.provider,
640 &entry.caps,
641 false,
642 );
643 return Ok(());
644 }
645
646 if args.remote
648 && let Some(ref provider_name) = args.provider
649 {
650 let registry = build_registry(&config);
651 if let Some(provider) = registry.get(provider_name) {
652 match provider.list_models().await {
653 Ok(models) => {
654 if let Some(info) = models.iter().find(|m| &m.id == model_id) {
655 print_model_detail(
656 model_id,
657 info.display_name.as_deref(),
658 &info.provider,
659 &info.capabilities,
660 false,
661 );
662 return Ok(());
663 }
664 }
665 Err(e) => {
666 eprintln!(
667 "Warning: could not fetch models from '{}': {}",
668 provider_name, e
669 );
670 }
671 }
672 } else {
673 eprintln!(
674 "Warning: provider '{}' is not configured (missing API key?)",
675 provider_name
676 );
677 }
678 }
679
680 println!("Model '{}' not found.", model_id);
682 println!(
683 "Add it to {} under [model_capabilities.'{}']",
684 Config::config_path().display(),
685 model_id
686 );
687 println!();
688 println!("Example:");
689 println!("[model_capabilities.'{}']", model_id);
690 println!("supports_temperature = true");
691 println!("supports_streaming = true");
692 println!("supports_tools = true");
693 println!("supports_system_prompt = true");
694 println!("max_context_tokens = 8192");
695 println!("max_output_tokens = 4096");
696
697 Ok(())
698}
699
700fn bool_icon(b: bool) -> &'static str {
703 if b { "✓" } else { "✗" }
704}
705
706fn fmt_tokens(n: usize) -> String {
708 if n >= 1_000_000 {
709 format!("{}M", n / 1_000_000)
710 } else if n >= 1_000 {
711 format!("{}K", n / 1_000)
712 } else {
713 n.to_string()
714 }
715}
716
717fn print_model_detail(
719 id: &str,
720 display_name: Option<&str>,
721 provider: &str,
722 caps: &ModelCapabilities,
723 is_user_override: bool,
724) {
725 println!("Model: {}", id);
726 if let Some(name) = display_name {
727 println!("Name: {}", name);
728 }
729 println!("Provider: {}", provider);
730 if is_user_override {
731 println!("Source: user override (config)");
732 }
733 println!();
734 println!("Capabilities");
735 println!("------------");
736 println!(" Temperature: {}", bool_icon(caps.supports_temperature));
737 println!(" Streaming: {}", bool_icon(caps.supports_streaming));
738 println!(" Tool calling: {}", bool_icon(caps.supports_tools));
739 println!(
740 " System prompt: {}",
741 bool_icon(caps.supports_system_prompt)
742 );
743 println!(
744 " Context window: {} tokens ({})",
745 caps.max_context_tokens,
746 fmt_tokens(caps.max_context_tokens)
747 );
748 println!(
749 " Max output: {} tokens ({})",
750 caps.max_output_tokens,
751 fmt_tokens(caps.max_output_tokens)
752 );
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
762 fn fmt_tokens_millions() {
763 assert_eq!(fmt_tokens(1_000_000), "1M");
764 assert_eq!(fmt_tokens(2_000_000), "2M");
765 }
766
767 #[test]
768 fn fmt_tokens_thousands() {
769 assert_eq!(fmt_tokens(128_000), "128K");
770 assert_eq!(fmt_tokens(4_096), "4K");
771 assert_eq!(fmt_tokens(1_000), "1K");
772 }
773
774 #[test]
775 fn fmt_tokens_small() {
776 assert_eq!(fmt_tokens(512), "512");
777 assert_eq!(fmt_tokens(0), "0");
778 }
779
780 #[test]
783 fn bool_icon_values() {
784 assert_eq!(bool_icon(true), "✓");
785 assert_eq!(bool_icon(false), "✗");
786 }
787
788 #[test]
791 fn builtin_table_is_not_empty() {
792 let table = builtin_table();
793 assert!(!table.is_empty());
794 }
795
796 #[test]
797 fn builtin_table_has_anthropic_models() {
798 let table = builtin_table();
799 let anthropic: Vec<_> = table.iter().filter(|e| e.provider == "anthropic").collect();
800 assert!(!anthropic.is_empty());
801 }
802
803 #[test]
804 fn builtin_table_has_openai_models() {
805 let table = builtin_table();
806 let openai: Vec<_> = table.iter().filter(|e| e.provider == "openai").collect();
807 assert!(!openai.is_empty());
808 }
809
810 #[test]
811 fn builtin_table_has_openrouter_models() {
812 let table = builtin_table();
813 let openrouter: Vec<_> = table
814 .iter()
815 .filter(|e| e.provider == "openrouter")
816 .collect();
817 assert!(!openrouter.is_empty());
818 }
819
820 #[test]
821 fn builtin_entries_have_valid_capabilities() {
822 for entry in builtin_table() {
823 assert!(entry.caps.max_context_tokens > 0);
824 assert!(entry.caps.max_output_tokens > 0);
825 assert!(entry.caps.supports_streaming);
826 assert!(entry.caps.supports_system_prompt);
827 }
828 }
829
830 #[test]
831 fn builtin_entries_have_unique_model_ids() {
832 let table = builtin_table();
833 let ids: Vec<&str> = table.iter().map(|e| e.model_id).collect();
834 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
835 assert_eq!(ids.len(), unique.len());
836 }
837
838 #[test]
839 fn deepseek_r1_models_no_tools() {
840 let table = builtin_table();
841 for entry in &table {
842 if entry.model_id.contains("deepseek-r1") {
843 assert!(!entry.caps.supports_tools);
844 }
845 }
846 }
847
848 #[test]
851 fn print_model_detail_does_not_panic() {
852 let caps = ModelCapabilities {
853 supports_temperature: true,
854 supports_streaming: true,
855 supports_tools: true,
856 supports_system_prompt: true,
857 max_context_tokens: 100_000,
858 max_output_tokens: 8_192,
859 };
860 print_model_detail("test-model", Some("Test Model"), "test", &caps, false);
862 print_model_detail("test-model", None, "test", &caps, true);
863 }
864
865 #[test]
868 fn fmt_tokens_exact_boundary() {
869 assert_eq!(fmt_tokens(999), "999");
870 assert_eq!(fmt_tokens(999_999), "999K");
871 }
872
873 #[test]
874 fn fmt_tokens_large_millions() {
875 assert_eq!(fmt_tokens(10_000_000), "10M");
876 }
877
878 #[test]
881 fn builtin_table_claude_opus_no_temperature() {
882 let table = builtin_table();
883 for entry in &table {
884 if entry.model_id == "claude-opus-4-8" || entry.model_id == "claude-opus-4-7" {
885 assert!(!entry.caps.supports_temperature);
886 }
887 }
888 }
889
890 #[test]
891 fn builtin_table_claude_sonnet_supports_temperature() {
892 let table = builtin_table();
893 let sonnet = table
894 .iter()
895 .find(|e| e.model_id == "claude-sonnet-4-6")
896 .expect("claude-sonnet-4-6 should be in table");
897 assert!(sonnet.caps.supports_temperature);
898 }
899
900 #[test]
901 fn builtin_table_has_display_names() {
902 let table = builtin_table();
903 for entry in &table {
904 assert!(!entry.display_name.is_empty());
905 }
906 }
907
908 #[test]
909 fn builtin_table_context_larger_than_output() {
910 let table = builtin_table();
911 for entry in &table {
912 assert!(entry.caps.max_context_tokens >= entry.caps.max_output_tokens);
913 }
914 }
915
916 #[test]
919 fn bool_icon_returns_unicode() {
920 assert!(!bool_icon(true).is_empty());
921 assert!(!bool_icon(false).is_empty());
922 assert_ne!(bool_icon(true), bool_icon(false));
923 }
924
925 #[test]
928 fn builtin_table_openai_models_support_temperature() {
929 let table = builtin_table();
930 for entry in &table {
931 if entry.provider == "openai" {
932 assert!(entry.caps.supports_temperature);
933 }
934 }
935 }
936
937 #[test]
942 fn builtin_table_offers_native_google_models() {
943 let table = builtin_table();
944 let native: Vec<&str> = table
945 .iter()
946 .filter(|e| e.provider == "google")
947 .map(|e| e.model_id)
948 .collect();
949 assert!(
950 !native.is_empty(),
951 "the native google provider must offer models of its own"
952 );
953 for id in &native {
955 assert!(
956 !id.contains('/'),
957 "native google model id must not be vendor-prefixed: {id}"
958 );
959 }
960 }
961
962 #[test]
963 fn builtin_table_gemini_flash_models_exist() {
964 let table = builtin_table();
965 let flash: Vec<_> = table
966 .iter()
967 .filter(|e| e.model_id.contains("gemini") && e.model_id.contains("flash"))
968 .collect();
969 assert!(!flash.is_empty());
970 }
971
972 #[test]
973 fn builtin_table_deepseek_r1_no_temperature() {
974 let table = builtin_table();
975 for entry in &table {
976 if entry.model_id.contains("deepseek-r1") {
977 assert!(!entry.caps.supports_temperature);
978 }
979 }
980 }
981
982 #[test]
983 fn builtin_table_qwen_models_exist() {
984 let table = builtin_table();
985 let qwen: Vec<_> = table
986 .iter()
987 .filter(|e| e.model_id.contains("qwen"))
988 .collect();
989 assert!(!qwen.is_empty());
990 }
991
992 #[test]
993 fn builtin_table_mistral_models_exist() {
994 let table = builtin_table();
995 let mistral: Vec<_> = table
996 .iter()
997 .filter(|e| e.model_id.contains("mistral"))
998 .collect();
999 assert!(!mistral.is_empty());
1000 }
1001
1002 #[test]
1003 fn builtin_table_all_entries_have_provider() {
1004 let table = builtin_table();
1005 for entry in &table {
1006 assert!(!entry.provider.is_empty());
1007 }
1008 }
1009
1010 #[test]
1011 fn builtin_table_all_entries_have_model_id() {
1012 let table = builtin_table();
1013 for entry in &table {
1014 assert!(!entry.model_id.is_empty());
1015 }
1016 }
1017
1018 #[tokio::test]
1021 async fn execute_list_command_runs_without_error() {
1022 crate::config::with_isolated_config_path_async(
1023 "models-execute_list_command_runs_without_error",
1024 |_fake_dir| async move {
1025 let args = ModelsArgs {
1026 command: ModelsCommand::List(ListArgs {
1027 provider: None,
1028 remote: false,
1029 all: false,
1030 json: false,
1031 }),
1032 };
1033 let result = execute(args).await;
1035 assert!(result.is_ok());
1036 },
1037 )
1038 .await;
1039 }
1040
1041 #[tokio::test]
1042 async fn execute_list_with_provider_filter_runs_without_error() {
1043 crate::config::with_isolated_config_path_async(
1044 "models-execute_list_with_provider_filter_runs_without_error",
1045 |_fake_dir| async move {
1046 let args = ModelsArgs {
1047 command: ModelsCommand::List(ListArgs {
1048 provider: Some("anthropic".to_string()),
1049 remote: false,
1050 all: false,
1051 json: false,
1052 }),
1053 };
1054 let result = execute(args).await;
1055 assert!(result.is_ok());
1056 },
1057 )
1058 .await;
1059 }
1060
1061 #[tokio::test]
1062 async fn execute_list_with_nonexistent_provider_filter() {
1063 crate::config::with_isolated_config_path_async(
1064 "models-execute_list_with_nonexistent_provider_filter",
1065 |_fake_dir| async move {
1066 let args = ModelsArgs {
1067 command: ModelsCommand::List(ListArgs {
1068 provider: Some("nonexistent_provider".to_string()),
1069 remote: false,
1070 all: false,
1071 json: false,
1072 }),
1073 };
1074 let result = execute(args).await;
1076 assert!(result.is_ok());
1077 },
1078 )
1079 .await;
1080 }
1081
1082 #[tokio::test]
1083 async fn execute_show_known_model_runs_without_error() {
1084 crate::config::with_isolated_config_path_async(
1085 "models-execute_show_known_model_runs_without_error",
1086 |_fake_dir| async move {
1087 let args = ModelsArgs {
1088 command: ModelsCommand::Show(ShowArgs {
1089 model: "claude-sonnet-4-6".to_string(),
1090 provider: None,
1091 remote: false,
1092 }),
1093 };
1094 let result = execute(args).await;
1096 assert!(result.is_ok());
1097 },
1098 )
1099 .await;
1100 }
1101
1102 #[tokio::test]
1103 async fn execute_show_unknown_model_runs_without_error() {
1104 crate::config::with_isolated_config_path_async(
1105 "models-execute_show_unknown_model_runs_without_error",
1106 |_fake_dir| async move {
1107 let args = ModelsArgs {
1108 command: ModelsCommand::Show(ShowArgs {
1109 model: "totally-unknown-model-xyz".to_string(),
1110 provider: None,
1111 remote: false,
1112 }),
1113 };
1114 let result = execute(args).await;
1116 assert!(result.is_ok());
1117 },
1118 )
1119 .await;
1120 }
1121
1122 #[tokio::test]
1123 async fn execute_show_unknown_model_with_remote_no_provider() {
1124 crate::config::with_isolated_config_path_async(
1125 "models-execute_show_unknown_model_with_remote_no_provider",
1126 |_fake_dir| async move {
1127 let args = ModelsArgs {
1128 command: ModelsCommand::Show(ShowArgs {
1129 model: "totally-unknown-model-xyz".to_string(),
1130 provider: None,
1131 remote: true, }),
1133 };
1134 let result = execute(args).await;
1135 assert!(result.is_ok());
1136 },
1137 )
1138 .await;
1139 }
1140
1141 #[tokio::test]
1142 async fn execute_show_unknown_model_with_remote_unconfigured_provider() {
1143 crate::config::with_isolated_config_path_async(
1144 "models-execute_show_unknown_model_with_remote_unconfigured_provider",
1145 |_fake_dir| async move {
1146 let args = ModelsArgs {
1147 command: ModelsCommand::Show(ShowArgs {
1148 model: "totally-unknown-model-xyz".to_string(),
1149 provider: Some("anthropic".to_string()),
1150 remote: true,
1151 }),
1153 };
1154 let result = execute(args).await;
1156 assert!(result.is_ok());
1157 },
1158 )
1159 .await;
1160 }
1161
1162 #[tokio::test]
1165 async fn list_with_openrouter_filter() {
1166 crate::config::with_isolated_config_path_async(
1174 "models-list-openrouter-filter",
1175 |_fake_dir| async move {
1176 let args = ModelsArgs {
1177 command: ModelsCommand::List(ListArgs {
1178 provider: Some("openrouter".to_string()),
1179 remote: false,
1180 all: false,
1181 json: false,
1182 }),
1183 };
1184 let result = execute(args).await;
1185 assert!(result.is_ok());
1186 },
1187 )
1188 .await;
1189 }
1190
1191 #[tokio::test]
1192 async fn list_with_openai_filter() {
1193 crate::config::with_isolated_config_path_async(
1196 "models-list-openai-filter",
1197 |_fake_dir| async move {
1198 let args = ModelsArgs {
1199 command: ModelsCommand::List(ListArgs {
1200 provider: Some("openai".to_string()),
1201 remote: false,
1202 all: false,
1203 json: false,
1204 }),
1205 };
1206 let result = execute(args).await;
1207 assert!(result.is_ok());
1208 },
1209 )
1210 .await;
1211 }
1212
1213 #[tokio::test]
1214 async fn show_builtin_anthropic_opus() {
1215 crate::config::with_isolated_config_path_async(
1218 "models-show-anthropic-opus",
1219 |_fake_dir| async move {
1220 let args = ModelsArgs {
1221 command: ModelsCommand::Show(ShowArgs {
1222 model: "claude-opus-4-6".to_string(),
1223 provider: None,
1224 remote: false,
1225 }),
1226 };
1227 let result = execute(args).await;
1228 assert!(result.is_ok());
1229 },
1230 )
1231 .await;
1232 }
1233
1234 #[tokio::test]
1235 async fn show_builtin_openai_model() {
1236 crate::config::with_isolated_config_path_async(
1239 "models-show-openai-model",
1240 |_fake_dir| async move {
1241 let args = ModelsArgs {
1242 command: ModelsCommand::Show(ShowArgs {
1243 model: "gpt-5.5".to_string(),
1244 provider: None,
1245 remote: false,
1246 }),
1247 };
1248 let result = execute(args).await;
1249 assert!(result.is_ok());
1250 },
1251 )
1252 .await;
1253 }
1254
1255 #[tokio::test]
1256 async fn show_builtin_deepseek_r1() {
1257 crate::config::with_isolated_config_path_async(
1260 "models-show-deepseek-r1",
1261 |_fake_dir| async move {
1262 let args = ModelsArgs {
1263 command: ModelsCommand::Show(ShowArgs {
1264 model: "deepseek/deepseek-r1".to_string(),
1265 provider: None,
1266 remote: false,
1267 }),
1268 };
1269 let result = execute(args).await;
1270 assert!(result.is_ok());
1271 },
1272 )
1273 .await;
1274 }
1275
1276 #[test]
1279 fn builtin_table_to_model_info_preserves_data() {
1280 let table = builtin_table();
1281 let infos: Vec<ModelInfo> = table
1282 .into_iter()
1283 .map(|e| ModelInfo {
1284 id: e.model_id.to_string(),
1285 display_name: Some(e.display_name.to_string()),
1286 provider: e.provider.to_string(),
1287 capabilities: e.caps,
1288 })
1289 .collect();
1290
1291 assert!(!infos.is_empty());
1292 for info in &infos {
1293 assert!(!info.id.is_empty());
1294 assert!(info.display_name.is_some());
1295 assert!(!info.provider.is_empty());
1296 }
1297 }
1298
1299 #[test]
1302 fn print_model_detail_with_no_tools_no_temp() {
1303 let caps = ModelCapabilities {
1304 supports_temperature: false,
1305 supports_streaming: false,
1306 supports_tools: false,
1307 supports_system_prompt: false,
1308 max_context_tokens: 1000,
1309 max_output_tokens: 500,
1310 };
1311 print_model_detail("test-model", Some("Test"), "test", &caps, false);
1313 }
1314
1315 #[test]
1316 fn print_model_detail_user_override_source() {
1317 let caps = ModelCapabilities::default();
1318 print_model_detail("override-model", None, "custom", &caps, true);
1320 }
1321
1322 #[test]
1325 fn fmt_tokens_just_below_thousand() {
1326 assert_eq!(fmt_tokens(999), "999");
1327 }
1328
1329 #[test]
1330 fn fmt_tokens_just_at_thousand() {
1331 assert_eq!(fmt_tokens(1000), "1K");
1332 }
1333
1334 #[test]
1335 fn fmt_tokens_just_below_million() {
1336 assert_eq!(fmt_tokens(999_999), "999K");
1337 }
1338
1339 #[test]
1340 fn fmt_tokens_just_at_million() {
1341 assert_eq!(fmt_tokens(1_000_000), "1M");
1342 }
1343
1344 #[test]
1345 fn fmt_tokens_non_round_thousands() {
1346 assert_eq!(fmt_tokens(1500), "1K");
1348 assert_eq!(fmt_tokens(65_536), "65K");
1349 }
1350
1351 #[tokio::test]
1360 async fn list_builtin_no_filter_succeeds() {
1361 crate::config::with_isolated_config_path_async(
1362 "models-list_builtin_no_filter_succeeds",
1363 |_fake_dir| async move {
1364 let args = ListArgs {
1365 remote: false,
1366 provider: None,
1367 all: false,
1368 json: false,
1369 };
1370 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1371 assert!(result.is_ok());
1372 },
1373 )
1374 .await;
1375 }
1376
1377 #[tokio::test]
1378 async fn list_json_with_no_configured_provider_is_an_empty_array() {
1379 crate::config::with_isolated_config_path_async(
1382 "models-list_json_empty",
1383 |_fake_dir| async move {
1384 let args = ListArgs {
1385 remote: false,
1386 provider: Some("no-such-provider".to_string()),
1387 all: false,
1388 json: true,
1389 };
1390 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1391 assert!(result.is_ok());
1392 },
1393 )
1394 .await;
1395 }
1396
1397 #[tokio::test]
1398 async fn list_json_with_all_succeeds() {
1399 crate::config::with_isolated_config_path_async(
1400 "models-list_json_all",
1401 |_fake_dir| async move {
1402 let args = ListArgs {
1403 remote: false,
1404 provider: None,
1405 all: true,
1406 json: true,
1407 };
1408 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1409 assert!(result.is_ok());
1410 },
1411 )
1412 .await;
1413 }
1414
1415 #[test]
1416 fn model_row_serializes_capabilities_and_the_override_flag() {
1417 let row = ModelRow {
1421 id: "m".to_string(),
1422 provider: "p".to_string(),
1423 display_name: Some("M".to_string()),
1424 capabilities: ModelCapabilities::default(),
1425 capabilities_overridden: true,
1426 };
1427 let value: serde_json::Value =
1428 serde_json::from_str(&serde_json::to_string(&row).unwrap()).unwrap();
1429 assert_eq!(value["id"], serde_json::json!("m"));
1430 assert_eq!(value["capabilities_overridden"], serde_json::json!(true));
1431 assert!(value["capabilities"]["supports_tools"].is_boolean());
1432 }
1433
1434 #[tokio::test]
1435 async fn list_builtin_with_provider_filter_succeeds() {
1436 crate::config::with_isolated_config_path_async(
1437 "models-list_builtin_with_provider_filter_succeeds",
1438 |_fake_dir| async move {
1439 let args = ListArgs {
1440 remote: false,
1441 provider: Some("anthropic".to_string()),
1442 all: false,
1443 json: false,
1444 };
1445 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1446 assert!(result.is_ok());
1447 },
1448 )
1449 .await;
1450 }
1451
1452 #[tokio::test]
1453 async fn list_unknown_provider_filter_finds_nothing() {
1454 crate::config::with_isolated_config_path_async(
1455 "models-list_unknown_provider_filter_finds_nothing",
1456 |_fake_dir| async move {
1457 let args = ListArgs {
1458 remote: false,
1459 provider: Some("no-such-provider".to_string()),
1460 all: false,
1461 json: false,
1462 };
1463 let result = list_with_registry(args, &build_provider_registry_from_config).await;
1465 assert!(result.is_ok());
1466 },
1467 )
1468 .await;
1469 }
1470
1471 #[tokio::test]
1472 async fn show_builtin_model_succeeds() {
1473 crate::config::with_isolated_config_path_async(
1474 "models-show_builtin_model_succeeds",
1475 |_fake_dir| async move {
1476 let known_id = builtin_table()[0].model_id.to_string();
1478 let args = ShowArgs {
1479 model: known_id,
1480 remote: false,
1481 provider: None,
1482 };
1483 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1484 assert!(result.is_ok());
1485 },
1486 )
1487 .await;
1488 }
1489
1490 #[tokio::test]
1491 async fn show_unknown_model_without_remote_succeeds_with_warning() {
1492 crate::config::with_isolated_config_path_async(
1493 "models-show_unknown_model_without_remote_succeeds_with_warning",
1494 |_fake_dir| async move {
1495 let args = ShowArgs {
1496 model: "totally-unknown-model-xyz".to_string(),
1497 remote: false,
1498 provider: None,
1499 };
1500 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1502 assert!(result.is_ok());
1503 },
1504 )
1505 .await;
1506 }
1507
1508 #[tokio::test]
1509 async fn show_remote_without_provider_falls_through_gracefully() {
1510 crate::config::with_isolated_config_path_async(
1511 "models-show_remote_without_provider_falls_through_gracefully",
1512 |_fake_dir| async move {
1513 let args = ShowArgs {
1516 model: "totally-unknown-model-xyz".to_string(),
1517 remote: true,
1518 provider: None,
1519 };
1520 let result = show_with_registry(args, &build_provider_registry_from_config).await;
1521 assert!(result.is_ok());
1522 },
1523 )
1524 .await;
1525 }
1526
1527 struct MockProvider {
1539 models: Vec<ModelInfo>,
1540 fail: bool,
1541 }
1542
1543 #[async_trait::async_trait]
1544 impl leviath_providers::Provider for MockProvider {
1545 async fn infer(
1546 &self,
1547 _request: leviath_providers::InferenceRequest,
1548 ) -> Result<leviath_providers::InferenceResponse, leviath_providers::ProviderError>
1549 {
1550 Err(leviath_providers::ProviderError::Other(
1551 "MockProvider does not support infer".to_string(),
1552 ))
1553 }
1554
1555 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1556 leviath_core::estimate_tokens(text)
1557 }
1558
1559 fn max_context_tokens(&self, _model: &str) -> usize {
1560 100_000
1561 }
1562
1563 fn name(&self) -> &str {
1564 "mock"
1565 }
1566
1567 fn capabilities(&self, _model: &str) -> ModelCapabilities {
1568 ModelCapabilities::default()
1569 }
1570
1571 async fn list_models(&self) -> Result<Vec<ModelInfo>, leviath_providers::ProviderError> {
1572 if self.fail {
1573 Err(leviath_providers::ProviderError::Other(
1574 "mock provider failure".to_string(),
1575 ))
1576 } else {
1577 Ok(self.models.clone())
1578 }
1579 }
1580 }
1581
1582 fn mock_registry(
1583 provider_name: &'static str,
1584 models: Vec<ModelInfo>,
1585 fail: bool,
1586 ) -> impl Fn(&Config) -> leviath_runtime::ProviderRegistry {
1587 move |_config: &Config| {
1594 let mut registry = leviath_runtime::ProviderRegistry::new();
1595 registry.register(
1596 provider_name.to_string(),
1597 std::sync::Arc::new(MockProvider {
1598 models: models.clone(),
1599 fail,
1600 }),
1601 );
1602 registry
1603 }
1604 }
1605
1606 #[tokio::test]
1607 async fn list_remote_merges_new_model_from_provider() {
1608 crate::config::with_isolated_config_path_async(
1609 "models-list_remote_merges_new_model_from_provider",
1610 |_fake_dir| async move {
1611 let args = ListArgs {
1612 remote: true,
1613 provider: Some("mock".to_string()),
1614 all: false,
1615 json: false,
1616 };
1617 let new_model = ModelInfo {
1618 id: "mock-brand-new-model".to_string(),
1619 display_name: Some("Mock Brand New Model".to_string()),
1620 provider: "mock".to_string(),
1621 capabilities: ModelCapabilities::default(),
1622 };
1623 let result =
1624 list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1625 assert!(result.is_ok());
1626 },
1627 )
1628 .await;
1629 }
1630
1631 #[tokio::test]
1632 async fn list_remote_without_provider_filter_queries_all_providers() {
1633 crate::config::with_isolated_config_path_async(
1638 "models-list_remote_without_provider_filter_queries_all_providers",
1639 |_fake_dir| async move {
1640 let args = ListArgs {
1641 remote: true,
1642 provider: None,
1643 all: false,
1644 json: false,
1645 };
1646 let new_model = ModelInfo {
1647 id: "mock-brand-new-model".to_string(),
1648 display_name: Some("Mock Brand New Model".to_string()),
1649 provider: "mock".to_string(),
1650 capabilities: ModelCapabilities::default(),
1651 };
1652 let result =
1653 list_with_registry(args, &mock_registry("mock", vec![new_model], false)).await;
1654 assert!(result.is_ok());
1655 },
1656 )
1657 .await;
1658 }
1659
1660 #[tokio::test]
1661 async fn list_remote_overrides_builtin_entry_with_same_id() {
1662 crate::config::with_isolated_config_path_async(
1663 "models-list_remote_overrides_builtin_entry_with_same_id",
1664 |_fake_dir| async move {
1665 let known_id = builtin_table()[0].model_id.to_string();
1666 let args = ListArgs {
1667 remote: true,
1668 provider: Some("mock".to_string()),
1669 all: false,
1670 json: false,
1671 };
1672 let overriding_model = ModelInfo {
1673 id: known_id,
1674 display_name: Some("Overridden".to_string()),
1675 provider: "mock".to_string(),
1676 capabilities: ModelCapabilities::default(),
1677 };
1678 let result =
1679 list_with_registry(args, &mock_registry("mock", vec![overriding_model], false))
1680 .await;
1681 assert!(result.is_ok());
1682 },
1683 )
1684 .await;
1685 }
1686
1687 #[tokio::test]
1692 async fn list_shows_only_providers_the_install_has_credentials_for() {
1693 crate::config::with_isolated_config_path_async(
1694 "models-list_only_available",
1695 |_fake_dir| async move {
1696 let args = ListArgs {
1697 remote: false,
1698 provider: None,
1699 all: false,
1700 json: false,
1701 };
1702 let result =
1705 list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1706 assert!(result.is_ok());
1707 },
1708 )
1709 .await;
1710 }
1711
1712 #[tokio::test]
1716 async fn list_remote_overrides_a_builtin_entry_with_the_same_id() {
1717 crate::config::with_isolated_config_path_async(
1718 "models-list_remote_override",
1719 |_fake_dir| async move {
1720 let remote = vec![ModelInfo {
1721 id: "claude-sonnet-5".to_string(),
1722 display_name: Some("Claude Sonnet 5 (remote)".to_string()),
1723 provider: "anthropic".to_string(),
1724 capabilities: leviath_providers::ModelCapabilities::default(),
1725 }];
1726 let args = ListArgs {
1727 remote: true,
1728 provider: None,
1729 all: false,
1730 json: false,
1731 };
1732 let result =
1733 list_with_registry(args, &mock_registry("anthropic", remote, false)).await;
1734 assert!(result.is_ok());
1735 },
1736 )
1737 .await;
1738 }
1739
1740 #[tokio::test]
1743 async fn list_all_includes_providers_without_credentials() {
1744 crate::config::with_isolated_config_path_async(
1745 "models-list_all_includes_everything",
1746 |_fake_dir| async move {
1747 let args = ListArgs {
1748 remote: false,
1749 provider: None,
1750 all: true,
1751 json: false,
1752 };
1753 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1754 assert!(result.is_ok());
1755 },
1756 )
1757 .await;
1758 }
1759
1760 #[tokio::test]
1763 async fn list_marks_overridden_capabilities_for_an_available_provider() {
1764 crate::config::with_isolated_config_path_async(
1765 "models-list_overridden_available",
1766 |_fake_dir| async move {
1767 let mut config = Config::default();
1768 config.model_capabilities.insert(
1769 "claude-sonnet-5".to_string(),
1770 leviath_providers::ModelCapabilities::default(),
1771 );
1772 config
1773 .save_to_path(&Config::config_path())
1774 .expect("the isolated config path is writable");
1775 let args = ListArgs {
1776 remote: false,
1777 provider: None,
1778 all: false,
1779 json: false,
1780 };
1781 let result =
1782 list_with_registry(args, &mock_registry("anthropic", vec![], false)).await;
1783 assert!(result.is_ok());
1784 },
1785 )
1786 .await;
1787 }
1788
1789 #[tokio::test]
1790 async fn list_remote_provider_error_warns_and_continues() {
1791 crate::config::with_isolated_config_path_async(
1792 "models-list_remote_provider_error_warns_and_continues",
1793 |_fake_dir| async move {
1794 let args = ListArgs {
1795 remote: true,
1796 provider: Some("mock".to_string()),
1797 all: false,
1798 json: false,
1799 };
1800 let result = list_with_registry(args, &mock_registry("mock", vec![], true)).await;
1801 assert!(result.is_ok());
1802 },
1803 )
1804 .await;
1805 }
1806
1807 #[tokio::test]
1808 async fn list_remote_skips_providers_not_matching_filter() {
1809 crate::config::with_isolated_config_path_async(
1810 "models-list_remote_skips_providers_not_matching_filter",
1811 |_fake_dir| async move {
1812 let args = ListArgs {
1816 remote: true,
1817 provider: Some("mock-other".to_string()),
1818 all: false,
1819 json: false,
1820 };
1821 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1822 assert!(result.is_ok());
1823 },
1824 )
1825 .await;
1826 }
1827
1828 #[tokio::test]
1829 async fn show_remote_finds_model_from_provider() {
1830 crate::config::with_isolated_config_path_async(
1831 "models-show_remote_finds_model_from_provider",
1832 |_fake_dir| async move {
1833 let args = ShowArgs {
1834 model: "mock-remote-model".to_string(),
1835 remote: true,
1836 provider: Some("mock".to_string()),
1837 };
1838 let remote_model = ModelInfo {
1839 id: "mock-remote-model".to_string(),
1840 display_name: Some("Mock Remote Model".to_string()),
1841 provider: "mock".to_string(),
1842 capabilities: ModelCapabilities::default(),
1843 };
1844 let result =
1845 show_with_registry(args, &mock_registry("mock", vec![remote_model], false))
1846 .await;
1847 assert!(result.is_ok());
1848 },
1849 )
1850 .await;
1851 }
1852
1853 #[tokio::test]
1854 async fn show_remote_model_not_found_in_provider_list_falls_through() {
1855 crate::config::with_isolated_config_path_async(
1856 "models-show_remote_model_not_found_in_provider_list_falls_through",
1857 |_fake_dir| async move {
1858 let args = ShowArgs {
1859 model: "totally-unknown-model-xyz".to_string(),
1860 remote: true,
1861 provider: Some("mock".to_string()),
1862 };
1863 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1864 assert!(result.is_ok());
1865 },
1866 )
1867 .await;
1868 }
1869
1870 #[tokio::test]
1871 async fn show_remote_provider_error_warns_and_falls_through() {
1872 crate::config::with_isolated_config_path_async(
1873 "models-show_remote_provider_error_warns_and_falls_through",
1874 |_fake_dir| async move {
1875 let args = ShowArgs {
1876 model: "totally-unknown-model-xyz".to_string(),
1877 remote: true,
1878 provider: Some("mock".to_string()),
1879 };
1880 let result = show_with_registry(args, &mock_registry("mock", vec![], true)).await;
1881 assert!(result.is_ok());
1882 },
1883 )
1884 .await;
1885 }
1886
1887 #[tokio::test]
1888 async fn show_remote_unconfigured_provider_warns_and_falls_through() {
1889 crate::config::with_isolated_config_path_async(
1890 "models-show_remote_unconfigured_provider_warns_and_falls_through",
1891 |_fake_dir| async move {
1892 let args = ShowArgs {
1895 model: "totally-unknown-model-xyz".to_string(),
1896 remote: true,
1897 provider: Some("nonexistent-provider".to_string()),
1898 };
1899 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1900 assert!(result.is_ok());
1901 },
1902 )
1903 .await;
1904 }
1905
1906 #[tokio::test]
1915 async fn list_prints_warning_and_applies_model_capabilities_override() {
1916 crate::config::with_isolated_config_path_async(
1917 "models-list-override",
1918 |_fake_dir| async move {
1919 let known_id = builtin_table()[0].model_id.to_string();
1920 let mut fake_config = Config::default();
1921 fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1922 fake_config.model_capabilities.insert(
1923 known_id,
1924 ModelCapabilities {
1925 supports_temperature: false,
1926 supports_streaming: false,
1927 supports_tools: false,
1928 supports_system_prompt: false,
1929 max_context_tokens: 1,
1930 max_output_tokens: 1,
1931 },
1932 );
1933 std::fs::write(
1934 Config::config_path(),
1935 toml::to_string(&fake_config).unwrap(),
1936 )
1937 .unwrap();
1938
1939 let args = ListArgs {
1940 remote: false,
1941 provider: None,
1942 all: false,
1943 json: false,
1944 };
1945 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
1946 assert!(result.is_ok());
1947 },
1948 )
1949 .await;
1950 }
1951
1952 #[tokio::test]
1953 async fn show_prints_warning_and_uses_model_capabilities_override() {
1954 crate::config::with_isolated_config_path_async(
1955 "models-show-override",
1956 |_fake_dir| async move {
1957 let known_id = builtin_table()[0].model_id.to_string();
1958 let mut fake_config = Config::default();
1959 fake_config.providers.anthropic_api_key = Some("not-a-real-key".to_string());
1960 fake_config.model_capabilities.insert(
1961 known_id.clone(),
1962 ModelCapabilities {
1963 supports_temperature: false,
1964 supports_streaming: false,
1965 supports_tools: false,
1966 supports_system_prompt: false,
1967 max_context_tokens: 1,
1968 max_output_tokens: 1,
1969 },
1970 );
1971 std::fs::write(
1972 Config::config_path(),
1973 toml::to_string(&fake_config).unwrap(),
1974 )
1975 .unwrap();
1976
1977 let args = ShowArgs {
1978 model: known_id,
1979 remote: false,
1980 provider: None,
1981 };
1982 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
1983 assert!(result.is_ok());
1984 },
1985 )
1986 .await;
1987 }
1988
1989 #[tokio::test]
1990 async fn mock_provider_trivial_trait_methods() {
1991 use leviath_providers::Provider;
1992 let provider = MockProvider {
1993 models: vec![],
1994 fail: false,
1995 };
1996 assert_eq!(provider.count_tokens("abcd", "mock-model").await, 1);
1997 assert_eq!(provider.max_context_tokens("mock-model"), 100_000);
1998 assert_eq!(provider.name(), "mock");
1999 let _ = provider.capabilities("mock-model");
2000 }
2001
2002 #[tokio::test]
2003 async fn mock_provider_infer_returns_err() {
2004 use leviath_providers::Provider;
2005 let provider = MockProvider {
2006 models: vec![],
2007 fail: false,
2008 };
2009 let request = leviath_providers::InferenceRequest {
2010 system: vec![],
2011 messages: vec![],
2012 model: "mock".to_string(),
2013 max_tokens: 100,
2014 temperature: 0.0,
2015 tools: vec![],
2016 extra: serde_json::Value::Null,
2017 request_timeout_secs: None,
2018 };
2019 let result = provider.infer(request).await;
2020 assert!(result.is_err());
2021 }
2022
2023 #[tokio::test]
2024 async fn list_with_registry_propagates_config_load_error() {
2025 crate::config::with_isolated_config_path_async(
2026 "models-list_with_registry_propagates_config_load_error",
2027 |fake_dir| async move {
2028 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2029 let args = ListArgs {
2030 remote: false,
2031 provider: None,
2032 all: false,
2033 json: false,
2034 };
2035 let result = list_with_registry(args, &mock_registry("mock", vec![], false)).await;
2036 assert!(result.is_err());
2037 },
2038 )
2039 .await;
2040 }
2041
2042 use clap::Parser as _;
2056
2057 #[derive(clap::Parser)]
2058 struct TestCli {
2059 #[command(flatten)]
2060 models: ModelsArgs,
2061 }
2062
2063 fn expect_list(cmd: ModelsCommand) -> ListArgs {
2069 match cmd {
2070 ModelsCommand::List(args) => args,
2071 ModelsCommand::Show(_) => panic!("expected List"),
2072 }
2073 }
2074
2075 #[test]
2076 #[should_panic(expected = "expected List")]
2077 fn expect_list_panics_on_show() {
2078 expect_list(ModelsCommand::Show(ShowArgs {
2079 model: "x".to_string(),
2080 provider: None,
2081 remote: false,
2082 }));
2083 }
2084
2085 fn expect_show(cmd: ModelsCommand) -> ShowArgs {
2087 match cmd {
2088 ModelsCommand::Show(args) => args,
2089 ModelsCommand::List(_) => panic!("expected Show"),
2090 }
2091 }
2092
2093 #[test]
2094 #[should_panic(expected = "expected Show")]
2095 fn expect_show_panics_on_list() {
2096 expect_show(ModelsCommand::List(ListArgs {
2097 provider: None,
2098 remote: false,
2099 all: false,
2100 json: false,
2101 }));
2102 }
2103
2104 #[test]
2105 fn parses_list_with_no_flags() {
2106 let cli = TestCli::try_parse_from(["lev", "list"]).unwrap();
2107 let args = expect_list(cli.models.command);
2108 assert!(args.provider.is_none());
2109 assert!(!args.remote);
2110 }
2111
2112 #[test]
2113 fn parses_list_with_long_flags() {
2114 let cli = TestCli::try_parse_from(["lev", "list", "--provider", "anthropic", "--remote"])
2115 .unwrap();
2116 let args = expect_list(cli.models.command);
2117 assert_eq!(args.provider.as_deref(), Some("anthropic"));
2118 assert!(args.remote);
2119 }
2120
2121 #[test]
2122 fn parses_list_with_short_flags() {
2123 let cli = TestCli::try_parse_from(["lev", "list", "-p", "openai", "-r"]).unwrap();
2124 let args = expect_list(cli.models.command);
2125 assert_eq!(args.provider.as_deref(), Some("openai"));
2126 assert!(args.remote);
2127 }
2128
2129 #[test]
2130 fn parses_show_with_positional_model_and_long_flags() {
2131 let cli = TestCli::try_parse_from([
2132 "lev",
2133 "show",
2134 "claude-sonnet-4-6",
2135 "--provider",
2136 "anthropic",
2137 "--remote",
2138 ])
2139 .unwrap();
2140 let args = expect_show(cli.models.command);
2141 assert_eq!(args.model, "claude-sonnet-4-6");
2142 assert_eq!(args.provider.as_deref(), Some("anthropic"));
2143 assert!(args.remote);
2144 }
2145
2146 #[test]
2147 fn parses_show_with_short_flags() {
2148 let cli =
2149 TestCli::try_parse_from(["lev", "show", "gpt-5.5", "-p", "openai", "-r"]).unwrap();
2150 let args = expect_show(cli.models.command);
2151 assert_eq!(args.model, "gpt-5.5");
2152 assert_eq!(args.provider.as_deref(), Some("openai"));
2153 assert!(args.remote);
2154 }
2155
2156 #[test]
2157 fn parses_show_missing_required_positional_errors() {
2158 let result = TestCli::try_parse_from(["lev", "show"]);
2159 assert!(result.is_err());
2160 }
2161
2162 #[test]
2163 fn parses_unknown_subcommand_errors() {
2164 let result = TestCli::try_parse_from(["lev", "not-a-subcommand"]);
2165 assert!(result.is_err());
2166 }
2167
2168 #[tokio::test]
2169 async fn show_with_registry_propagates_config_load_error() {
2170 crate::config::with_isolated_config_path_async(
2171 "models-show_with_registry_propagates_config_load_error",
2172 |fake_dir| async move {
2173 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2174 let args = ShowArgs {
2175 model: "any-model".to_string(),
2176 remote: false,
2177 provider: None,
2178 };
2179 let result = show_with_registry(args, &mock_registry("mock", vec![], false)).await;
2180 assert!(result.is_err());
2181 },
2182 )
2183 .await;
2184 }
2185}