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