1#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6use std::collections::HashSet;
7
8use crate::diagnostic::{Diagnostic, DiagnosticCollector, DiagnosticLevel};
9use crate::error::MarsError;
10use crate::models::availability::{AvailabilityStatus, ModelAvailability};
11use crate::models::probes::OpenCodeProbeResult;
12use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
13use crate::types::MarsContext;
14
15#[derive(Debug, Parser)]
17pub struct ModelsArgs {
18 #[command(subcommand)]
19 pub command: ModelsCommand,
20}
21
22#[derive(Debug, Subcommand)]
23pub enum ModelsCommand {
24 Refresh,
26 List(ListArgs),
28 Resolve(ResolveAliasArgs),
30 Alias(AddAliasArgs),
32}
33
34#[derive(Debug, Parser)]
35pub struct ListArgs {
36 #[arg(long, conflicts_with = "catalog", conflicts_with = "unavailable")]
38 all: bool,
39 #[arg(long)]
41 no_refresh_models: bool,
42 #[arg(long, value_delimiter = ',')]
44 include: Option<Vec<String>>,
45 #[arg(long, value_delimiter = ',')]
47 exclude: Option<Vec<String>>,
48 #[arg(long, conflicts_with = "all")]
50 catalog: bool,
51 #[arg(long)]
53 unavailable: bool,
54}
55
56#[derive(Debug, Parser)]
57pub struct ResolveAliasArgs {
58 pub name: String,
60 #[arg(long)]
62 no_refresh_models: bool,
63}
64
65#[derive(Debug, Parser)]
66pub struct AddAliasArgs {
67 pub name: String,
69 pub model_id: String,
71 #[arg(long, default_value = "claude")]
73 pub harness: String,
74 #[arg(long)]
76 pub description: Option<String>,
77}
78
79pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
80 match &args.command {
81 ModelsCommand::Refresh => run_refresh(ctx, json),
82 ModelsCommand::List(args) => run_list(args, ctx, json),
83 ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
84 ModelsCommand::Alias(a) => run_alias(a, ctx, json),
85 }
86}
87
88fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
89 ctx.project_root.join(".mars")
90}
91
92fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
93 let mars = mars_dir(ctx);
94 let ttl = models::load_models_cache_ttl(ctx);
95 eprint!("Fetching models catalog... ");
96
97 let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
98 let count = cache.models.len();
99 let cache_warning = cache_warning(&outcome);
100
101 if let Some(warning) = cache_warning.as_deref() {
102 eprintln!("warning: {warning}");
103 } else if !json {
104 eprintln!("done.");
105 }
106
107 if json {
108 let out = serde_json::json!({
109 "status": "ok",
110 "models_count": count,
111 "fetched_at": cache.fetched_at,
112 });
113 let mut out = out;
114 if let Some(warning) = cache_warning.as_deref() {
115 out["cache_warning"] = serde_json::json!(warning);
116 }
117 println!("{}", serde_json::to_string_pretty(&out).unwrap());
118 } else {
119 if cache_warning.is_some() {
120 println!(
121 "Using stale models cache with {} models in .mars/models-cache.json",
122 count
123 );
124 } else {
125 println!("Cached {} models in .mars/models-cache.json", count);
126 }
127 }
128
129 Ok(0)
130}
131
132fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
133 let mars = mars_dir(ctx);
134 let ttl = models::load_models_cache_ttl(ctx);
135 let mode = models::resolve_refresh_mode(args.no_refresh_models);
136 let Some((cache, outcome)) = ensure_fresh_or_json_error(&mars, ttl, mode, json)? else {
137 return Ok(1);
138 };
139
140 if args.catalog {
141 return run_list_catalog(&cache, &outcome, ctx, args, json);
142 }
143
144 let merged = load_merged_aliases(ctx)?;
146 let installed = models::harness::detect_installed_harnesses();
147 let is_offline = models::is_mars_offline() || args.no_refresh_models;
148 let probe_result = probe_opencode_if_needed(&installed, is_offline, json);
149 if args.all {
150 let availability_ctx = AvailabilityContext {
151 installed: &installed,
152 probe_result: probe_result.as_ref(),
153 is_offline,
154 };
155 return run_list_all(&merged, &cache, &outcome, ctx, args, availability_ctx, json);
156 }
157
158 let cache_warning = cache_warning(&outcome);
159 let mut diag = DiagnosticCollector::new();
160
161 let mut resolved = models::resolve_all(&merged, &cache, &mut diag);
162 annotate_resolved_availability(&mut resolved, &installed, probe_result.as_ref(), is_offline);
163 if !args.unavailable {
164 prune_unavailable(&mut resolved);
165 }
166
167 let visibility = effective_visibility(ctx, args);
169 let resolved = models::filter_by_visibility(resolved, &visibility);
170
171 if json {
172 let entries: Vec<serde_json::Value> = resolved
173 .values()
174 .map(|r| {
175 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
176 let mut obj = serde_json::json!({
177 "name": r.name,
178 "harness": r.harness,
179 "harness_source": r.harness_source,
180 "harness_candidates": r.harness_candidates,
181 "provider": r.provider,
182 "mode": mode,
183 "model_id": r.model_id,
184 "resolved_model": r.model_id,
185 "description": r.description,
186 });
187 if let Some(error) = unavailable_harness_error(r) {
188 obj["error"] = serde_json::json!(error);
189 }
190 if let Some(default_effort) = &r.default_effort {
191 obj["default_effort"] = serde_json::json!(default_effort);
192 }
193 if let Some(autocompact) = r.autocompact {
194 obj["autocompact"] = serde_json::json!(autocompact);
195 }
196 add_availability_json_fields(&mut obj, r.availability.as_ref());
197 obj
198 })
199 .collect();
200 let mut out = serde_json::json!({
201 "aliases": entries,
202 "cache_available": cache.fetched_at.is_some(),
203 });
204 add_probe_results_json(&mut out, probe_result.as_ref());
205 if let Some(warning) = cache_warning.as_deref() {
206 out["cache_warning"] = serde_json::json!(warning);
207 }
208 if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
209 out["diagnostics"] = diagnostics;
210 }
211 println!("{}", serde_json::to_string_pretty(&out).unwrap());
212 } else {
213 if let Some(warning) = cache_warning.as_deref() {
214 eprintln!("warning: {warning}");
215 }
216 println!(
218 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
219 "ALIAS", "HARNESS", "MODE", "RESOLVED", "AVAILABILITY", "DESCRIPTION"
220 );
221 for r in resolved.values() {
222 let harness = r.harness.as_deref().unwrap_or("—");
223 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
224 let availability = availability_status_label(r.availability.as_ref());
225 let desc = r.description.clone().unwrap_or_default();
226 println!(
227 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
228 r.name, harness, mode, r.model_id, availability, desc
229 );
230 }
231 emit_text_diagnostics(&mut diag);
232 }
233
234 Ok(0)
235}
236
237#[derive(Debug, Clone)]
238struct ListModelEntry {
239 id: String,
240 provider: String,
241 release_date: Option<String>,
242 harness: Option<String>,
243 harness_source: HarnessSource,
244 harness_candidates: Vec<String>,
245 description: Option<String>,
246 matched_aliases: Vec<String>,
247 availability: Option<ModelAvailability>,
248}
249
250#[derive(Clone, Copy)]
251struct AvailabilityContext<'a> {
252 installed: &'a HashSet<String>,
253 probe_result: Option<&'a OpenCodeProbeResult>,
254 is_offline: bool,
255}
256
257fn run_list_all(
258 merged: &IndexMap<String, ModelAlias>,
259 cache: &models::ModelsCache,
260 outcome: &models::RefreshOutcome,
261 ctx: &MarsContext,
262 args: &ListArgs,
263 availability_ctx: AvailabilityContext<'_>,
264 json: bool,
265) -> Result<i32, MarsError> {
266 let cache_warning = cache_warning(outcome);
267 let visibility = effective_visibility(ctx, args);
268 let models = collect_all_model_entries(
269 merged,
270 cache,
271 availability_ctx.installed,
272 availability_ctx.probe_result,
273 availability_ctx.is_offline,
274 );
275 let models = filter_model_entries_by_visibility(models, &visibility);
276
277 if json {
278 let entries: Vec<serde_json::Value> = models
279 .into_iter()
280 .map(|model| {
281 let mut obj = serde_json::json!({
282 "id": model.id,
283 "provider": model.provider,
284 "release_date": model.release_date,
285 "harness": model.harness,
286 "harness_source": model.harness_source,
287 "harness_candidates": model.harness_candidates,
288 "description": model.description,
289 "matched_aliases": model.matched_aliases,
290 });
291 add_availability_json_fields(&mut obj, model.availability.as_ref());
292 obj
293 })
294 .collect();
295 let mut out = serde_json::json!({
296 "models": entries,
297 "cache_available": cache.fetched_at.is_some(),
298 });
299 add_probe_results_json(&mut out, availability_ctx.probe_result);
300 if let Some(warning) = cache_warning.as_deref() {
301 out["cache_warning"] = serde_json::json!(warning);
302 }
303 println!("{}", serde_json::to_string_pretty(&out).unwrap());
304 } else {
305 if let Some(warning) = cache_warning.as_deref() {
306 eprintln!("warning: {warning}");
307 }
308 println!(
309 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
310 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY", "ALIASES"
311 );
312 for model in models {
313 let release = model.release_date.as_deref().unwrap_or("—");
314 let harness = model.harness.as_deref().unwrap_or("—");
315 let availability = availability_status_label(model.availability.as_ref());
316 println!(
317 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
318 model.provider,
319 model.id,
320 release,
321 harness,
322 availability,
323 model.matched_aliases.join(",")
324 );
325 }
326 }
327
328 Ok(0)
329}
330
331fn run_list_catalog(
332 cache: &models::ModelsCache,
333 outcome: &models::RefreshOutcome,
334 ctx: &MarsContext,
335 args: &ListArgs,
336 json: bool,
337) -> Result<i32, MarsError> {
338 let cache_warning = cache_warning(outcome);
339 let installed = models::harness::detect_installed_harnesses();
340 let is_offline = models::is_mars_offline() || args.no_refresh_models;
341 let probe_result = probe_opencode_if_needed(&installed, is_offline, json);
342 let visibility = effective_visibility(ctx, args);
343 let models =
344 collect_catalog_model_entries(cache, &installed, probe_result.as_ref(), is_offline);
345 let models = filter_model_entries_by_visibility(models, &visibility);
346
347 if json {
348 let entries: Vec<serde_json::Value> = models
349 .into_iter()
350 .map(|model| {
351 let mut obj = serde_json::json!({
352 "id": model.id,
353 "provider": model.provider,
354 "release_date": model.release_date,
355 "harness": model.harness,
356 "harness_source": model.harness_source,
357 "harness_candidates": model.harness_candidates,
358 "description": model.description,
359 });
360 add_availability_json_fields(&mut obj, model.availability.as_ref());
361 obj
362 })
363 .collect();
364 let mut out = serde_json::json!({
365 "models": entries,
366 "cache_available": cache.fetched_at.is_some(),
367 });
368 add_probe_results_json(&mut out, probe_result.as_ref());
369 if let Some(warning) = cache_warning.as_deref() {
370 out["cache_warning"] = serde_json::json!(warning);
371 }
372 println!("{}", serde_json::to_string_pretty(&out).unwrap());
373 } else {
374 if let Some(warning) = cache_warning.as_deref() {
375 eprintln!("warning: {warning}");
376 }
377 println!(
378 "{:<10} {:<34} {:<12} {:<10} {:<12}",
379 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY"
380 );
381 for model in models {
382 let release = model.release_date.as_deref().unwrap_or("—");
383 let harness = model.harness.as_deref().unwrap_or("—");
384 let availability = availability_status_label(model.availability.as_ref());
385 println!(
386 "{:<10} {:<34} {:<12} {:<10} {:<12}",
387 model.provider, model.id, release, harness, availability
388 );
389 }
390 }
391
392 Ok(0)
393}
394
395fn collect_all_model_entries(
396 merged: &IndexMap<String, ModelAlias>,
397 cache: &models::ModelsCache,
398 installed: &HashSet<String>,
399 probe_result: Option<&OpenCodeProbeResult>,
400 is_offline: bool,
401) -> Vec<ListModelEntry> {
402 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
403
404 for (alias_name, alias) in merged {
405 match &alias.spec {
406 ModelSpec::AutoResolve {
407 provider,
408 match_patterns,
409 exclude_patterns,
410 } => {
411 for matched in
412 models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
413 {
414 append_alias_match(
415 &mut by_model_id,
416 matched,
417 installed,
418 probe_result,
419 is_offline,
420 alias_name,
421 );
422 }
423 }
424 ModelSpec::Pinned {
425 model, provider, ..
426 } => {
427 if let Some(matched) = cache
428 .models
429 .iter()
430 .find(|cache_model| cache_model.id == *model)
431 {
432 append_alias_match(
433 &mut by_model_id,
434 matched,
435 installed,
436 probe_result,
437 is_offline,
438 alias_name,
439 );
440 } else {
441 append_pinned_alias_match(
442 &mut by_model_id,
443 model,
444 provider.as_deref(),
445 alias.description.as_deref(),
446 AvailabilityContext {
447 installed,
448 probe_result,
449 is_offline,
450 },
451 alias_name,
452 );
453 }
454 }
455 ModelSpec::PinnedWithMatch {
456 model,
457 provider,
458 match_patterns,
459 exclude_patterns,
460 } => {
461 if let Some(matched) = cache
462 .models
463 .iter()
464 .find(|cache_model| cache_model.id == *model)
465 {
466 append_alias_match(
467 &mut by_model_id,
468 matched,
469 installed,
470 probe_result,
471 is_offline,
472 alias_name,
473 );
474 } else {
475 append_pinned_alias_match(
476 &mut by_model_id,
477 model,
478 provider.as_deref(),
479 alias.description.as_deref(),
480 AvailabilityContext {
481 installed,
482 probe_result,
483 is_offline,
484 },
485 alias_name,
486 );
487 }
488
489 let provider_for_discovery = provider
490 .as_deref()
491 .or_else(|| models::infer_provider_from_model_id(model));
492 if let Some(provider_for_discovery) = provider_for_discovery {
493 for matched in models::auto_resolve_all(
494 provider_for_discovery,
495 match_patterns,
496 exclude_patterns,
497 cache,
498 ) {
499 append_alias_match(
500 &mut by_model_id,
501 matched,
502 installed,
503 probe_result,
504 is_offline,
505 alias_name,
506 );
507 }
508 }
509 }
510 }
511 }
512
513 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
514 sort_list_model_entries(&mut out);
515 out
516}
517
518fn collect_catalog_model_entries(
519 cache: &models::ModelsCache,
520 installed: &HashSet<String>,
521 probe_result: Option<&OpenCodeProbeResult>,
522 is_offline: bool,
523) -> Vec<ListModelEntry> {
524 let mut out: Vec<ListModelEntry> = cache
525 .models
526 .iter()
527 .map(|model| model_entry_for_cached(model, installed, probe_result, is_offline))
528 .collect();
529 sort_list_model_entries(&mut out);
530 out
531}
532
533fn append_alias_match(
534 by_model_id: &mut IndexMap<String, ListModelEntry>,
535 model: &models::CachedModel,
536 installed: &HashSet<String>,
537 probe_result: Option<&OpenCodeProbeResult>,
538 is_offline: bool,
539 alias_name: &str,
540) {
541 let entry = by_model_id
542 .entry(model.id.clone())
543 .or_insert_with(|| model_entry_for_cached(model, installed, probe_result, is_offline));
544
545 append_alias_name(entry, alias_name);
546}
547
548fn append_pinned_alias_match(
549 by_model_id: &mut IndexMap<String, ListModelEntry>,
550 model_id: &str,
551 provider: Option<&str>,
552 description: Option<&str>,
553 availability_ctx: AvailabilityContext<'_>,
554 alias_name: &str,
555) {
556 let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
557 model_entry_for_pinned(
558 model_id,
559 provider,
560 description,
561 availability_ctx.installed,
562 availability_ctx.probe_result,
563 availability_ctx.is_offline,
564 )
565 });
566
567 append_alias_name(entry, alias_name);
568}
569
570fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
571 if !entry
572 .matched_aliases
573 .iter()
574 .any(|existing| existing == alias_name)
575 {
576 entry.matched_aliases.push(alias_name.to_string());
577 }
578}
579
580fn model_entry_for_cached(
581 model: &models::CachedModel,
582 installed: &HashSet<String>,
583 probe_result: Option<&OpenCodeProbeResult>,
584 is_offline: bool,
585) -> ListModelEntry {
586 let harness = models::harness::resolve_harness_for_provider(&model.provider, installed);
587 let harness_source = if harness.is_some() {
588 HarnessSource::AutoDetected
589 } else {
590 HarnessSource::Unavailable
591 };
592
593 ListModelEntry {
594 id: model.id.clone(),
595 provider: model.provider.clone(),
596 release_date: model.release_date.clone(),
597 harness,
598 harness_source,
599 harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
600 description: model.description.clone(),
601 matched_aliases: Vec::new(),
602 availability: Some(models::availability::classify_model(
603 &model.id,
604 &model.provider,
605 installed,
606 probe_result,
607 is_offline,
608 )),
609 }
610}
611
612fn model_entry_for_pinned(
613 model_id: &str,
614 provider: Option<&str>,
615 description: Option<&str>,
616 installed: &HashSet<String>,
617 probe_result: Option<&OpenCodeProbeResult>,
618 is_offline: bool,
619) -> ListModelEntry {
620 let provider = provider
621 .map(str::to_string)
622 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
623 .unwrap_or_else(|| "unknown".to_string());
624 let harness = models::harness::resolve_harness_for_provider(&provider, installed);
625 let harness_source = if harness.is_some() {
626 HarnessSource::AutoDetected
627 } else {
628 HarnessSource::Unavailable
629 };
630
631 ListModelEntry {
632 id: model_id.to_string(),
633 provider: provider.clone(),
634 release_date: None,
635 harness,
636 harness_source,
637 harness_candidates: models::harness::harness_candidates_for_provider(&provider),
638 description: description.map(str::to_string),
639 matched_aliases: Vec::new(),
640 availability: Some(models::availability::classify_model(
641 model_id,
642 &provider,
643 installed,
644 probe_result,
645 is_offline,
646 )),
647 }
648}
649
650fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
651 entries.sort_by(|a, b| {
652 a.provider
653 .to_ascii_lowercase()
654 .cmp(&b.provider.to_ascii_lowercase())
655 .then_with(|| {
656 b.release_date
657 .as_deref()
658 .unwrap_or("")
659 .cmp(a.release_date.as_deref().unwrap_or(""))
660 })
661 .then_with(|| a.id.cmp(&b.id))
662 });
663}
664
665fn effective_visibility(ctx: &MarsContext, args: &ListArgs) -> crate::config::ModelVisibility {
666 if args.include.is_some() || args.exclude.is_some() {
667 return crate::config::ModelVisibility {
668 include: args.include.clone(),
669 exclude: args.exclude.clone(),
670 };
671 }
672
673 crate::config::load(&ctx.project_root)
674 .map(|config| config.settings.model_visibility)
675 .unwrap_or_default()
676}
677
678fn probe_opencode_if_needed(
679 installed: &HashSet<String>,
680 is_offline: bool,
681 json: bool,
682) -> Option<OpenCodeProbeResult> {
683 if is_offline || !installed.contains("opencode") {
684 return None;
685 }
686 if !json {
687 eprint!("Probing OpenCode providers... ");
688 }
689 let probe = models::probes::opencode::probe();
690 if !json {
691 if probe.provider_probe_success {
692 eprintln!(
693 "done ({} providers, {} models)",
694 probe.providers.len(),
695 probe.model_slugs.len()
696 );
697 } else {
698 eprintln!("unknown");
699 }
700 }
701 Some(probe)
702}
703
704fn annotate_resolved_availability(
705 resolved: &mut IndexMap<String, models::ResolvedAlias>,
706 installed: &HashSet<String>,
707 probe_result: Option<&OpenCodeProbeResult>,
708 is_offline: bool,
709) {
710 for alias in resolved.values_mut() {
711 alias.availability = Some(models::availability::classify_model(
712 &alias.model_id,
713 &alias.provider,
714 installed,
715 probe_result,
716 is_offline,
717 ));
718 }
719}
720
721fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
722 resolved.retain(|_, alias| {
723 alias
724 .availability
725 .as_ref()
726 .map(|availability| availability.status != AvailabilityStatus::Unavailable)
727 .unwrap_or(true)
728 });
729}
730
731fn filter_model_entries_by_visibility(
732 entries: Vec<ListModelEntry>,
733 visibility: &crate::config::ModelVisibility,
734) -> Vec<ListModelEntry> {
735 if visibility.include.is_none() && visibility.exclude.is_none() {
736 return entries;
737 }
738
739 entries
740 .into_iter()
741 .filter(|entry| {
742 let paths = entry
743 .availability
744 .as_ref()
745 .map(|availability| availability.runnable_paths.as_slice())
746 .unwrap_or(&[]);
747 let included = visibility.include.as_ref().is_none_or(|includes| {
748 includes.iter().any(|pattern| {
749 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
750 })
751 });
752 let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
753 excludes.iter().any(|pattern| {
754 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
755 })
756 });
757 included && !excluded
758 })
759 .collect()
760}
761
762fn add_availability_json_fields(
763 obj: &mut serde_json::Value,
764 availability: Option<&ModelAvailability>,
765) {
766 if let Some(availability) = availability {
767 obj["availability"] = serde_json::json!(availability.status);
768 obj["availability_source"] = serde_json::json!(availability.source);
769 obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
770 }
771}
772
773fn add_probe_results_json(out: &mut serde_json::Value, probe_result: Option<&OpenCodeProbeResult>) {
774 if let Some(probe) = probe_result {
775 out["probe_results"] = serde_json::json!({
776 "opencode": {
777 "success": probe.provider_probe_success && probe.model_probe_success,
778 "providers_found": probe.providers.keys().collect::<Vec<_>>(),
779 "models_found": probe.model_slugs.len(),
780 }
781 });
782 }
783}
784
785fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
786 match availability.map(|value| value.status) {
787 Some(AvailabilityStatus::Runnable) => "runnable",
788 Some(AvailabilityStatus::Unavailable) => "unavailable",
789 Some(AvailabilityStatus::Unknown) => "unknown",
790 None => "unknown",
791 }
792}
793
794fn annotate_one_availability(
795 resolved: &mut models::ResolvedAlias,
796 args: &ResolveAliasArgs,
797 probe_result: Option<&OpenCodeProbeResult>,
798) {
799 let installed = models::harness::detect_installed_harnesses();
800 let is_offline = models::is_mars_offline() || args.no_refresh_models;
801 let owned_probe = if probe_result.is_none() && !is_offline && installed.contains("opencode") {
802 Some(models::probes::opencode::probe())
803 } else {
804 None
805 };
806 let probe_result = probe_result.or(owned_probe.as_ref());
807 resolved.availability = Some(models::availability::classify_model(
808 &resolved.model_id,
809 &resolved.provider,
810 &installed,
811 probe_result,
812 is_offline,
813 ));
814}
815
816fn print_availability_text(availability: Option<&ModelAvailability>) {
817 if let Some(availability) = availability {
818 println!(
819 "Availability: {} ({:?})",
820 availability_status_label(Some(availability)),
821 availability.source
822 );
823 for (idx, path) in availability.runnable_paths.iter().enumerate() {
824 let label = if idx == 0 {
825 "Runnable via:"
826 } else {
827 " "
828 };
829 println!("{label} {} -> {}", path.harness, path.harness_model_id);
830 }
831 }
832}
833
834fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
835 let merged = load_merged_aliases(ctx)?;
836 let mars = mars_dir(ctx);
837 let ttl = models::load_models_cache_ttl(ctx);
838 let mode = models::resolve_refresh_mode(args.no_refresh_models);
839
840 let cache_result = ensure_fresh_or_json_error(&mars, ttl, mode, json)?;
842
843 if let Some((cache, outcome)) = &cache_result {
844 if let Some(alias) = merged.get(&args.name) {
846 return run_resolve_exact_alias(args, alias, &merged, ctx, cache, outcome, json);
847 }
848
849 if let Some(mut resolved) = models::resolve_with_alias_prefix(&args.name, &merged, cache) {
851 annotate_one_availability(&mut resolved, args, None);
852 return run_output_resolved(&args.name, &resolved, "alias_prefix", outcome, json);
853 }
854 }
855
856 let outcome = cache_result
858 .as_ref()
859 .map(|(_, o)| o.clone())
860 .unwrap_or(models::RefreshOutcome::Offline);
861 let is_offline = models::is_mars_offline() || args.no_refresh_models;
862 run_output_passthrough(&args.name, &outcome, is_offline, json)
863}
864
865fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
866 let mut config = crate::config::load(&ctx.project_root)?;
867 config.models.insert(
868 args.name.clone(),
869 ModelAlias {
870 harness: Some(args.harness.clone()),
871 description: args.description.clone(),
872 default_effort: None,
873 autocompact: None,
874 spec: ModelSpec::Pinned {
875 model: args.model_id.clone(),
876 provider: None,
877 },
878 },
879 );
880 crate::config::save(&ctx.project_root, &config)?;
881
882 if json {
883 println!(
884 "{}",
885 serde_json::to_string_pretty(&serde_json::json!({
886 "status": "ok",
887 "alias": args.name,
888 "model": args.model_id,
889 "harness": args.harness,
890 }))
891 .unwrap()
892 );
893 } else {
894 println!(
895 "Added alias `{}` → {} (harness: {})",
896 args.name, args.model_id, args.harness
897 );
898 }
899
900 Ok(0)
901}
902
903fn ensure_fresh_or_json_error(
904 mars: &std::path::Path,
905 ttl: u32,
906 mode: models::RefreshMode,
907 json: bool,
908) -> Result<Option<(models::ModelsCache, models::RefreshOutcome)>, MarsError> {
909 match models::ensure_fresh(mars, ttl, mode) {
910 Ok(ok) => Ok(Some(ok)),
911 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
912 println!(
913 "{}",
914 serde_json::to_string_pretty(&serde_json::json!({
915 "error": format!("{err}"),
916 }))
917 .unwrap()
918 );
919 Ok(None)
920 }
921 Err(err) => Err(err),
922 }
923}
924
925fn run_resolve_exact_alias(
926 args: &ResolveAliasArgs,
927 alias: &ModelAlias,
928 merged: &IndexMap<String, ModelAlias>,
929 ctx: &MarsContext,
930 cache: &models::ModelsCache,
931 outcome: &models::RefreshOutcome,
932 json: bool,
933) -> Result<i32, MarsError> {
934 let cache_warning = cache_warning(outcome);
935 if let Some(warning) = cache_warning.as_deref()
936 && !json
937 {
938 eprintln!("warning: {warning}");
939 }
940
941 let name = &args.name;
942 let source = determine_source(name, ctx)?;
943 let mut diag = DiagnosticCollector::new();
944 let mut resolved_entry = models::resolve_one(name, merged, cache, &mut diag);
945 if let Some(r) = resolved_entry.as_mut() {
946 annotate_one_availability(r, args, None);
947 }
948 let diagnostics = diag.drain();
949
950 if json {
951 if let Some(r) = resolved_entry.as_ref() {
952 let mut out = serde_json::json!({
953 "name": r.name,
954 "source": source,
955 "provider": r.provider,
956 "harness": r.harness,
957 "harness_source": r.harness_source,
958 "harness_candidates": r.harness_candidates,
959 "model_id": r.model_id,
960 "resolved_model": r.model_id,
961 "spec": format_spec(&alias.spec),
962 "description": r.description,
963 });
964 if let Some(error) = unavailable_harness_error(r) {
965 out["error"] = serde_json::json!(error);
966 }
967 if let Some(default_effort) = &r.default_effort {
968 out["default_effort"] = serde_json::json!(default_effort);
969 }
970 if let Some(autocompact) = r.autocompact {
971 out["autocompact"] = serde_json::json!(autocompact);
972 }
973 add_availability_json_fields(&mut out, r.availability.as_ref());
974 if let Some(warning) = cache_warning.as_deref() {
975 out["cache_warning"] = serde_json::json!(warning);
976 }
977 if !diagnostics.is_empty() {
978 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
979 }
980 println!("{}", serde_json::to_string_pretty(&out).unwrap());
981 } else {
982 let mut out = serde_json::json!({
983 "error": format!("alias `{}` did not resolve to a model ID", name),
984 });
985 if let Some(warning) = cache_warning.as_deref() {
986 out["cache_warning"] = serde_json::json!(warning);
987 }
988 if !diagnostics.is_empty() {
989 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
990 }
991 println!("{}", serde_json::to_string_pretty(&out).unwrap());
992 return Ok(1);
993 }
994 } else {
995 let Some(r) = resolved_entry.as_ref() else {
996 eprintln!("error: alias `{}` did not resolve to a model ID", name);
997 return Ok(1);
998 };
999 let harness = r.harness.as_deref().unwrap_or("—");
1000 println!("Alias: {}", name);
1001 println!("Source: {}", source);
1002 println!(
1003 "Harness: {} ({})",
1004 harness,
1005 harness_source_label(&r.harness_source)
1006 );
1007 println!("Provider: {}", r.provider);
1008 match &alias.spec {
1009 ModelSpec::Pinned { model, provider: _ } => {
1010 println!("Mode: pinned");
1011 println!("Model: {}", model);
1012 }
1013 ModelSpec::PinnedWithMatch {
1014 model,
1015 provider: _,
1016 match_patterns,
1017 exclude_patterns,
1018 } => {
1019 println!("Mode: pinned");
1020 println!("Model: {}", model);
1021 println!("Match: {}", match_patterns.join(", "));
1022 if !exclude_patterns.is_empty() {
1023 println!("Exclude: {}", exclude_patterns.join(", "));
1024 }
1025 println!("Resolved: {}", r.model_id);
1026 }
1027 ModelSpec::AutoResolve {
1028 provider: _,
1029 match_patterns,
1030 exclude_patterns,
1031 } => {
1032 println!("Mode: auto-resolve");
1033 println!("Match: {}", match_patterns.join(", "));
1034 if !exclude_patterns.is_empty() {
1035 println!("Exclude: {}", exclude_patterns.join(", "));
1036 }
1037 println!("Resolved: {}", r.model_id);
1038 }
1039 }
1040 if let Some(error) = unavailable_harness_error(r) {
1041 println!("Error: {}", error);
1042 }
1043 print_availability_text(r.availability.as_ref());
1044 if let Some(desc) = &r.description {
1045 println!("Desc: {}", desc);
1046 }
1047 emit_drained_text_diagnostics(&diagnostics);
1048 }
1049
1050 Ok(0)
1051}
1052
1053fn run_output_resolved(
1054 name: &str,
1055 resolved: &models::ResolvedAlias,
1056 source: &str,
1057 outcome: &models::RefreshOutcome,
1058 json: bool,
1059) -> Result<i32, MarsError> {
1060 let cache_warning = cache_warning(outcome);
1061 if let Some(warning) = cache_warning.as_deref()
1062 && !json
1063 {
1064 eprintln!("warning: {warning}");
1065 }
1066
1067 if json {
1068 let mut out = serde_json::json!({
1069 "name": name,
1070 "source": source,
1071 "provider": resolved.provider,
1072 "harness": resolved.harness,
1073 "harness_source": resolved.harness_source,
1074 "harness_candidates": resolved.harness_candidates,
1075 "model_id": resolved.model_id,
1076 "resolved_model": resolved.model_id,
1077 "description": resolved.description,
1078 });
1079 if let Some(error) = unavailable_harness_error(resolved) {
1080 out["error"] = serde_json::json!(error);
1081 }
1082 if let Some(default_effort) = &resolved.default_effort {
1083 out["default_effort"] = serde_json::json!(default_effort);
1084 }
1085 if let Some(autocompact) = resolved.autocompact {
1086 out["autocompact"] = serde_json::json!(autocompact);
1087 }
1088 add_availability_json_fields(&mut out, resolved.availability.as_ref());
1089 if let Some(warning) = cache_warning.as_deref() {
1090 out["cache_warning"] = serde_json::json!(warning);
1091 }
1092 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1093 } else {
1094 let harness = resolved.harness.as_deref().unwrap_or("—");
1095 println!("Alias: {}", name);
1096 println!("Source: {}", source);
1097 println!(
1098 "Harness: {} ({})",
1099 harness,
1100 harness_source_label(&resolved.harness_source)
1101 );
1102 println!("Provider: {}", resolved.provider);
1103 println!("Resolved: {}", resolved.model_id);
1104 if let Some(error) = unavailable_harness_error(resolved) {
1105 println!("Error: {}", error);
1106 }
1107 print_availability_text(resolved.availability.as_ref());
1108 if let Some(desc) = &resolved.description {
1109 println!("Desc: {}", desc);
1110 }
1111 }
1112
1113 Ok(0)
1114}
1115
1116fn run_output_passthrough(
1117 name: &str,
1118 outcome: &models::RefreshOutcome,
1119 is_offline: bool,
1120 json: bool,
1121) -> Result<i32, MarsError> {
1122 if name.trim().is_empty() {
1123 if json {
1124 println!(
1125 "{}",
1126 serde_json::to_string_pretty(&serde_json::json!({
1127 "error": "model name cannot be empty"
1128 }))
1129 .unwrap()
1130 );
1131 } else {
1132 eprintln!("error: model name cannot be empty");
1133 }
1134 return Ok(1);
1135 }
1136
1137 let cache_warning = cache_warning(outcome);
1138 if let Some(warning) = cache_warning.as_deref()
1139 && !json
1140 {
1141 eprintln!("warning: {warning}");
1142 }
1143
1144 let installed = models::harness::detect_installed_harnesses();
1145 let guessed_provider = models::infer_provider_from_model_id(name).map(str::to_string);
1146 let harness = guessed_provider
1147 .as_deref()
1148 .and_then(|p| models::harness::resolve_harness_for_provider(p, &installed));
1149 let harness_source = if harness.is_some() {
1150 "pattern_guess"
1151 } else {
1152 "unavailable"
1153 };
1154 let harness_candidates = guessed_provider
1155 .as_deref()
1156 .map(models::harness::harness_candidates_for_provider)
1157 .unwrap_or_default();
1158 let probe_result = if !is_offline && installed.contains("opencode") {
1159 Some(models::probes::opencode::probe())
1160 } else {
1161 None
1162 };
1163 let availability = models::availability::classify_model(
1164 name,
1165 guessed_provider.as_deref().unwrap_or("unknown"),
1166 &installed,
1167 probe_result.as_ref(),
1168 is_offline,
1169 );
1170
1171 let warning = format!(
1172 "model '{}' not found in catalog, passing through to harness",
1173 name
1174 );
1175
1176 if json {
1177 let mut out = serde_json::json!({
1178 "name": name,
1179 "source": "passthrough",
1180 "model_id": name,
1181 "resolved_model": name,
1182 "provider": guessed_provider,
1183 "harness": harness,
1184 "harness_source": harness_source,
1185 "harness_candidates": harness_candidates,
1186 "description": serde_json::Value::Null,
1187 "warning": warning,
1188 });
1189 add_availability_json_fields(&mut out, Some(&availability));
1190 if let Some(warning) = cache_warning.as_deref() {
1191 out["cache_warning"] = serde_json::json!(warning);
1192 }
1193 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1194 } else {
1195 eprintln!("warning: {}", warning);
1196 let h = harness.as_deref().unwrap_or("—");
1197 println!("Model: {}", name);
1198 println!("Source: passthrough");
1199 println!("Harness: {} ({})", h, harness_source);
1200 if let Some(provider) = guessed_provider {
1201 println!("Provider: {}", provider);
1202 }
1203 if !harness_candidates.is_empty() {
1204 println!("Candidates: {}", harness_candidates.join(", "));
1205 }
1206 }
1207
1208 Ok(0)
1209}
1210
1211fn load_merged_aliases(
1217 ctx: &MarsContext,
1218) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
1219 let mut merged = models::builtin_aliases();
1221
1222 let mars_dir = ctx.project_root.join(".mars");
1224 let merged_path = mars_dir.join("models-merged.json");
1225 if let Ok(content) = std::fs::read_to_string(&merged_path)
1226 && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
1227 {
1228 for (name, alias) in cached {
1229 merged.insert(name, alias);
1230 }
1231 }
1232
1233 if let Ok(config) = crate::config::load(&ctx.project_root) {
1235 for (name, alias) in &config.models {
1236 merged.insert(name.clone(), alias.clone());
1237 }
1238 }
1239
1240 Ok(merged)
1241}
1242
1243fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
1245 let config = match crate::config::load(&ctx.project_root) {
1246 Ok(c) => c,
1247 Err(_) => return Ok("unknown".to_string()),
1248 };
1249
1250 if config.models.contains_key(name) {
1251 return Ok("consumer (mars.toml)".to_string());
1252 }
1253
1254 Ok("dependency".to_string())
1255}
1256
1257fn format_spec(spec: &ModelSpec) -> serde_json::Value {
1258 match spec {
1259 ModelSpec::Pinned { model, provider } => {
1260 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
1261 if let Some(provider) = provider {
1262 out["provider"] = serde_json::json!(provider);
1263 }
1264 out
1265 }
1266 ModelSpec::PinnedWithMatch {
1267 model,
1268 provider,
1269 match_patterns,
1270 exclude_patterns,
1271 } => {
1272 let mut out = serde_json::json!({
1273 "mode": "pinned",
1274 "model": model,
1275 "match": match_patterns,
1276 "exclude": exclude_patterns,
1277 });
1278 if let Some(provider) = provider {
1279 out["provider"] = serde_json::json!(provider);
1280 }
1281 out
1282 }
1283 ModelSpec::AutoResolve {
1284 provider,
1285 match_patterns,
1286 exclude_patterns,
1287 } => {
1288 serde_json::json!({
1289 "mode": "auto-resolve",
1290 "provider": provider,
1291 "match": match_patterns,
1292 "exclude": exclude_patterns,
1293 })
1294 }
1295 }
1296}
1297
1298fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
1299 match spec {
1300 Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
1301 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
1302 None => "unknown",
1303 }
1304}
1305
1306fn harness_source_label(source: &HarnessSource) -> &'static str {
1307 match source {
1308 HarnessSource::Explicit => "explicit",
1309 HarnessSource::AutoDetected => "auto-detected",
1310 HarnessSource::Unavailable => "unavailable",
1311 }
1312}
1313
1314fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
1315 if resolved.harness_source != HarnessSource::Unavailable {
1316 return None;
1317 }
1318 if let Some(h) = &resolved.harness {
1319 Some(format!("Harness '{}' is not installed", h))
1320 } else {
1321 Some(format!(
1322 "No installed harness for provider '{}'. Install one of: {}",
1323 resolved.provider,
1324 resolved.harness_candidates.join(", ")
1325 ))
1326 }
1327}
1328
1329fn stale_warning(reason: &str) -> String {
1330 format!("models cache refresh failed: {reason}; using stale cache")
1331}
1332
1333fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
1334 match outcome {
1335 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
1336 _ => None,
1337 }
1338}
1339
1340fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
1341 diagnostics
1342 .iter()
1343 .map(|diagnostic| {
1344 serde_json::json!({
1345 "level": diagnostic_level_label(diagnostic.level),
1346 "code": diagnostic.code,
1347 "message": diagnostic.message,
1348 "context": diagnostic.context,
1349 })
1350 })
1351 .collect()
1352}
1353
1354fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
1355 let diagnostics = diag.drain();
1356 if diagnostics.is_empty() {
1357 None
1358 } else {
1359 Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
1360 }
1361}
1362
1363fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
1364 for diagnostic in diagnostics {
1365 let label = diagnostic_level_label(diagnostic.level);
1366 eprintln!("{label}: {}", diagnostic.message);
1367 }
1368}
1369
1370fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
1371 let diagnostics = diag.drain();
1372 emit_drained_text_diagnostics(&diagnostics);
1373}
1374
1375fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
1376 match level {
1377 DiagnosticLevel::Error => "error",
1378 DiagnosticLevel::Warning => "warning",
1379 DiagnosticLevel::Info => "info",
1380 }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386 use clap::Parser;
1387 use indexmap::IndexMap;
1388 use tempfile::TempDir;
1389
1390 fn write_mars_toml(temp: &TempDir, contents: &str) {
1391 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
1392 }
1393
1394 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
1395 match result {
1396 Ok(code) => code,
1397 Err(err) => err.exit_code(),
1398 }
1399 }
1400
1401 #[test]
1402 fn list_args_parses_no_refresh_models() {
1403 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
1404 assert!(args.no_refresh_models);
1405 }
1406
1407 #[test]
1408 fn list_args_parses_catalog() {
1409 let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
1410 assert!(args.catalog);
1411 }
1412
1413 #[test]
1414 fn list_all_and_catalog_conflict() {
1415 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
1416 assert!(parsed.is_err());
1417 }
1418
1419 #[test]
1420 fn list_all_and_include_can_combine() {
1421 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
1422 assert!(parsed.is_ok());
1423 }
1424
1425 #[test]
1426 fn list_catalog_and_include_can_combine() {
1427 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
1428 assert!(parsed.is_ok());
1429 }
1430
1431 #[test]
1432 fn resolve_alias_args_parses_no_refresh_models() {
1433 let args =
1434 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
1435 assert!(args.no_refresh_models);
1436 }
1437
1438 #[test]
1439 fn list_no_refresh_without_cache_is_non_zero() {
1440 let temp = TempDir::new().unwrap();
1441 write_mars_toml(&temp, "[settings]\n");
1442 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1443 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
1444
1445 let exit = normalized_exit_code(run(&args, &ctx, false));
1446 assert_ne!(exit, 0);
1447 }
1448
1449 #[test]
1450 fn resolve_no_refresh_without_cache_is_non_zero() {
1451 let temp = TempDir::new().unwrap();
1452 write_mars_toml(
1453 &temp,
1454 r#"[settings]
1455
1456[models.opus]
1457harness = "claude"
1458model = "claude-opus-4-6"
1459"#,
1460 );
1461 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1462 let args =
1463 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
1464
1465 let exit = normalized_exit_code(run(&args, &ctx, false));
1466 assert_ne!(exit, 0);
1467 }
1468
1469 #[test]
1470 fn alias_updates_existing_model_entry() {
1471 let temp = TempDir::new().unwrap();
1472 write_mars_toml(
1473 &temp,
1474 r#"[settings]
1475
1476[models.fast]
1477harness = "claude"
1478model = "claude-3-5-sonnet"
1479description = "Old alias"
1480"#,
1481 );
1482 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1483
1484 let args = AddAliasArgs {
1485 name: "fast".to_string(),
1486 model_id: "gpt-5.3-codex".to_string(),
1487 harness: "codex".to_string(),
1488 description: Some("Updated alias".to_string()),
1489 };
1490
1491 let exit = run_alias(&args, &ctx, false).unwrap();
1492 assert_eq!(exit, 0);
1493
1494 let config = crate::config::load(temp.path()).unwrap();
1495 assert_eq!(config.models.len(), 1);
1496
1497 let alias = config.models.get("fast").unwrap();
1498 assert_eq!(alias.harness.as_deref(), Some("codex"));
1499 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
1500 match &alias.spec {
1501 ModelSpec::Pinned { model, provider } => {
1502 assert_eq!(model, "gpt-5.3-codex");
1503 assert_eq!(provider, &None);
1504 }
1505 _ => panic!("expected pinned alias"),
1506 }
1507 }
1508
1509 fn auto_alias(
1510 provider: &str,
1511 match_patterns: &[&str],
1512 exclude_patterns: &[&str],
1513 ) -> ModelAlias {
1514 ModelAlias {
1515 harness: None,
1516 description: None,
1517 default_effort: None,
1518 autocompact: None,
1519 spec: ModelSpec::AutoResolve {
1520 provider: provider.to_string(),
1521 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1522 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1523 },
1524 }
1525 }
1526
1527 fn pinned_with_match_alias(
1528 model: &str,
1529 provider: &str,
1530 match_patterns: &[&str],
1531 exclude_patterns: &[&str],
1532 ) -> ModelAlias {
1533 ModelAlias {
1534 harness: None,
1535 description: None,
1536 default_effort: None,
1537 autocompact: None,
1538 spec: ModelSpec::PinnedWithMatch {
1539 model: model.to_string(),
1540 provider: Some(provider.to_string()),
1541 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1542 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1543 },
1544 }
1545 }
1546
1547 fn pinned_alias(model: &str) -> ModelAlias {
1548 ModelAlias {
1549 harness: None,
1550 description: None,
1551 default_effort: None,
1552 autocompact: None,
1553 spec: ModelSpec::Pinned {
1554 model: model.to_string(),
1555 provider: None,
1556 },
1557 }
1558 }
1559
1560 fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
1561 ModelAlias {
1562 harness: None,
1563 description: None,
1564 default_effort: None,
1565 autocompact: None,
1566 spec: ModelSpec::Pinned {
1567 model: model.to_string(),
1568 provider: Some(provider.to_string()),
1569 },
1570 }
1571 }
1572
1573 fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
1574 models::CachedModel {
1575 id: id.to_string(),
1576 provider: provider.to_string(),
1577 release_date: release_date.map(|value| value.to_string()),
1578 description: Some(format!("desc-{id}")),
1579 context_window: None,
1580 max_output: None,
1581 }
1582 }
1583
1584 fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
1585 models::ModelsCache {
1586 models,
1587 fetched_at: Some("123".to_string()),
1588 }
1589 }
1590
1591 fn installed(names: &[&str]) -> HashSet<String> {
1592 names.iter().map(|name| (*name).to_string()).collect()
1593 }
1594
1595 #[test]
1596 fn list_all_shows_multiple_per_alias() {
1597 let mut merged = IndexMap::new();
1598 merged.insert(
1599 "opus".to_string(),
1600 auto_alias("Anthropic", &["claude-opus-*"], &[]),
1601 );
1602
1603 let models_cache = cache(vec![
1604 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1605 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
1606 ]);
1607
1608 let installed = installed(&[]);
1609 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1610 assert_eq!(rows.len(), 2);
1611 assert_eq!(rows[0].id, "claude-opus-4-7");
1612 assert_eq!(rows[1].id, "claude-opus-4-6");
1613 }
1614
1615 #[test]
1616 fn list_all_includes_matched_aliases_with_dedup() {
1617 let mut merged = IndexMap::new();
1618 merged.insert(
1619 "opus".to_string(),
1620 auto_alias("Anthropic", &["claude-opus-*"], &[]),
1621 );
1622 merged.insert(
1623 "legacy".to_string(),
1624 auto_alias("Anthropic", &["*4-6"], &[]),
1625 );
1626
1627 let models_cache = cache(vec![cached_model(
1628 "claude-opus-4-6",
1629 "Anthropic",
1630 Some("2026-02-05"),
1631 )]);
1632
1633 let installed = installed(&[]);
1634 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1635 assert_eq!(rows.len(), 1);
1636 assert_eq!(rows[0].id, "claude-opus-4-6");
1637 assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
1638 }
1639
1640 #[test]
1641 fn list_all_includes_pinned_cache_entries() {
1642 let mut merged = IndexMap::new();
1643 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1644
1645 let models_cache = cache(vec![cached_model(
1646 "gpt-5.3-codex",
1647 "OpenAI",
1648 Some("2026-01-01"),
1649 )]);
1650 let installed = installed(&[]);
1651 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1652 assert_eq!(rows.len(), 1);
1653 assert_eq!(rows[0].id, "gpt-5.3-codex");
1654 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1655 }
1656
1657 #[test]
1658 fn list_all_includes_pinned_cache_miss_entries() {
1659 let mut merged = IndexMap::new();
1660 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1661
1662 let models_cache = cache(Vec::new());
1663 let installed = installed(&[]);
1664 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1665 assert_eq!(rows.len(), 1);
1666 assert_eq!(rows[0].id, "gpt-5.3-codex");
1667 assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
1668 assert_eq!(rows[0].release_date, None);
1669 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1670 }
1671
1672 #[test]
1673 fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
1674 let mut merged = IndexMap::new();
1675 merged.insert(
1676 "custom".to_string(),
1677 pinned_alias_with_provider("custom-model-id", "Anthropic"),
1678 );
1679
1680 let models_cache = cache(Vec::new());
1681 let installed = installed(&[]);
1682 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1683 assert_eq!(rows.len(), 1);
1684 assert_eq!(rows[0].id, "custom-model-id");
1685 assert_eq!(rows[0].provider, "Anthropic");
1686 assert_eq!(rows[0].release_date, None);
1687 assert_eq!(rows[0].matched_aliases, vec!["custom"]);
1688 }
1689
1690 #[test]
1691 fn list_all_includes_unavailable_harness_entries() {
1692 let mut merged = IndexMap::new();
1693 merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
1694 let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
1695
1696 let installed = installed(&[]);
1697 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1698 assert_eq!(rows.len(), 1);
1699 assert_eq!(rows[0].harness, None);
1700 assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
1701 assert!(rows[0].harness_candidates.is_empty());
1702 }
1703
1704 #[test]
1705 fn list_catalog_shows_all_cache_sorted() {
1706 let models_cache = cache(vec![
1707 cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
1708 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1709 cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
1710 ]);
1711
1712 let installed = installed(&[]);
1713 let rows = collect_catalog_model_entries(&models_cache, &installed, None, false);
1714 assert_eq!(rows.len(), 3);
1715 assert_eq!(rows[0].id, "claude-opus-4-6");
1716 assert_eq!(rows[1].id, "claude-sonnet-4-5");
1717 assert_eq!(rows[2].id, "gpt-5");
1718 }
1719
1720 #[test]
1721 fn list_all_includes_pinned_with_match_discovery_candidates() {
1722 let mut merged = IndexMap::new();
1723 merged.insert(
1724 "opus".to_string(),
1725 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1726 );
1727 let models_cache = cache(vec![
1728 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1729 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1730 ]);
1731
1732 let installed = installed(&[]);
1733 let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1734 assert_eq!(rows.len(), 2);
1735 assert_eq!(rows[0].id, "claude-opus-4-7");
1736 assert_eq!(rows[1].id, "claude-opus-4-6");
1737 assert_eq!(rows[0].matched_aliases, vec!["opus"]);
1738 assert_eq!(rows[1].matched_aliases, vec!["opus"]);
1739 }
1740
1741 #[test]
1742 fn resolve_pinned_with_match_uses_model_field() {
1743 let mut merged = IndexMap::new();
1744 merged.insert(
1745 "opus".to_string(),
1746 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1747 );
1748 let models_cache = cache(vec![
1749 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1750 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1751 ]);
1752 let mut diag = DiagnosticCollector::new();
1753 let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
1754 assert_eq!(resolved.model_id, "claude-opus-4-6");
1755 assert!(diag.drain().is_empty());
1756 }
1757}