1#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6use std::collections::HashSet;
7
8use crate::config::routing_settings::ResolvedRoutingSettings;
9use crate::diagnostic::{Diagnostic, DiagnosticCollector, DiagnosticLevel};
10use crate::error::{ConfigError, MarsError};
11use crate::harness::host::{
12 CapabilityCollectionOptions, CapabilitySnapshot, collect_capability_snapshot,
13};
14use crate::models::availability::{AvailabilityStatus, ModelAvailability};
15use crate::models::probes::OpenCodeProbeResult;
16use crate::models::probes::PiProbeResult;
17use crate::models::probes::opencode_cache::{self, CachedProbeOutcome};
18use crate::models::probes::pi_cache;
19use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
20use crate::types::MarsContext;
21
22#[derive(Debug, Parser)]
24pub struct ModelsArgs {
25 #[command(subcommand)]
26 pub command: ModelsCommand,
27}
28
29#[derive(Debug, Subcommand)]
30pub enum ModelsCommand {
31 Refresh,
33 List(ListArgs),
35 Resolve(ResolveAliasArgs),
37 Alias(AddAliasArgs),
39 #[command(name = "__refresh-probe", hide = true)]
40 RefreshProbe(RefreshProbeArgs),
41}
42
43#[derive(Debug, Parser)]
44pub struct ListArgs {
45 #[arg(long, conflicts_with = "catalog", conflicts_with = "unavailable")]
47 all: bool,
48 #[arg(long)]
50 no_refresh_models: bool,
51 #[arg(long, value_delimiter = ',')]
53 include: Option<Vec<String>>,
54 #[arg(long, value_delimiter = ',')]
56 exclude: Option<Vec<String>>,
57 #[arg(long, conflicts_with = "all")]
59 catalog: bool,
60 #[arg(long)]
62 unavailable: bool,
63}
64
65#[derive(Debug, Parser)]
66pub struct ResolveAliasArgs {
67 pub name: String,
69 #[arg(long)]
71 no_refresh_models: bool,
72}
73
74#[derive(Debug, Parser)]
75pub struct RefreshProbeArgs {
76 #[arg(long)]
77 target: String,
78}
79
80#[derive(Debug, Parser)]
81pub struct AddAliasArgs {
82 pub name: String,
84 pub model_id: String,
86 #[arg(long, default_value = "claude")]
88 pub harness: String,
89 #[arg(long)]
91 pub description: Option<String>,
92}
93
94pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
95 match &args.command {
96 ModelsCommand::Refresh => run_refresh(ctx, json),
97 ModelsCommand::List(args) => run_list(args, ctx, json),
98 ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
99 ModelsCommand::Alias(a) => run_alias(a, ctx, json),
100 ModelsCommand::RefreshProbe(a) => run_refresh_probe(a),
101 }
102}
103
104fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
105 ctx.project_root.join(".mars")
106}
107
108fn collect_models_capability_snapshot(no_refresh_models: bool) -> CapabilitySnapshot {
109 let offline = models::is_mars_offline() || no_refresh_models;
110 collect_capability_snapshot(&CapabilityCollectionOptions {
111 offline,
112 allow_probe_refresh: !no_refresh_models,
113 })
114}
115
116fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
117 let mars = mars_dir(ctx);
118 let ttl = models::load_models_cache_ttl(ctx);
119 eprint!("Fetching models catalog... ");
120
121 let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
122 let count = cache.models.len();
123 let cache_warning = cache_warning(&outcome);
124
125 if let Some(warning) = cache_warning.as_deref() {
126 eprintln!("warning: {warning}");
127 } else if !json {
128 eprintln!("done.");
129 }
130
131 if json {
132 let out = serde_json::json!({
133 "status": "ok",
134 "models_count": count,
135 "fetched_at": cache.fetched_at,
136 });
137 let mut out = out;
138 if let Some(warning) = cache_warning.as_deref() {
139 out["cache_warning"] = serde_json::json!(warning);
140 }
141 println!("{}", serde_json::to_string_pretty(&out).unwrap());
142 } else {
143 if cache_warning.is_some() {
144 println!(
145 "Using stale models cache with {} models in .mars/models-cache.json",
146 count
147 );
148 } else {
149 println!("Cached {} models in .mars/models-cache.json", count);
150 }
151 }
152
153 Ok(0)
154}
155
156fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
157 let mars = mars_dir(ctx);
158 let ttl = models::load_models_cache_ttl(ctx);
159 let mode = models::resolve_refresh_mode(args.no_refresh_models);
160 let routing_settings = ResolvedRoutingSettings::from_config(&ctx.project_root);
161 let routing_diagnostics = routing_settings.diagnostic_messages();
162 if !json {
163 emit_routing_settings_warnings(&routing_diagnostics);
164 }
165 let (cache, outcome) = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
166 FreshOrJsonError::Fresh(cache, outcome) => (cache, outcome),
167 FreshOrJsonError::JsonError(error_message) => {
168 let mut out = serde_json::json!({
169 "error": error_message,
170 });
171 add_routing_diagnostics_json(&mut out, &routing_diagnostics);
172 println!("{}", serde_json::to_string_pretty(&out).unwrap());
173 return Ok(1);
174 }
175 };
176 let capability_snapshot = collect_models_capability_snapshot(args.no_refresh_models);
177
178 if args.catalog {
179 return run_list_catalog(ListCatalogInput {
180 cache: &cache,
181 outcome: &outcome,
182 ctx,
183 args,
184 routing_settings: &routing_settings,
185 routing_diagnostics: &routing_diagnostics,
186 capability_snapshot: &capability_snapshot,
187 json,
188 });
189 }
190
191 let merged = load_merged_aliases(ctx)?;
193 let installed = capability_snapshot.installed_harnesses();
194 let is_offline = capability_snapshot.offline;
195 let opencode_probe_result = capability_snapshot.opencode.result().cloned();
196 let pi_probe_result = capability_snapshot.pi.result().cloned();
197 let visibility = effective_visibility(ctx, args);
198 if args.all {
199 let availability_ctx = AvailabilityContext {
200 installed: &installed,
201 opencode_probe_result: opencode_probe_result.as_ref(),
202 pi_probe_result: pi_probe_result.as_ref(),
203 is_offline,
204 routing_settings: &routing_settings,
205 };
206 return run_list_all(
207 &merged,
208 &cache,
209 &outcome,
210 &visibility,
211 availability_ctx,
212 &routing_diagnostics,
213 json,
214 );
215 }
216
217 let cache_warning = cache_warning(&outcome);
218 let mut diag = DiagnosticCollector::new();
219
220 let mut resolved = models::resolve_all_with_probe(
221 &merged,
222 &cache,
223 &mut diag,
224 opencode_probe_result.as_ref(),
225 pi_probe_result.as_ref(),
226 );
227 apply_routing_settings_to_resolved_aliases(
228 &mut resolved,
229 &merged,
230 &installed,
231 opencode_probe_result.as_ref(),
232 pi_probe_result.as_ref(),
233 &routing_settings,
234 );
235 annotate_resolved_availability(
236 &mut resolved,
237 &installed,
238 opencode_probe_result.as_ref(),
239 pi_probe_result.as_ref(),
240 is_offline,
241 );
242 if !args.unavailable {
243 prune_unavailable(&mut resolved);
244 }
245
246 let resolved = models::filter_by_visibility(resolved, &visibility);
248
249 if json {
250 let entries: Vec<serde_json::Value> = resolved
251 .values()
252 .map(|r| {
253 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
254 let mut obj = serde_json::json!({
255 "name": r.name,
256 "harness": r.harness,
257 "harness_source": r.harness_source,
258 "harness_candidates": r.harness_candidates,
259 "provider": r.provider,
260 "mode": mode,
261 "model_id": r.model_id,
262 "resolved_model": r.model_id,
263 "description": r.description,
264 });
265 if let Some(error) = unavailable_harness_error(r) {
266 obj["error"] = serde_json::json!(error);
267 }
268 if let Some(default_effort) = &r.default_effort {
269 obj["default_effort"] = serde_json::json!(default_effort);
270 }
271 if let Some(autocompact) = r.autocompact {
272 obj["autocompact"] = serde_json::json!(autocompact);
273 }
274 if let Some(autocompact_pct) = r.autocompact_pct {
275 obj["autocompact_pct"] = serde_json::json!(autocompact_pct);
276 }
277 if let Some(model) = cache.models.iter().find(|model| model.id == r.model_id) {
278 add_cost_json_fields(&mut obj, model);
279 }
280 add_availability_json_fields(&mut obj, r.availability.as_ref());
281 obj
282 })
283 .collect();
284 let mut out = serde_json::json!({
285 "aliases": entries,
286 "cache_available": cache.fetched_at.is_some(),
287 });
288 add_probe_results_json(
289 &mut out,
290 opencode_probe_result.as_ref(),
291 pi_probe_result.as_ref(),
292 );
293 if let Some(warning) = cache_warning.as_deref() {
294 out["cache_warning"] = serde_json::json!(warning);
295 }
296 if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
297 out["diagnostics"] = diagnostics;
298 }
299 add_routing_diagnostics_json(&mut out, &routing_diagnostics);
300 println!("{}", serde_json::to_string_pretty(&out).unwrap());
301 } else {
302 if let Some(warning) = cache_warning.as_deref() {
303 eprintln!("warning: {warning}");
304 }
305 println!(
307 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
308 "ALIAS", "HARNESS", "MODE", "RESOLVED", "AVAILABILITY", "DESCRIPTION"
309 );
310 for r in resolved.values() {
311 let harness = r.harness.as_deref().unwrap_or("—");
312 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
313 let availability = availability_status_label(r.availability.as_ref());
314 let desc = r.description.clone().unwrap_or_default();
315 println!(
316 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
317 r.name, harness, mode, r.model_id, availability, desc
318 );
319 }
320 emit_text_diagnostics(&mut diag);
321 }
322
323 Ok(0)
324}
325
326#[derive(Debug, Clone)]
327struct ListModelEntry {
328 id: String,
329 provider: String,
330 release_date: Option<String>,
331 harness: Option<String>,
332 harness_source: HarnessSource,
333 harness_candidates: Vec<String>,
334 description: Option<String>,
335 cost_input: Option<f64>,
336 cost_output: Option<f64>,
337 cost_cache_read: Option<f64>,
338 cost_cache_write: Option<f64>,
339 cost_reasoning: Option<f64>,
340 matched_aliases: Vec<String>,
341 availability: Option<ModelAvailability>,
342}
343
344#[derive(Clone, Copy)]
345struct AvailabilityContext<'a> {
346 installed: &'a HashSet<String>,
347 opencode_probe_result: Option<&'a OpenCodeProbeResult>,
348 pi_probe_result: Option<&'a PiProbeResult>,
349 is_offline: bool,
350 routing_settings: &'a ResolvedRoutingSettings,
351}
352
353struct ResolveRuntime<'a> {
354 cache: &'a models::ModelsCache,
355 outcome: &'a models::RefreshOutcome,
356 installed: &'a HashSet<String>,
357 probe_outcome: CachedProbeOutcome,
358 pi_probe_result: Option<&'a PiProbeResult>,
359 routing_settings: &'a ResolvedRoutingSettings,
360}
361
362struct RouteTraceInput<'a> {
363 model_id: &'a str,
364 provider_for_order: &'a str,
365 provider_constraint: Option<&'a str>,
366 installed: &'a HashSet<String>,
367 opencode_probe_result: Option<&'a OpenCodeProbeResult>,
368 pi_probe_result: Option<&'a PiProbeResult>,
369 routing_settings: &'a ResolvedRoutingSettings,
370}
371
372struct ListCatalogInput<'a> {
373 cache: &'a models::ModelsCache,
374 outcome: &'a models::RefreshOutcome,
375 ctx: &'a MarsContext,
376 args: &'a ListArgs,
377 routing_settings: &'a ResolvedRoutingSettings,
378 routing_diagnostics: &'a [String],
379 capability_snapshot: &'a CapabilitySnapshot,
380 json: bool,
381}
382
383struct OutputResolvedInput<'a> {
384 name: &'a str,
385 resolved: &'a models::ResolvedAlias,
386 source: &'a str,
387 route_trace: &'a crate::routing::RoutingTrace,
388 outcome: &'a models::RefreshOutcome,
389 cache_outcome: &'a CachedProbeOutcome,
390 routing_diagnostics: &'a [String],
391 json: bool,
392}
393
394struct OutputPassthroughInput<'a> {
395 name: &'a str,
396 outcome: &'a models::RefreshOutcome,
397 is_offline: bool,
398 installed: &'a HashSet<String>,
399 routing_settings: &'a ResolvedRoutingSettings,
400 cache_error: Option<&'a str>,
401 routing_diagnostics: &'a [String],
402 json: bool,
403}
404
405fn run_list_all(
406 merged: &IndexMap<String, ModelAlias>,
407 cache: &models::ModelsCache,
408 outcome: &models::RefreshOutcome,
409 visibility: &crate::config::ModelVisibility,
410 availability_ctx: AvailabilityContext<'_>,
411 routing_diagnostics: &[String],
412 json: bool,
413) -> Result<i32, MarsError> {
414 let cache_warning = cache_warning(outcome);
415 let models = collect_all_model_entries(merged, cache, availability_ctx);
416 let models = filter_model_entries_by_visibility(models, visibility);
417
418 if json {
419 let entries: Vec<serde_json::Value> = models
420 .into_iter()
421 .map(|model| {
422 let mut obj = serde_json::json!({
423 "id": model.id,
424 "provider": model.provider,
425 "release_date": model.release_date,
426 "harness": model.harness,
427 "harness_source": model.harness_source,
428 "harness_candidates": model.harness_candidates,
429 "description": model.description,
430 "cost_input": model.cost_input,
431 "cost_output": model.cost_output,
432 "cost_cache_read": model.cost_cache_read,
433 "cost_cache_write": model.cost_cache_write,
434 "cost_reasoning": model.cost_reasoning,
435 "matched_aliases": model.matched_aliases,
436 });
437 add_availability_json_fields(&mut obj, model.availability.as_ref());
438 obj
439 })
440 .collect();
441 let mut out = serde_json::json!({
442 "models": entries,
443 "cache_available": cache.fetched_at.is_some(),
444 });
445 add_probe_results_json(
446 &mut out,
447 availability_ctx.opencode_probe_result,
448 availability_ctx.pi_probe_result,
449 );
450 if let Some(warning) = cache_warning.as_deref() {
451 out["cache_warning"] = serde_json::json!(warning);
452 }
453 add_routing_diagnostics_json(&mut out, routing_diagnostics);
454 println!("{}", serde_json::to_string_pretty(&out).unwrap());
455 } else {
456 if let Some(warning) = cache_warning.as_deref() {
457 eprintln!("warning: {warning}");
458 }
459 println!(
460 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
461 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY", "ALIASES"
462 );
463 for model in models {
464 let release = model.release_date.as_deref().unwrap_or("—");
465 let harness = model.harness.as_deref().unwrap_or("—");
466 let availability = availability_status_label(model.availability.as_ref());
467 println!(
468 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
469 model.provider,
470 model.id,
471 release,
472 harness,
473 availability,
474 model.matched_aliases.join(",")
475 );
476 }
477 }
478
479 Ok(0)
480}
481
482fn run_list_catalog(input: ListCatalogInput<'_>) -> Result<i32, MarsError> {
483 let ListCatalogInput {
484 cache,
485 outcome,
486 ctx,
487 args,
488 routing_settings,
489 routing_diagnostics,
490 capability_snapshot,
491 json,
492 } = input;
493 let cache_warning = cache_warning(outcome);
494 let installed = capability_snapshot.installed_harnesses();
495 let is_offline = capability_snapshot.offline || args.no_refresh_models;
496 let probe_result = capability_snapshot.opencode.result().cloned();
497 let pi_probe_result = capability_snapshot.pi.result().cloned();
498 let availability_ctx = AvailabilityContext {
499 installed: &installed,
500 opencode_probe_result: probe_result.as_ref(),
501 pi_probe_result: pi_probe_result.as_ref(),
502 is_offline,
503 routing_settings,
504 };
505 let visibility = effective_visibility(ctx, args);
506 let models = collect_catalog_model_entries(cache, availability_ctx);
507 let models = filter_model_entries_by_visibility(models, &visibility);
508
509 if json {
510 let entries: Vec<serde_json::Value> = models
511 .into_iter()
512 .map(|model| {
513 let mut obj = serde_json::json!({
514 "id": model.id,
515 "provider": model.provider,
516 "release_date": model.release_date,
517 "harness": model.harness,
518 "harness_source": model.harness_source,
519 "harness_candidates": model.harness_candidates,
520 "description": model.description,
521 "cost_input": model.cost_input,
522 "cost_output": model.cost_output,
523 "cost_cache_read": model.cost_cache_read,
524 "cost_cache_write": model.cost_cache_write,
525 "cost_reasoning": model.cost_reasoning,
526 });
527 add_availability_json_fields(&mut obj, model.availability.as_ref());
528 obj
529 })
530 .collect();
531 let mut out = serde_json::json!({
532 "models": entries,
533 "cache_available": cache.fetched_at.is_some(),
534 });
535 add_probe_results_json(&mut out, probe_result.as_ref(), pi_probe_result.as_ref());
536 if let Some(warning) = cache_warning.as_deref() {
537 out["cache_warning"] = serde_json::json!(warning);
538 }
539 add_routing_diagnostics_json(&mut out, routing_diagnostics);
540 println!("{}", serde_json::to_string_pretty(&out).unwrap());
541 } else {
542 if let Some(warning) = cache_warning.as_deref() {
543 eprintln!("warning: {warning}");
544 }
545 println!(
546 "{:<10} {:<34} {:<12} {:<10} {:<12}",
547 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY"
548 );
549 for model in models {
550 let release = model.release_date.as_deref().unwrap_or("—");
551 let harness = model.harness.as_deref().unwrap_or("—");
552 let availability = availability_status_label(model.availability.as_ref());
553 println!(
554 "{:<10} {:<34} {:<12} {:<10} {:<12}",
555 model.provider, model.id, release, harness, availability
556 );
557 }
558 }
559
560 Ok(0)
561}
562
563fn collect_all_model_entries(
564 merged: &IndexMap<String, ModelAlias>,
565 cache: &models::ModelsCache,
566 availability_ctx: AvailabilityContext<'_>,
567) -> Vec<ListModelEntry> {
568 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
569
570 for (alias_name, alias) in merged {
571 match &alias.spec {
572 ModelSpec::AutoResolve {
573 provider,
574 match_patterns,
575 exclude_patterns,
576 } => {
577 for matched in
578 models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
579 {
580 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
581 }
582 }
583 ModelSpec::Pinned {
584 model, provider, ..
585 } => {
586 if let Some(matched) = cache
587 .models
588 .iter()
589 .find(|cache_model| cache_model.id == *model)
590 {
591 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
592 } else {
593 append_pinned_alias_match(
594 &mut by_model_id,
595 model,
596 provider.as_deref(),
597 alias.description.as_deref(),
598 availability_ctx,
599 alias_name,
600 );
601 }
602 }
603 ModelSpec::PinnedWithMatch {
604 model,
605 provider,
606 match_patterns,
607 exclude_patterns,
608 } => {
609 if let Some(matched) = cache
610 .models
611 .iter()
612 .find(|cache_model| cache_model.id == *model)
613 {
614 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
615 } else {
616 append_pinned_alias_match(
617 &mut by_model_id,
618 model,
619 provider.as_deref(),
620 alias.description.as_deref(),
621 availability_ctx,
622 alias_name,
623 );
624 }
625
626 let provider_for_discovery = provider
627 .as_deref()
628 .or_else(|| models::infer_provider_from_model_id(model));
629 if let Some(provider_for_discovery) = provider_for_discovery {
630 for matched in models::auto_resolve_all(
631 provider_for_discovery,
632 match_patterns,
633 exclude_patterns,
634 cache,
635 ) {
636 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
637 }
638 }
639 }
640 }
641 }
642
643 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
644 sort_list_model_entries(&mut out);
645 out
646}
647
648fn collect_catalog_model_entries(
649 cache: &models::ModelsCache,
650 availability_ctx: AvailabilityContext<'_>,
651) -> Vec<ListModelEntry> {
652 let mut out: Vec<ListModelEntry> = cache
653 .models
654 .iter()
655 .map(|model| model_entry_for_cached(model, availability_ctx))
656 .collect();
657 sort_list_model_entries(&mut out);
658 out
659}
660
661fn append_alias_match(
662 by_model_id: &mut IndexMap<String, ListModelEntry>,
663 model: &models::CachedModel,
664 availability_ctx: AvailabilityContext<'_>,
665 alias_name: &str,
666) {
667 let entry = by_model_id
668 .entry(model.id.clone())
669 .or_insert_with(|| model_entry_for_cached(model, availability_ctx));
670
671 append_alias_name(entry, alias_name);
672}
673
674fn append_pinned_alias_match(
675 by_model_id: &mut IndexMap<String, ListModelEntry>,
676 model_id: &str,
677 provider: Option<&str>,
678 description: Option<&str>,
679 availability_ctx: AvailabilityContext<'_>,
680 alias_name: &str,
681) {
682 let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
683 model_entry_for_pinned(model_id, provider, description, availability_ctx)
684 });
685
686 append_alias_name(entry, alias_name);
687}
688
689fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
690 if !entry
691 .matched_aliases
692 .iter()
693 .any(|existing| existing == alias_name)
694 {
695 entry.matched_aliases.push(alias_name.to_string());
696 }
697}
698
699fn model_entry_for_cached(
700 model: &models::CachedModel,
701 availability_ctx: AvailabilityContext<'_>,
702) -> ListModelEntry {
703 let (harness, harness_source) = resolve_harness_with_routing(
704 &model.provider,
705 &model.id,
706 availability_ctx.installed,
707 availability_ctx.opencode_probe_result,
708 availability_ctx.pi_probe_result,
709 availability_ctx.routing_settings,
710 );
711
712 ListModelEntry {
713 id: model.id.clone(),
714 provider: model.provider.clone(),
715 release_date: model.release_date.clone(),
716 harness,
717 harness_source,
718 harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
719 description: model.description.clone(),
720 cost_input: model.cost_input,
721 cost_output: model.cost_output,
722 cost_cache_read: model.cost_cache_read,
723 cost_cache_write: model.cost_cache_write,
724 cost_reasoning: model.cost_reasoning,
725 matched_aliases: Vec::new(),
726 availability: Some(models::availability::classify_model(
727 &model.id,
728 &model.provider,
729 availability_ctx.installed,
730 availability_ctx.opencode_probe_result,
731 availability_ctx.pi_probe_result,
732 availability_ctx.is_offline,
733 )),
734 }
735}
736
737fn model_entry_for_pinned(
738 model_id: &str,
739 provider: Option<&str>,
740 description: Option<&str>,
741 availability_ctx: AvailabilityContext<'_>,
742) -> ListModelEntry {
743 let provider = provider
744 .map(str::to_string)
745 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
746 .unwrap_or_else(|| "unknown".to_string());
747 let (harness, harness_source) = resolve_harness_with_routing(
748 &provider,
749 model_id,
750 availability_ctx.installed,
751 availability_ctx.opencode_probe_result,
752 availability_ctx.pi_probe_result,
753 availability_ctx.routing_settings,
754 );
755
756 ListModelEntry {
757 id: model_id.to_string(),
758 provider: provider.clone(),
759 release_date: None,
760 harness,
761 harness_source,
762 harness_candidates: models::harness::harness_candidates_for_provider(&provider),
763 description: description.map(str::to_string),
764 cost_input: None,
765 cost_output: None,
766 cost_cache_read: None,
767 cost_cache_write: None,
768 cost_reasoning: None,
769 matched_aliases: Vec::new(),
770 availability: Some(models::availability::classify_model(
771 model_id,
772 &provider,
773 availability_ctx.installed,
774 availability_ctx.opencode_probe_result,
775 availability_ctx.pi_probe_result,
776 availability_ctx.is_offline,
777 )),
778 }
779}
780
781fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
782 entries.sort_by(|a, b| {
783 a.provider
784 .to_ascii_lowercase()
785 .cmp(&b.provider.to_ascii_lowercase())
786 .then_with(|| {
787 b.release_date
788 .as_deref()
789 .unwrap_or("")
790 .cmp(a.release_date.as_deref().unwrap_or(""))
791 })
792 .then_with(|| a.id.cmp(&b.id))
793 });
794}
795
796fn resolve_harness_with_routing(
797 provider: &str,
798 model_id: &str,
799 installed: &HashSet<String>,
800 opencode_probe_result: Option<&OpenCodeProbeResult>,
801 pi_probe_result: Option<&PiProbeResult>,
802 routing_settings: &ResolvedRoutingSettings,
803) -> (Option<String>, HarnessSource) {
804 let provider_order = routing_settings.provider_order_names();
805 let harness_order = routing_settings.harness_order_names();
806 let default_harness = routing_settings.default_harness_name();
807 let linked_harnesses = routing_settings.linked_harness_names();
808 let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
809 model_id,
810 provider_for_order: Some(provider),
811 provider_constraint: None,
812 settings_provider_order: provider_order.as_deref(),
813 settings_harness_order: harness_order.as_deref(),
814 config_default_harness: default_harness.as_deref(),
815 installed_harnesses: installed,
816 linked_harnesses: (!linked_harnesses.is_empty()).then_some(linked_harnesses.as_slice()),
817 opencode_probe_result,
818 pi_probe_result,
819 });
820
821 match crate::routing::acceptance::accept_route(
822 &trace,
823 installed,
824 crate::routing::acceptance::MatchPolicy::InstalledOnly,
825 ) {
826 Ok(()) => (
827 Some(trace.selected_harness().to_string()),
828 HarnessSource::AutoDetected,
829 ),
830 Err(_) => (None, HarnessSource::Unavailable),
831 }
832}
833
834fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
835 match &alias.spec {
836 ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
837 provider.clone()
838 }
839 ModelSpec::AutoResolve { provider, .. } => Some(provider.clone()),
840 }
841 .map(|provider| provider.trim().to_ascii_lowercase())
842}
843
844fn route_trace_for_resolved_model(input: &RouteTraceInput<'_>) -> crate::routing::RoutingTrace {
845 let provider_order = input.routing_settings.provider_order_names();
846 let harness_order = input.routing_settings.harness_order_names();
847 let default_harness = input.routing_settings.default_harness_name();
848 let linked_harnesses = input.routing_settings.linked_harness_names();
849 crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
850 model_id: input.model_id,
851 provider_for_order: Some(input.provider_for_order),
852 provider_constraint: input.provider_constraint,
853 settings_provider_order: provider_order.as_deref(),
854 settings_harness_order: harness_order.as_deref(),
855 config_default_harness: default_harness.as_deref(),
856 installed_harnesses: input.installed,
857 linked_harnesses: (!linked_harnesses.is_empty()).then_some(linked_harnesses.as_slice()),
858 opencode_probe_result: input.opencode_probe_result,
859 pi_probe_result: input.pi_probe_result,
860 })
861}
862
863fn route_trace_for_fixed_harness(
864 input: &RouteTraceInput<'_>,
865 fixed_harness: &str,
866 source: crate::routing::RouteSource,
867) -> crate::routing::RoutingTrace {
868 let provider_order = input.routing_settings.provider_order_names();
869 let harness_order = input.routing_settings.harness_order_names();
870 let default_harness = input.routing_settings.default_harness_name();
871 let linked_harnesses = input.routing_settings.linked_harness_names();
872 let provider_for_order = crate::routing::provider_for_order_for_fixed_harness(
873 Some(input.provider_for_order),
874 fixed_harness,
875 );
876 let fixed_input = crate::routing::RoutingInput {
877 model_id: input.model_id,
878 provider_for_order,
879 provider_constraint: input.provider_constraint,
880 settings_provider_order: provider_order.as_deref(),
881 settings_harness_order: harness_order.as_deref(),
882 config_default_harness: default_harness.as_deref(),
883 installed_harnesses: input.installed,
884 linked_harnesses: (!linked_harnesses.is_empty()).then_some(linked_harnesses.as_slice()),
885 opencode_probe_result: input.opencode_probe_result,
886 pi_probe_result: input.pi_probe_result,
887 };
888 let assessment = crate::routing::evaluate_fixed_harness(&fixed_input, fixed_harness);
889 crate::routing::trace_for_fixed_harness(source, fixed_harness, assessment, Vec::new())
890}
891
892fn effective_visibility(ctx: &MarsContext, args: &ListArgs) -> crate::config::ModelVisibility {
893 if args.include.is_some() || args.exclude.is_some() {
894 return crate::config::ModelVisibility {
895 include: args.include.clone(),
896 exclude: args.exclude.clone(),
897 };
898 }
899
900 crate::config::load(&ctx.project_root)
901 .map(|config| config.settings.model_visibility)
902 .unwrap_or_default()
903}
904
905fn apply_routing_settings_to_resolved_aliases(
906 resolved: &mut IndexMap<String, models::ResolvedAlias>,
907 aliases: &IndexMap<String, ModelAlias>,
908 installed: &HashSet<String>,
909 opencode_probe_result: Option<&OpenCodeProbeResult>,
910 pi_probe_result: Option<&PiProbeResult>,
911 routing_settings: &ResolvedRoutingSettings,
912) {
913 for alias in resolved.values_mut() {
914 let has_explicit_harness = aliases
915 .get(&alias.name)
916 .is_some_and(|source_alias| source_alias.harness.is_some());
917 if has_explicit_harness {
918 continue;
919 }
920 apply_routing_settings_to_resolved_alias(
921 alias,
922 installed,
923 opencode_probe_result,
924 pi_probe_result,
925 routing_settings,
926 );
927 }
928}
929
930fn apply_routing_settings_to_resolved_alias(
931 alias: &mut models::ResolvedAlias,
932 installed: &HashSet<String>,
933 opencode_probe_result: Option<&OpenCodeProbeResult>,
934 pi_probe_result: Option<&PiProbeResult>,
935 routing_settings: &ResolvedRoutingSettings,
936) {
937 let (harness, harness_source) = resolve_harness_with_routing(
938 &alias.provider,
939 &alias.model_id,
940 installed,
941 opencode_probe_result,
942 pi_probe_result,
943 routing_settings,
944 );
945 alias.harness = harness;
946 alias.harness_source = harness_source;
947}
948
949fn annotate_resolved_availability(
950 resolved: &mut IndexMap<String, models::ResolvedAlias>,
951 installed: &HashSet<String>,
952 opencode_probe_result: Option<&OpenCodeProbeResult>,
953 pi_probe_result: Option<&PiProbeResult>,
954 is_offline: bool,
955) {
956 for alias in resolved.values_mut() {
957 alias.availability = Some(models::availability::classify_model(
958 &alias.model_id,
959 &alias.provider,
960 installed,
961 opencode_probe_result,
962 pi_probe_result,
963 is_offline,
964 ));
965 }
966}
967
968fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
969 resolved.retain(|_, alias| {
970 alias
971 .availability
972 .as_ref()
973 .map(|availability| availability.status != AvailabilityStatus::Unavailable)
974 .unwrap_or(true)
975 });
976}
977
978fn filter_model_entries_by_visibility(
979 entries: Vec<ListModelEntry>,
980 visibility: &crate::config::ModelVisibility,
981) -> Vec<ListModelEntry> {
982 if visibility.include.is_none() && visibility.exclude.is_none() {
983 return entries;
984 }
985
986 entries
987 .into_iter()
988 .filter(|entry| {
989 let paths = entry
990 .availability
991 .as_ref()
992 .map(|availability| availability.runnable_paths.as_slice())
993 .unwrap_or(&[]);
994 let included = visibility.include.as_ref().is_none_or(|includes| {
995 includes.iter().any(|pattern| {
996 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
997 })
998 });
999 let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
1000 excludes.iter().any(|pattern| {
1001 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1002 })
1003 });
1004 included && !excluded
1005 })
1006 .collect()
1007}
1008
1009fn add_availability_json_fields(
1010 obj: &mut serde_json::Value,
1011 availability: Option<&ModelAvailability>,
1012) {
1013 if let Some(availability) = availability {
1014 obj["availability"] = serde_json::json!(availability.status);
1015 obj["availability_source"] = serde_json::json!(availability.source);
1016 obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
1017 }
1018}
1019
1020fn add_cost_json_fields(obj: &mut serde_json::Value, model: &models::CachedModel) {
1021 obj["cost_input"] = serde_json::json!(model.cost_input);
1022 obj["cost_output"] = serde_json::json!(model.cost_output);
1023 obj["cost_cache_read"] = serde_json::json!(model.cost_cache_read);
1024 obj["cost_cache_write"] = serde_json::json!(model.cost_cache_write);
1025 obj["cost_reasoning"] = serde_json::json!(model.cost_reasoning);
1026}
1027
1028fn add_probe_results_json(
1029 out: &mut serde_json::Value,
1030 probe_result: Option<&OpenCodeProbeResult>,
1031 pi_probe_result: Option<&PiProbeResult>,
1032) {
1033 if let Some(probe) = probe_result {
1034 out["probe_results"] = serde_json::json!({
1035 "opencode": {
1036 "success": probe.model_probe_success,
1037 "models_found": probe.model_slugs.len(),
1038 }
1039 });
1040 }
1041 if let Some(probe) = pi_probe_result {
1042 if out.get("probe_results").is_none() {
1043 out["probe_results"] = serde_json::json!({});
1044 }
1045 out["probe_results"]["pi"] = serde_json::json!({
1046 "compatible": probe.compatible,
1047 "version": probe.version,
1048 "missing_surface_tokens": probe.help_surface_tokens_missing,
1049 });
1050 }
1051}
1052
1053fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
1054 match availability.map(|value| value.status) {
1055 Some(AvailabilityStatus::Runnable) => "runnable",
1056 Some(AvailabilityStatus::Unavailable) => "unavailable",
1057 Some(AvailabilityStatus::Unknown) => "unknown",
1058 None => "unknown",
1059 }
1060}
1061
1062fn annotate_one_availability(
1063 resolved: &mut models::ResolvedAlias,
1064 args: &ResolveAliasArgs,
1065 installed: &HashSet<String>,
1066 opencode_probe_result: Option<&OpenCodeProbeResult>,
1067 pi_probe_result: Option<&PiProbeResult>,
1068) {
1069 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1070 resolved.availability = Some(models::availability::classify_model(
1071 &resolved.model_id,
1072 &resolved.provider,
1073 installed,
1074 opencode_probe_result,
1075 pi_probe_result,
1076 is_offline,
1077 ));
1078}
1079
1080fn print_availability_text(availability: Option<&ModelAvailability>) {
1081 if let Some(availability) = availability {
1082 println!(
1083 "Availability: {} ({:?})",
1084 availability_status_label(Some(availability)),
1085 availability.source
1086 );
1087 for (idx, path) in availability.runnable_paths.iter().enumerate() {
1088 let label = if idx == 0 {
1089 "Runnable via:"
1090 } else {
1091 " "
1092 };
1093 println!("{label} {} -> {}", path.harness, path.harness_model_id);
1094 }
1095 }
1096}
1097
1098fn add_route_json_fields(out: &mut serde_json::Value, trace: &crate::routing::RoutingTrace) {
1099 let report = trace.to_report();
1100 out["route"] = serde_json::json!(report.compact_summary());
1101 out["route_trace"] = serde_json::json!(report);
1102}
1103
1104fn print_route_text(trace: &crate::routing::RoutingTrace) {
1105 let report = trace.to_report();
1106 println!(
1107 "Route: {} ({}, {}, {})",
1108 trace.selected_harness(),
1109 trace.source.label(),
1110 trace.selected_selection_kind().label(),
1111 trace.selected_match_evidence().label()
1112 );
1113 if !report.candidates_tried.is_empty() {
1114 println!("Tried: {}", report.candidates_tried.join(", "));
1115 }
1116 for assessment in report.assessments {
1117 if let Some(skip_reason) = assessment.skip_reason {
1118 println!("Skip: {} ({})", assessment.harness, skip_reason);
1119 }
1120 }
1121}
1122
1123fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1124 let merged = load_merged_aliases(ctx)?;
1125 let mars = mars_dir(ctx);
1126 let ttl = models::load_models_cache_ttl(ctx);
1127 let mode = models::resolve_refresh_mode(args.no_refresh_models);
1128 let routing_settings = ResolvedRoutingSettings::from_config(&ctx.project_root);
1129 let routing_diagnostics = routing_settings.diagnostic_messages();
1130 if !json {
1131 emit_routing_settings_warnings(&routing_diagnostics);
1132 }
1133
1134 let mut cache_error = None;
1136 let cache_result = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
1137 FreshOrJsonError::Fresh(cache, outcome) => Some((cache, outcome)),
1138 FreshOrJsonError::JsonError(error_message) => {
1139 cache_error = Some(error_message);
1140 None
1141 }
1142 };
1143 let capability_snapshot = collect_models_capability_snapshot(args.no_refresh_models);
1144 let installed = capability_snapshot.installed_harnesses();
1145 let cache_outcome = capability_snapshot.opencode.clone();
1146 let probe_result = cache_outcome.result().cloned();
1147 let pi_probe_result = capability_snapshot.pi.result().cloned();
1148
1149 if let Some(alias) = merged.get(&args.name) {
1151 if cache_result.is_none() && matches!(alias.spec, ModelSpec::AutoResolve { .. }) {
1152 return run_auto_resolve_alias_cache_unavailable(
1153 AutoResolveAliasCacheUnavailableInput {
1154 name: &args.name,
1155 alias,
1156 ctx,
1157 cache_error: cache_error.as_deref(),
1158 routing_diagnostics: &routing_diagnostics,
1159 json,
1160 },
1161 );
1162 }
1163
1164 let fallback_cache = models::ModelsCache {
1165 models: Vec::new(),
1166 fetched_at: None,
1167 };
1168 let fallback_outcome = models::RefreshOutcome::Offline;
1169 let (cache, outcome) = cache_result
1170 .as_ref()
1171 .map(|(cache, outcome)| (cache, outcome))
1172 .unwrap_or((&fallback_cache, &fallback_outcome));
1173
1174 let runtime = ResolveRuntime {
1175 cache,
1176 outcome,
1177 installed: &installed,
1178 probe_outcome: cache_outcome.clone(),
1179 pi_probe_result: pi_probe_result.as_ref(),
1180 routing_settings: &routing_settings,
1181 };
1182 return run_resolve_exact_alias(
1183 args,
1184 alias,
1185 &merged,
1186 ctx,
1187 runtime,
1188 &routing_diagnostics,
1189 json,
1190 );
1191 }
1192
1193 if let Some((cache, outcome)) = &cache_result
1195 && let Some(mut resolved) = models::resolve_with_alias_prefix_with_probe(
1196 &args.name,
1197 &merged,
1198 cache,
1199 probe_result.as_ref(),
1200 pi_probe_result.as_ref(),
1201 )
1202 {
1203 apply_routing_settings_to_resolved_alias(
1204 &mut resolved,
1205 &installed,
1206 probe_result.as_ref(),
1207 pi_probe_result.as_ref(),
1208 &routing_settings,
1209 );
1210 annotate_one_availability(
1211 &mut resolved,
1212 args,
1213 &installed,
1214 probe_result.as_ref(),
1215 pi_probe_result.as_ref(),
1216 );
1217 let route_input = RouteTraceInput {
1218 model_id: &resolved.model_id,
1219 provider_for_order: &resolved.provider,
1220 provider_constraint: None,
1221 installed: &installed,
1222 opencode_probe_result: probe_result.as_ref(),
1223 pi_probe_result: pi_probe_result.as_ref(),
1224 routing_settings: &routing_settings,
1225 };
1226 let route_trace = route_trace_for_resolved_model(&route_input);
1227 return run_output_resolved(OutputResolvedInput {
1228 name: &args.name,
1229 resolved: &resolved,
1230 source: "alias_prefix",
1231 route_trace: &route_trace,
1232 outcome,
1233 cache_outcome: &cache_outcome,
1234 routing_diagnostics: &routing_diagnostics,
1235 json,
1236 });
1237 }
1238
1239 let outcome = cache_result
1241 .as_ref()
1242 .map(|(_, o)| o.clone())
1243 .unwrap_or(models::RefreshOutcome::Offline);
1244 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1245 run_output_passthrough(OutputPassthroughInput {
1246 name: &args.name,
1247 outcome: &outcome,
1248 is_offline,
1249 installed: &installed,
1250 routing_settings: &routing_settings,
1251 cache_error: cache_error.as_deref(),
1252 routing_diagnostics: &routing_diagnostics,
1253 json,
1254 })
1255}
1256
1257fn run_refresh_probe(args: &RefreshProbeArgs) -> Result<i32, MarsError> {
1258 match args.target.as_str() {
1259 "opencode" => opencode_cache::run_refresh_probe_command(),
1260 "pi" => pi_cache::run_refresh_probe_command(),
1261 _ => Ok(1),
1262 }
1263}
1264
1265fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1266 let normalized_harness =
1267 models::harness::normalize_harness_name(&args.harness).ok_or_else(|| {
1268 MarsError::Config(ConfigError::Invalid {
1269 message: format!(
1270 "invalid harness '{}'; valid harnesses: {}",
1271 args.harness,
1272 models::harness::VALID_HARNESSES.join(", ")
1273 ),
1274 })
1275 })?;
1276 let mut config = crate::config::load(&ctx.project_root)?;
1277 config.models.insert(
1278 args.name.clone(),
1279 ModelAlias {
1280 harness: Some(normalized_harness.clone()),
1281 description: args.description.clone(),
1282 default_effort: None,
1283 autocompact: None,
1284 autocompact_pct: None,
1285 spec: ModelSpec::Pinned {
1286 model: args.model_id.clone(),
1287 provider: None,
1288 },
1289 },
1290 );
1291 crate::config::save(&ctx.project_root, &config)?;
1292
1293 if json {
1294 println!(
1295 "{}",
1296 serde_json::to_string_pretty(&serde_json::json!({
1297 "status": "ok",
1298 "alias": args.name,
1299 "model": args.model_id,
1300 "harness": normalized_harness,
1301 }))
1302 .unwrap()
1303 );
1304 } else {
1305 println!(
1306 "Added alias `{}` → {} (harness: {})",
1307 args.name, args.model_id, normalized_harness
1308 );
1309 }
1310
1311 Ok(0)
1312}
1313
1314enum FreshOrJsonError {
1315 Fresh(models::ModelsCache, models::RefreshOutcome),
1316 JsonError(String),
1317}
1318
1319fn ensure_fresh_or_json_error(
1320 mars: &std::path::Path,
1321 ttl: u32,
1322 mode: models::RefreshMode,
1323 json: bool,
1324) -> Result<FreshOrJsonError, MarsError> {
1325 match models::ensure_fresh(mars, ttl, mode) {
1326 Ok((cache, outcome)) => Ok(FreshOrJsonError::Fresh(cache, outcome)),
1327 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
1328 Ok(FreshOrJsonError::JsonError(format!("{err}")))
1329 }
1330 Err(err) => Err(err),
1331 }
1332}
1333
1334fn run_resolve_exact_alias(
1335 args: &ResolveAliasArgs,
1336 alias: &ModelAlias,
1337 merged: &IndexMap<String, ModelAlias>,
1338 ctx: &MarsContext,
1339 runtime: ResolveRuntime<'_>,
1340 routing_diagnostics: &[String],
1341 json: bool,
1342) -> Result<i32, MarsError> {
1343 let cache_warning = cache_warning(runtime.outcome);
1344 if let Some(warning) = cache_warning.as_deref()
1345 && !json
1346 {
1347 eprintln!("warning: {warning}");
1348 }
1349
1350 let name = &args.name;
1351 let source = determine_source(name, ctx)?;
1352 let mut diag = DiagnosticCollector::new();
1353 let mut resolved_entry = models::resolve_one_with_probe(
1354 name,
1355 merged,
1356 runtime.cache,
1357 &mut diag,
1358 runtime.probe_outcome.result(),
1359 runtime.pi_probe_result,
1360 );
1361 let mut route_trace = None;
1362 let mut fixed_harness_route_rejection = None;
1363 if let Some(r) = resolved_entry.as_mut() {
1364 if alias.harness.is_none() {
1365 apply_routing_settings_to_resolved_alias(
1366 r,
1367 runtime.installed,
1368 runtime.probe_outcome.result(),
1369 runtime.pi_probe_result,
1370 runtime.routing_settings,
1371 );
1372 }
1373 let provider_constraint = provider_constraint_for_alias(alias);
1374 let route_input = RouteTraceInput {
1375 model_id: &r.model_id,
1376 provider_for_order: &r.provider,
1377 provider_constraint: provider_constraint.as_deref(),
1378 installed: runtime.installed,
1379 opencode_probe_result: runtime.probe_outcome.result(),
1380 pi_probe_result: runtime.pi_probe_result,
1381 routing_settings: runtime.routing_settings,
1382 };
1383 route_trace = Some(if let Some(fixed_harness) = alias.harness.as_deref() {
1384 let fixed_trace = route_trace_for_fixed_harness(
1385 &route_input,
1386 fixed_harness,
1387 crate::routing::RouteSource::Alias,
1388 );
1389 let assessed = fixed_trace
1390 .assessments
1391 .iter()
1392 .find(|assessment| assessment.harness == fixed_harness)
1393 .or_else(|| fixed_trace.assessments.first());
1394 fixed_harness_route_rejection = match assessed {
1395 Some(assessment) => crate::routing::acceptance::accept_assessment(assessment).err(),
1396 None => Some(
1397 crate::routing::acceptance::RejectionReason::AssessmentFailed {
1398 harness: fixed_harness.to_string(),
1399 skip_reason: Some("missing_assessment".to_string()),
1400 },
1401 ),
1402 };
1403 fixed_trace
1404 } else {
1405 route_trace_for_resolved_model(&route_input)
1406 });
1407 annotate_one_availability(
1408 r,
1409 args,
1410 runtime.installed,
1411 runtime.probe_outcome.result(),
1412 runtime.pi_probe_result,
1413 );
1414 }
1415 let diagnostics = diag.drain();
1416
1417 if let Some(rejection_reason) = fixed_harness_route_rejection {
1418 let trace = route_trace
1419 .as_ref()
1420 .expect("fixed harness route trace exists");
1421 let Some(resolved) = resolved_entry.as_ref() else {
1422 return Ok(1);
1423 };
1424 return run_resolve_fixed_harness_failure(ResolveFixedHarnessFailureInput {
1425 name,
1426 source: source.as_str(),
1427 resolved,
1428 trace,
1429 cache_warning: cache_warning.as_deref(),
1430 diagnostics: &diagnostics,
1431 rejection_reason: &rejection_reason,
1432 routing_diagnostics,
1433 json,
1434 });
1435 }
1436
1437 if json {
1438 if let Some(r) = resolved_entry.as_ref() {
1439 let mut out = serde_json::json!({
1440 "name": r.name,
1441 "source": source,
1442 "provider": r.provider,
1443 "harness": r.harness,
1444 "harness_source": r.harness_source,
1445 "harness_candidates": r.harness_candidates,
1446 "model_id": r.model_id,
1447 "resolved_model": r.model_id,
1448 "spec": format_spec(&alias.spec),
1449 "description": r.description,
1450 });
1451 out["probe_cache"] = serde_json::json!(runtime.probe_outcome.cache_status());
1452 if let Some(error) = unavailable_harness_error(r) {
1453 out["error"] = serde_json::json!(error);
1454 }
1455 if let Some(default_effort) = &r.default_effort {
1456 out["default_effort"] = serde_json::json!(default_effort);
1457 }
1458 if let Some(autocompact) = r.autocompact {
1459 out["autocompact"] = serde_json::json!(autocompact);
1460 }
1461 if let Some(autocompact_pct) = r.autocompact_pct {
1462 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
1463 }
1464 add_availability_json_fields(&mut out, r.availability.as_ref());
1465 if let Some(warning) = cache_warning.as_deref() {
1466 out["cache_warning"] = serde_json::json!(warning);
1467 }
1468 if !diagnostics.is_empty() {
1469 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
1470 }
1471 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1472 if let Some(trace) = route_trace.as_ref() {
1473 add_route_json_fields(&mut out, trace);
1474 }
1475 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1476 } else {
1477 let mut out = serde_json::json!({
1478 "error": format!("alias `{}` did not resolve to a model ID", name),
1479 });
1480 if let Some(warning) = cache_warning.as_deref() {
1481 out["cache_warning"] = serde_json::json!(warning);
1482 }
1483 if !diagnostics.is_empty() {
1484 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
1485 }
1486 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1487 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1488 return Ok(1);
1489 }
1490 } else {
1491 if matches!(runtime.probe_outcome, CachedProbeOutcome::Stale(_)) {
1492 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
1493 }
1494 let Some(r) = resolved_entry.as_ref() else {
1495 eprintln!("error: alias `{}` did not resolve to a model ID", name);
1496 return Ok(1);
1497 };
1498 let harness = r.harness.as_deref().unwrap_or("—");
1499 println!("Alias: {}", name);
1500 println!("Source: {}", source);
1501 println!(
1502 "Harness: {} ({})",
1503 harness,
1504 harness_source_label(&r.harness_source)
1505 );
1506 println!("Provider: {}", r.provider);
1507 match &alias.spec {
1508 ModelSpec::Pinned { model, provider: _ } => {
1509 println!("Mode: pinned");
1510 println!("Model: {}", model);
1511 }
1512 ModelSpec::PinnedWithMatch {
1513 model,
1514 provider: _,
1515 match_patterns,
1516 exclude_patterns,
1517 } => {
1518 println!("Mode: pinned");
1519 println!("Model: {}", model);
1520 println!("Match: {}", match_patterns.join(", "));
1521 if !exclude_patterns.is_empty() {
1522 println!("Exclude: {}", exclude_patterns.join(", "));
1523 }
1524 println!("Resolved: {}", r.model_id);
1525 }
1526 ModelSpec::AutoResolve {
1527 provider: _,
1528 match_patterns,
1529 exclude_patterns,
1530 } => {
1531 println!("Mode: auto-resolve");
1532 println!("Match: {}", match_patterns.join(", "));
1533 if !exclude_patterns.is_empty() {
1534 println!("Exclude: {}", exclude_patterns.join(", "));
1535 }
1536 println!("Resolved: {}", r.model_id);
1537 }
1538 }
1539 if let Some(error) = unavailable_harness_error(r) {
1540 println!("Error: {}", error);
1541 }
1542 print_availability_text(r.availability.as_ref());
1543 if let Some(desc) = &r.description {
1544 println!("Desc: {}", desc);
1545 }
1546 if let Some(trace) = route_trace.as_ref() {
1547 print_route_text(trace);
1548 }
1549 emit_drained_text_diagnostics(&diagnostics);
1550 }
1551
1552 Ok(0)
1553}
1554
1555struct ResolveFixedHarnessFailureInput<'a> {
1556 name: &'a str,
1557 source: &'a str,
1558 resolved: &'a models::ResolvedAlias,
1559 trace: &'a crate::routing::RoutingTrace,
1560 cache_warning: Option<&'a str>,
1561 diagnostics: &'a [Diagnostic],
1562 rejection_reason: &'a crate::routing::acceptance::RejectionReason,
1563 routing_diagnostics: &'a [String],
1564 json: bool,
1565}
1566
1567struct AutoResolveAliasCacheUnavailableInput<'a> {
1568 name: &'a str,
1569 alias: &'a ModelAlias,
1570 ctx: &'a MarsContext,
1571 cache_error: Option<&'a str>,
1572 routing_diagnostics: &'a [String],
1573 json: bool,
1574}
1575
1576fn run_auto_resolve_alias_cache_unavailable(
1577 input: AutoResolveAliasCacheUnavailableInput<'_>,
1578) -> Result<i32, MarsError> {
1579 let AutoResolveAliasCacheUnavailableInput {
1580 name,
1581 alias,
1582 ctx,
1583 cache_error,
1584 routing_diagnostics,
1585 json,
1586 } = input;
1587 let source = determine_source(name, ctx)?;
1588 let detail = cache_error.unwrap_or("models cache unavailable");
1589 let error = format!(
1590 "alias `{name}` requires models cache for auto-resolve, but cache is unavailable ({detail})"
1591 );
1592
1593 if json {
1594 let mut out = serde_json::json!({
1595 "name": name,
1596 "source": source,
1597 "spec": format_spec(&alias.spec),
1598 "error": error,
1599 });
1600 if let Some(cache_error) = cache_error {
1601 out["cache_error"] = serde_json::json!(cache_error);
1602 }
1603 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1604 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1605 } else {
1606 eprintln!("error: {error}");
1607 }
1608
1609 Ok(1)
1610}
1611
1612fn run_resolve_fixed_harness_failure(
1613 input: ResolveFixedHarnessFailureInput<'_>,
1614) -> Result<i32, MarsError> {
1615 let ResolveFixedHarnessFailureInput {
1616 name,
1617 source,
1618 resolved,
1619 trace,
1620 cache_warning,
1621 diagnostics,
1622 rejection_reason,
1623 routing_diagnostics,
1624 json,
1625 } = input;
1626 let error_message = fixed_alias_rejection_message(rejection_reason);
1627
1628 if json {
1629 let mut out = serde_json::json!({
1630 "name": name,
1631 "source": source,
1632 "provider": resolved.provider,
1633 "harness": trace.selected_harness(),
1634 "model_id": resolved.model_id,
1635 "resolved_model": resolved.model_id,
1636 "error": error_message,
1637 "route_rejection": route_rejection_json(rejection_reason),
1638 "harnesses_tried": trace.candidates_tried,
1639 });
1640 add_route_json_fields(&mut out, trace);
1641 if let Some(warning) = cache_warning {
1642 out["cache_warning"] = serde_json::json!(warning);
1643 }
1644 if !diagnostics.is_empty() {
1645 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(diagnostics));
1646 }
1647 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1648 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1649 } else {
1650 eprintln!("error: {error_message}");
1651 println!("Alias: {name}");
1652 println!("Source: {source}");
1653 println!("Provider: {}", resolved.provider);
1654 println!("Resolved: {}", resolved.model_id);
1655 print_route_text(trace);
1656 emit_drained_text_diagnostics(diagnostics);
1657 }
1658
1659 Ok(1)
1660}
1661
1662fn run_output_resolved(input: OutputResolvedInput<'_>) -> Result<i32, MarsError> {
1663 let OutputResolvedInput {
1664 name,
1665 resolved,
1666 source,
1667 route_trace,
1668 outcome,
1669 cache_outcome,
1670 routing_diagnostics,
1671 json,
1672 } = input;
1673 let cache_warning = cache_warning(outcome);
1674 if let Some(warning) = cache_warning.as_deref()
1675 && !json
1676 {
1677 eprintln!("warning: {warning}");
1678 }
1679
1680 if json {
1681 let mut out = serde_json::json!({
1682 "name": name,
1683 "source": source,
1684 "provider": resolved.provider,
1685 "harness": resolved.harness,
1686 "harness_source": resolved.harness_source,
1687 "harness_candidates": resolved.harness_candidates,
1688 "model_id": resolved.model_id,
1689 "resolved_model": resolved.model_id,
1690 "description": resolved.description,
1691 });
1692 if let Some(error) = unavailable_harness_error(resolved) {
1693 out["error"] = serde_json::json!(error);
1694 }
1695 if let Some(default_effort) = &resolved.default_effort {
1696 out["default_effort"] = serde_json::json!(default_effort);
1697 }
1698 if let Some(autocompact) = resolved.autocompact {
1699 out["autocompact"] = serde_json::json!(autocompact);
1700 }
1701 if let Some(autocompact_pct) = resolved.autocompact_pct {
1702 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
1703 }
1704 out["probe_cache"] = serde_json::json!(cache_outcome.cache_status());
1705 add_availability_json_fields(&mut out, resolved.availability.as_ref());
1706 if let Some(warning) = cache_warning.as_deref() {
1707 out["cache_warning"] = serde_json::json!(warning);
1708 }
1709 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1710 add_route_json_fields(&mut out, route_trace);
1711 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1712 } else {
1713 if matches!(cache_outcome, CachedProbeOutcome::Stale(_)) {
1714 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
1715 }
1716 let harness = resolved.harness.as_deref().unwrap_or("—");
1717 println!("Alias: {}", name);
1718 println!("Source: {}", source);
1719 println!(
1720 "Harness: {} ({})",
1721 harness,
1722 harness_source_label(&resolved.harness_source)
1723 );
1724 println!("Provider: {}", resolved.provider);
1725 println!("Resolved: {}", resolved.model_id);
1726 if let Some(error) = unavailable_harness_error(resolved) {
1727 println!("Error: {}", error);
1728 }
1729 print_availability_text(resolved.availability.as_ref());
1730 if let Some(desc) = &resolved.description {
1731 println!("Desc: {}", desc);
1732 }
1733 print_route_text(route_trace);
1734 }
1735
1736 Ok(0)
1737}
1738
1739fn run_output_passthrough(input: OutputPassthroughInput<'_>) -> Result<i32, MarsError> {
1740 let OutputPassthroughInput {
1741 name,
1742 outcome,
1743 is_offline,
1744 installed,
1745 routing_settings,
1746 cache_error,
1747 routing_diagnostics,
1748 json,
1749 } = input;
1750 if name.trim().is_empty() {
1751 if json {
1752 let mut out = serde_json::json!({
1753 "error": "model name cannot be empty",
1754 });
1755 if let Some(cache_error) = cache_error {
1756 out["cache_error"] = serde_json::json!(cache_error);
1757 }
1758 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1759 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1760 } else {
1761 eprintln!("error: model name cannot be empty");
1762 }
1763 return Ok(1);
1764 }
1765
1766 let cache_warning = cache_warning(outcome);
1767 if let Some(warning) = cache_warning.as_deref()
1768 && !json
1769 {
1770 eprintln!("warning: {warning}");
1771 }
1772
1773 let (passthrough_model_id, provider_constraint) =
1774 models::split_provider_constrained_model_token(name);
1775 let guessed_provider =
1776 models::infer_provider_from_model_id(&passthrough_model_id).map(str::to_string);
1777 let provider_for_order = provider_constraint.as_deref().unwrap_or("unknown");
1778 let provider_for_classification = guessed_provider
1779 .as_deref()
1780 .or(provider_constraint.as_deref())
1781 .unwrap_or("unknown");
1782 let cache_outcome = opencode_cache::probe_cached(installed, is_offline);
1783 let probe_result = cache_outcome.result().cloned();
1784 let pi_probe_result = pi_cache::probe_cached(installed, is_offline)
1785 .result()
1786 .cloned();
1787 let provider_order = routing_settings.provider_order_names();
1788 let harness_order = routing_settings.harness_order_names();
1789 let default_harness = routing_settings.default_harness_name();
1790 let linked_harnesses = routing_settings.linked_harness_names();
1791 let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
1792 model_id: &passthrough_model_id,
1793 provider_for_order: Some(provider_for_order),
1794 provider_constraint: provider_constraint.as_deref(),
1795 settings_provider_order: provider_order.as_deref(),
1796 settings_harness_order: harness_order.as_deref(),
1797 config_default_harness: default_harness.as_deref(),
1798 installed_harnesses: installed,
1799 linked_harnesses: (!linked_harnesses.is_empty()).then_some(linked_harnesses.as_slice()),
1800 opencode_probe_result: probe_result.as_ref(),
1801 pi_probe_result: pi_probe_result.as_ref(),
1802 });
1803 if let Err(rejection_reason) = crate::routing::acceptance::accept_route(
1804 &trace,
1805 installed,
1806 crate::routing::acceptance::MatchPolicy::RequireSlugEvidence,
1807 ) {
1808 let message = passthrough_rejection_message(name, &rejection_reason);
1809 if json {
1810 let mut out = serde_json::json!({
1811 "error": message,
1812 "source": "passthrough",
1813 "model_id": passthrough_model_id,
1814 "resolved_model": passthrough_model_id,
1815 "provider_constraint": provider_constraint,
1816 "harnesses_tried": trace.candidates_tried,
1817 "route_rejection": route_rejection_json(&rejection_reason),
1818 });
1819 add_route_json_fields(&mut out, &trace);
1820 if !trace.selected_diagnostics().is_empty() {
1821 out["diagnostics"] = serde_json::json!(trace.selected_diagnostics());
1822 }
1823 if let Some(warning) = cache_warning.as_deref() {
1824 out["cache_warning"] = serde_json::json!(warning);
1825 }
1826 if let Some(cache_error) = cache_error {
1827 out["cache_error"] = serde_json::json!(cache_error);
1828 }
1829 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1830 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1831 } else {
1832 eprintln!("error: {message}");
1833 print_route_text(&trace);
1834 }
1835 return Ok(1);
1836 }
1837
1838 let harness = installed
1839 .contains(trace.selected_harness())
1840 .then_some(trace.selected_harness().to_string());
1841 let harness_source = "pattern_guess";
1842 let harness_candidates = models::harness::harness_candidates_for_provider(provider_for_order);
1843 let availability = models::availability::classify_model(
1844 &passthrough_model_id,
1845 provider_for_classification,
1846 installed,
1847 probe_result.as_ref(),
1848 pi_probe_result.as_ref(),
1849 is_offline,
1850 );
1851
1852 let warning = format!(
1853 "model '{}' not found in catalog, passing through to harness",
1854 name
1855 );
1856
1857 if json {
1858 let mut out = serde_json::json!({
1859 "name": name,
1860 "source": "passthrough",
1861 "model_id": passthrough_model_id,
1862 "resolved_model": passthrough_model_id,
1863 "provider": guessed_provider,
1864 "harness": harness,
1865 "harness_source": harness_source,
1866 "harness_candidates": harness_candidates,
1867 "description": serde_json::Value::Null,
1868 "warning": warning,
1869 });
1870 add_availability_json_fields(&mut out, Some(&availability));
1871 add_route_json_fields(&mut out, &trace);
1872 if let Some(warning) = cache_warning.as_deref() {
1873 out["cache_warning"] = serde_json::json!(warning);
1874 }
1875 if let Some(cache_error) = cache_error {
1876 out["cache_error"] = serde_json::json!(cache_error);
1877 }
1878 add_routing_diagnostics_json(&mut out, routing_diagnostics);
1879 println!("{}", serde_json::to_string_pretty(&out).unwrap());
1880 } else {
1881 eprintln!("warning: {}", warning);
1882 let h = harness.as_deref().unwrap_or("—");
1883 println!("Model: {}", name);
1884 println!("Source: passthrough");
1885 println!("Harness: {} ({})", h, harness_source);
1886 if let Some(provider) = guessed_provider {
1887 println!("Provider: {}", provider);
1888 }
1889 if !harness_candidates.is_empty() {
1890 println!("Candidates: {}", harness_candidates.join(", "));
1891 }
1892 print_route_text(&trace);
1893 }
1894
1895 Ok(0)
1896}
1897
1898fn load_merged_aliases(
1904 ctx: &MarsContext,
1905) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
1906 let mut merged = models::builtin_aliases();
1908
1909 let mars_dir = ctx.project_root.join(".mars");
1911 let merged_path = mars_dir.join("models-merged.json");
1912 if let Ok(content) = std::fs::read_to_string(&merged_path)
1913 && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
1914 {
1915 for (name, alias) in cached {
1916 merged.insert(name, alias);
1917 }
1918 }
1919
1920 if let Ok(config) = crate::config::load(&ctx.project_root) {
1922 for (name, alias) in &config.models {
1923 merged.insert(name.clone(), alias.clone());
1924 }
1925 }
1926
1927 Ok(merged)
1928}
1929
1930fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
1932 let config = match crate::config::load(&ctx.project_root) {
1933 Ok(c) => c,
1934 Err(_) => return Ok("unknown".to_string()),
1935 };
1936
1937 if config.models.contains_key(name) {
1938 return Ok("consumer (mars.toml)".to_string());
1939 }
1940
1941 Ok("dependency".to_string())
1942}
1943
1944fn format_spec(spec: &ModelSpec) -> serde_json::Value {
1945 match spec {
1946 ModelSpec::Pinned { model, provider } => {
1947 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
1948 if let Some(provider) = provider {
1949 out["provider"] = serde_json::json!(provider);
1950 }
1951 out
1952 }
1953 ModelSpec::PinnedWithMatch {
1954 model,
1955 provider,
1956 match_patterns,
1957 exclude_patterns,
1958 } => {
1959 let mut out = serde_json::json!({
1960 "mode": "pinned",
1961 "model": model,
1962 "match": match_patterns,
1963 "exclude": exclude_patterns,
1964 });
1965 if let Some(provider) = provider {
1966 out["provider"] = serde_json::json!(provider);
1967 }
1968 out
1969 }
1970 ModelSpec::AutoResolve {
1971 provider,
1972 match_patterns,
1973 exclude_patterns,
1974 } => {
1975 serde_json::json!({
1976 "mode": "auto-resolve",
1977 "provider": provider,
1978 "match": match_patterns,
1979 "exclude": exclude_patterns,
1980 })
1981 }
1982 }
1983}
1984
1985fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
1986 match spec {
1987 Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
1988 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
1989 None => "unknown",
1990 }
1991}
1992
1993fn harness_source_label(source: &HarnessSource) -> &'static str {
1994 match source {
1995 HarnessSource::Explicit => "explicit",
1996 HarnessSource::AutoDetected => "auto-detected",
1997 HarnessSource::Unavailable => "unavailable",
1998 }
1999}
2000
2001fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
2002 if resolved.harness_source != HarnessSource::Unavailable {
2003 return None;
2004 }
2005 if let Some(h) = &resolved.harness {
2006 Some(format!("Harness '{}' is not installed", h))
2007 } else {
2008 Some(format!(
2009 "No installed harness for provider '{}'. Install one of: {}",
2010 resolved.provider,
2011 resolved.harness_candidates.join(", ")
2012 ))
2013 }
2014}
2015
2016fn fixed_alias_rejection_message(
2017 rejection: &crate::routing::acceptance::RejectionReason,
2018) -> String {
2019 match rejection {
2020 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2021 "alias harness `{harness}` is not installed and cannot run resolved model under model-first routing"
2022 ),
2023 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => format!(
2024 "alias harness `{harness}` did not provide required model slug evidence under model-first routing"
2025 ),
2026 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2027 harness,
2028 skip_reason,
2029 } => format!(
2030 "alias harness `{harness}` cannot run resolved model under model-first routing ({})",
2031 skip_reason.as_deref().unwrap_or("unavailable")
2032 ),
2033 }
2034}
2035
2036fn passthrough_rejection_message(
2037 model_name: &str,
2038 rejection: &crate::routing::acceptance::RejectionReason,
2039) -> String {
2040 match rejection {
2041 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2042 "model '{model_name}' selected harness '{harness}', but that harness is not installed"
2043 ),
2044 crate::routing::acceptance::RejectionReason::NoSlugEvidence { .. } => format!(
2045 "model '{model_name}' did not match any harness-reported model slug under model-first routing"
2046 ),
2047 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2048 harness,
2049 skip_reason,
2050 } => format!(
2051 "model '{model_name}' failed model-first routing assessment on harness '{harness}' ({})",
2052 skip_reason.as_deref().unwrap_or("unavailable")
2053 ),
2054 }
2055}
2056
2057fn route_rejection_json(
2058 rejection: &crate::routing::acceptance::RejectionReason,
2059) -> serde_json::Value {
2060 match rejection {
2061 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => {
2062 serde_json::json!({
2063 "reason": "harness_not_installed",
2064 "harness": harness,
2065 })
2066 }
2067 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => {
2068 serde_json::json!({
2069 "reason": "no_slug_evidence",
2070 "harness": harness,
2071 })
2072 }
2073 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2074 harness,
2075 skip_reason,
2076 } => {
2077 serde_json::json!({
2078 "reason": "assessment_failed",
2079 "harness": harness,
2080 "skip_reason": skip_reason,
2081 })
2082 }
2083 }
2084}
2085
2086fn stale_warning(reason: &str) -> String {
2087 format!("models cache refresh failed: {reason}; using stale cache")
2088}
2089
2090fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
2091 match outcome {
2092 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
2093 _ => None,
2094 }
2095}
2096
2097fn emit_routing_settings_warnings(routing_diagnostics: &[String]) {
2098 for message in routing_diagnostics {
2099 eprintln!("warning: {message}");
2100 }
2101}
2102
2103fn add_routing_diagnostics_json(out: &mut serde_json::Value, routing_diagnostics: &[String]) {
2104 if !routing_diagnostics.is_empty() {
2105 out["routing_diagnostics"] = serde_json::json!(routing_diagnostics);
2106 }
2107}
2108
2109fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
2110 diagnostics
2111 .iter()
2112 .map(|diagnostic| {
2113 serde_json::json!({
2114 "level": diagnostic_level_label(diagnostic.level),
2115 "code": diagnostic.code,
2116 "message": diagnostic.message,
2117 "context": diagnostic.context,
2118 })
2119 })
2120 .collect()
2121}
2122
2123fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
2124 let diagnostics = diag.drain();
2125 if diagnostics.is_empty() {
2126 None
2127 } else {
2128 Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
2129 }
2130}
2131
2132fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
2133 for diagnostic in diagnostics {
2134 let label = diagnostic_level_label(diagnostic.level);
2135 eprintln!("{label}: {}", diagnostic.message);
2136 }
2137}
2138
2139fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
2140 let diagnostics = diag.drain();
2141 emit_drained_text_diagnostics(&diagnostics);
2142}
2143
2144fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
2145 match level {
2146 DiagnosticLevel::Error => "error",
2147 DiagnosticLevel::Warning => "warning",
2148 DiagnosticLevel::Info => "info",
2149 }
2150}
2151
2152#[cfg(test)]
2153mod tests {
2154 use super::*;
2155 use clap::Parser;
2156 use indexmap::IndexMap;
2157 use tempfile::TempDir;
2158
2159 fn write_mars_toml(temp: &TempDir, contents: &str) {
2160 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
2161 }
2162
2163 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
2164 match result {
2165 Ok(code) => code,
2166 Err(err) => err.exit_code(),
2167 }
2168 }
2169
2170 #[test]
2171 fn list_args_parses_no_refresh_models() {
2172 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
2173 assert!(args.no_refresh_models);
2174 }
2175
2176 #[test]
2177 fn list_args_parses_catalog() {
2178 let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
2179 assert!(args.catalog);
2180 }
2181
2182 #[test]
2183 fn list_all_and_catalog_conflict() {
2184 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
2185 assert!(parsed.is_err());
2186 }
2187
2188 #[test]
2189 fn list_all_and_include_can_combine() {
2190 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
2191 assert!(parsed.is_ok());
2192 }
2193
2194 #[test]
2195 fn list_catalog_and_include_can_combine() {
2196 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
2197 assert!(parsed.is_ok());
2198 }
2199
2200 #[test]
2201 fn resolve_alias_args_parses_no_refresh_models() {
2202 let args =
2203 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
2204 assert!(args.no_refresh_models);
2205 }
2206
2207 #[test]
2208 fn list_no_refresh_without_cache_is_non_zero() {
2209 let temp = TempDir::new().unwrap();
2210 write_mars_toml(&temp, "[settings]\n");
2211 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2212 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
2213
2214 let exit = normalized_exit_code(run(&args, &ctx, false));
2215 assert_ne!(exit, 0);
2216 }
2217
2218 #[test]
2219 fn resolve_no_refresh_without_cache_is_non_zero() {
2220 let temp = TempDir::new().unwrap();
2221 write_mars_toml(
2222 &temp,
2223 r#"[settings]
2224
2225[models.opus]
2226harness = "claude"
2227model = "claude-opus-4-6"
2228"#,
2229 );
2230 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2231 let args =
2232 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
2233
2234 let exit = normalized_exit_code(run(&args, &ctx, false));
2235 assert_ne!(exit, 0);
2236 }
2237
2238 #[test]
2239 fn alias_updates_existing_model_entry() {
2240 let temp = TempDir::new().unwrap();
2241 write_mars_toml(
2242 &temp,
2243 r#"[settings]
2244
2245[models.fast]
2246harness = "claude"
2247model = "claude-3-5-sonnet"
2248description = "Old alias"
2249"#,
2250 );
2251 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2252
2253 let args = AddAliasArgs {
2254 name: "fast".to_string(),
2255 model_id: "gpt-5.3-codex".to_string(),
2256 harness: "codex".to_string(),
2257 description: Some("Updated alias".to_string()),
2258 };
2259
2260 let exit = run_alias(&args, &ctx, false).unwrap();
2261 assert_eq!(exit, 0);
2262
2263 let config = crate::config::load(temp.path()).unwrap();
2264 assert_eq!(config.models.len(), 1);
2265
2266 let alias = config.models.get("fast").unwrap();
2267 assert_eq!(alias.harness.as_deref(), Some("codex"));
2268 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
2269 match &alias.spec {
2270 ModelSpec::Pinned { model, provider } => {
2271 assert_eq!(model, "gpt-5.3-codex");
2272 assert_eq!(provider, &None);
2273 }
2274 _ => panic!("expected pinned alias"),
2275 }
2276 }
2277
2278 #[test]
2279 fn alias_rejects_invalid_harness_at_write_boundary() {
2280 let temp = TempDir::new().unwrap();
2281 write_mars_toml(&temp, "[settings]\n");
2282 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2283
2284 let args = AddAliasArgs {
2285 name: "fast".to_string(),
2286 model_id: "gpt-5.3-codex".to_string(),
2287 harness: "gemini".to_string(),
2288 description: None,
2289 };
2290
2291 let err = run_alias(&args, &ctx, false).unwrap_err().to_string();
2292 assert!(err.contains("invalid harness 'gemini'"));
2293 assert!(err.contains("valid harnesses: claude, codex, pi, opencode, cursor"));
2294 }
2295
2296 #[test]
2297 fn alias_normalizes_mixed_case_harness_before_write() {
2298 let temp = TempDir::new().unwrap();
2299 write_mars_toml(&temp, "[settings]\n");
2300 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2301
2302 let args = AddAliasArgs {
2303 name: "fast".to_string(),
2304 model_id: "gpt-5.3-codex".to_string(),
2305 harness: "OpenCode".to_string(),
2306 description: None,
2307 };
2308
2309 let exit = run_alias(&args, &ctx, false).unwrap();
2310 assert_eq!(exit, 0);
2311
2312 let config = crate::config::load(temp.path()).unwrap();
2313 let alias = config.models.get("fast").unwrap();
2314 assert_eq!(alias.harness.as_deref(), Some("opencode"));
2315 }
2316
2317 fn auto_alias(
2318 provider: &str,
2319 match_patterns: &[&str],
2320 exclude_patterns: &[&str],
2321 ) -> ModelAlias {
2322 ModelAlias {
2323 harness: None,
2324 description: None,
2325 default_effort: None,
2326 autocompact: None,
2327 autocompact_pct: None,
2328 spec: ModelSpec::AutoResolve {
2329 provider: provider.to_string(),
2330 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2331 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2332 },
2333 }
2334 }
2335
2336 fn pinned_with_match_alias(
2337 model: &str,
2338 provider: &str,
2339 match_patterns: &[&str],
2340 exclude_patterns: &[&str],
2341 ) -> ModelAlias {
2342 ModelAlias {
2343 harness: None,
2344 description: None,
2345 default_effort: None,
2346 autocompact: None,
2347 autocompact_pct: None,
2348 spec: ModelSpec::PinnedWithMatch {
2349 model: model.to_string(),
2350 provider: Some(provider.to_string()),
2351 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2352 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2353 },
2354 }
2355 }
2356
2357 fn pinned_alias(model: &str) -> ModelAlias {
2358 ModelAlias {
2359 harness: None,
2360 description: None,
2361 default_effort: None,
2362 autocompact: None,
2363 autocompact_pct: None,
2364 spec: ModelSpec::Pinned {
2365 model: model.to_string(),
2366 provider: None,
2367 },
2368 }
2369 }
2370
2371 fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
2372 ModelAlias {
2373 harness: None,
2374 description: None,
2375 default_effort: None,
2376 autocompact: None,
2377 autocompact_pct: None,
2378 spec: ModelSpec::Pinned {
2379 model: model.to_string(),
2380 provider: Some(provider.to_string()),
2381 },
2382 }
2383 }
2384
2385 fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
2386 models::CachedModel {
2387 id: id.to_string(),
2388 provider: provider.to_string(),
2389 release_date: release_date.map(|value| value.to_string()),
2390 description: Some(format!("desc-{id}")),
2391 context_window: None,
2392 max_output: None,
2393 cost_input: None,
2394 cost_output: None,
2395 cost_cache_read: None,
2396 cost_cache_write: None,
2397 cost_reasoning: None,
2398 }
2399 }
2400
2401 fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
2402 models::ModelsCache {
2403 models,
2404 fetched_at: Some("123".to_string()),
2405 }
2406 }
2407
2408 fn installed(names: &[&str]) -> HashSet<String> {
2409 names.iter().map(|name| (*name).to_string()).collect()
2410 }
2411
2412 fn default_routing_settings() -> ResolvedRoutingSettings {
2413 crate::config::routing_settings::resolve(&crate::config::Settings::default())
2414 }
2415
2416 fn collect_all_model_entries(
2417 merged: &IndexMap<String, ModelAlias>,
2418 cache: &models::ModelsCache,
2419 installed: &HashSet<String>,
2420 opencode_probe_result: Option<&OpenCodeProbeResult>,
2421 pi_probe_result: Option<&PiProbeResult>,
2422 is_offline: bool,
2423 routing_settings: &ResolvedRoutingSettings,
2424 ) -> Vec<ListModelEntry> {
2425 super::collect_all_model_entries(
2426 merged,
2427 cache,
2428 AvailabilityContext {
2429 installed,
2430 opencode_probe_result,
2431 pi_probe_result,
2432 is_offline,
2433 routing_settings,
2434 },
2435 )
2436 }
2437
2438 fn collect_catalog_model_entries(
2439 cache: &models::ModelsCache,
2440 installed: &HashSet<String>,
2441 opencode_probe_result: Option<&OpenCodeProbeResult>,
2442 pi_probe_result: Option<&PiProbeResult>,
2443 is_offline: bool,
2444 routing_settings: &ResolvedRoutingSettings,
2445 ) -> Vec<ListModelEntry> {
2446 super::collect_catalog_model_entries(
2447 cache,
2448 AvailabilityContext {
2449 installed,
2450 opencode_probe_result,
2451 pi_probe_result,
2452 is_offline,
2453 routing_settings,
2454 },
2455 )
2456 }
2457
2458 #[test]
2459 fn list_all_shows_multiple_per_alias() {
2460 let mut merged = IndexMap::new();
2461 merged.insert(
2462 "opus".to_string(),
2463 auto_alias("Anthropic", &["claude-opus-*"], &[]),
2464 );
2465
2466 let models_cache = cache(vec![
2467 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2468 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
2469 ]);
2470
2471 let installed = installed(&[]);
2472 let rows = collect_all_model_entries(
2473 &merged,
2474 &models_cache,
2475 &installed,
2476 None,
2477 None,
2478 false,
2479 &default_routing_settings(),
2480 );
2481 assert_eq!(rows.len(), 2);
2482 assert_eq!(rows[0].id, "claude-opus-4-7");
2483 assert_eq!(rows[1].id, "claude-opus-4-6");
2484 }
2485
2486 #[test]
2487 fn list_all_includes_matched_aliases_with_dedup() {
2488 let mut merged = IndexMap::new();
2489 merged.insert(
2490 "opus".to_string(),
2491 auto_alias("Anthropic", &["claude-opus-*"], &[]),
2492 );
2493 merged.insert(
2494 "legacy".to_string(),
2495 auto_alias("Anthropic", &["*4-6"], &[]),
2496 );
2497
2498 let models_cache = cache(vec![cached_model(
2499 "claude-opus-4-6",
2500 "Anthropic",
2501 Some("2026-02-05"),
2502 )]);
2503
2504 let installed = installed(&[]);
2505 let rows = collect_all_model_entries(
2506 &merged,
2507 &models_cache,
2508 &installed,
2509 None,
2510 None,
2511 false,
2512 &default_routing_settings(),
2513 );
2514 assert_eq!(rows.len(), 1);
2515 assert_eq!(rows[0].id, "claude-opus-4-6");
2516 assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
2517 }
2518
2519 #[test]
2520 fn list_all_includes_pinned_cache_entries() {
2521 let mut merged = IndexMap::new();
2522 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
2523
2524 let models_cache = cache(vec![cached_model(
2525 "gpt-5.3-codex",
2526 "OpenAI",
2527 Some("2026-01-01"),
2528 )]);
2529 let installed = installed(&[]);
2530 let rows = collect_all_model_entries(
2531 &merged,
2532 &models_cache,
2533 &installed,
2534 None,
2535 None,
2536 false,
2537 &default_routing_settings(),
2538 );
2539 assert_eq!(rows.len(), 1);
2540 assert_eq!(rows[0].id, "gpt-5.3-codex");
2541 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
2542 }
2543
2544 #[test]
2545 fn list_all_includes_pinned_cache_miss_entries() {
2546 let mut merged = IndexMap::new();
2547 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
2548
2549 let models_cache = cache(Vec::new());
2550 let installed = installed(&[]);
2551 let rows = collect_all_model_entries(
2552 &merged,
2553 &models_cache,
2554 &installed,
2555 None,
2556 None,
2557 false,
2558 &default_routing_settings(),
2559 );
2560 assert_eq!(rows.len(), 1);
2561 assert_eq!(rows[0].id, "gpt-5.3-codex");
2562 assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
2563 assert_eq!(rows[0].release_date, None);
2564 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
2565 }
2566
2567 #[test]
2568 fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
2569 let mut merged = IndexMap::new();
2570 merged.insert(
2571 "custom".to_string(),
2572 pinned_alias_with_provider("custom-model-id", "Anthropic"),
2573 );
2574
2575 let models_cache = cache(Vec::new());
2576 let installed = installed(&[]);
2577 let rows = collect_all_model_entries(
2578 &merged,
2579 &models_cache,
2580 &installed,
2581 None,
2582 None,
2583 false,
2584 &default_routing_settings(),
2585 );
2586 assert_eq!(rows.len(), 1);
2587 assert_eq!(rows[0].id, "custom-model-id");
2588 assert_eq!(rows[0].provider, "Anthropic");
2589 assert_eq!(rows[0].release_date, None);
2590 assert_eq!(rows[0].matched_aliases, vec!["custom"]);
2591 }
2592
2593 #[test]
2594 fn list_all_includes_unavailable_harness_entries_with_fallback_candidates() {
2595 let mut merged = IndexMap::new();
2596 merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
2597 let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
2598
2599 let installed = installed(&[]);
2600 let rows = collect_all_model_entries(
2601 &merged,
2602 &models_cache,
2603 &installed,
2604 None,
2605 None,
2606 false,
2607 &default_routing_settings(),
2608 );
2609 assert_eq!(rows.len(), 1);
2610 assert_eq!(rows[0].harness, None);
2611 assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
2612 assert_eq!(rows[0].harness_candidates, vec!["pi", "opencode", "cursor"]);
2613 }
2614
2615 #[test]
2616 fn list_catalog_shows_all_cache_sorted() {
2617 let models_cache = cache(vec![
2618 cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
2619 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2620 cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
2621 ]);
2622
2623 let installed = installed(&[]);
2624 let rows = collect_catalog_model_entries(
2625 &models_cache,
2626 &installed,
2627 None,
2628 None,
2629 false,
2630 &default_routing_settings(),
2631 );
2632 assert_eq!(rows.len(), 3);
2633 assert_eq!(rows[0].id, "claude-opus-4-6");
2634 assert_eq!(rows[1].id, "claude-sonnet-4-5");
2635 assert_eq!(rows[2].id, "gpt-5");
2636 }
2637
2638 #[test]
2639 fn list_all_includes_pinned_with_match_discovery_candidates() {
2640 let mut merged = IndexMap::new();
2641 merged.insert(
2642 "opus".to_string(),
2643 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2644 );
2645 let models_cache = cache(vec![
2646 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2647 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2648 ]);
2649
2650 let installed = installed(&[]);
2651 let rows = collect_all_model_entries(
2652 &merged,
2653 &models_cache,
2654 &installed,
2655 None,
2656 None,
2657 false,
2658 &default_routing_settings(),
2659 );
2660 assert_eq!(rows.len(), 2);
2661 assert_eq!(rows[0].id, "claude-opus-4-7");
2662 assert_eq!(rows[1].id, "claude-opus-4-6");
2663 assert_eq!(rows[0].matched_aliases, vec!["opus"]);
2664 assert_eq!(rows[1].matched_aliases, vec!["opus"]);
2665 }
2666
2667 #[test]
2668 fn resolve_pinned_with_match_uses_model_field() {
2669 let mut merged = IndexMap::new();
2670 merged.insert(
2671 "opus".to_string(),
2672 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2673 );
2674 let models_cache = cache(vec![
2675 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2676 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2677 ]);
2678 let mut diag = DiagnosticCollector::new();
2679 let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
2680 assert_eq!(resolved.model_id, "claude-opus-4-6");
2681 assert!(diag.drain().is_empty());
2682 }
2683}