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