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