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, CapabilitySession, CapabilitySnapshot, collect_capability_snapshot,
13};
14use crate::models::availability::{AvailabilityStatus, ModelAvailability};
15use crate::models::probes::CursorProbeResult;
16use crate::models::probes::OpenCodeProbeResult;
17use crate::models::probes::PiProbeResult;
18use crate::models::probes::ProbeRefreshMode;
19use crate::models::probes::cursor_cache;
20use crate::models::probes::opencode_cache::{self, CachedProbeOutcome};
21use crate::models::probes::pi_cache;
22use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
23use crate::types::MarsContext;
24
25#[derive(Debug, Parser)]
27pub struct ModelsArgs {
28 #[command(subcommand)]
29 pub command: ModelsCommand,
30}
31
32#[derive(Debug, Subcommand)]
33pub enum ModelsCommand {
34 Refresh,
36 List(ListArgs),
38 Resolve(ResolveAliasArgs),
40 Alias(AddAliasArgs),
42 #[command(name = "__refresh-probe", hide = true)]
43 RefreshProbe(RefreshProbeArgs),
44}
45
46#[derive(Debug, Parser)]
47pub struct ListArgs {
48 #[arg(long, conflicts_with = "catalog", conflicts_with = "unavailable")]
50 all: bool,
51 #[arg(long)]
53 live: bool,
54 #[arg(long, conflicts_with = "no_refresh_models")]
56 refresh_models: bool,
57 #[arg(long, conflicts_with = "refresh_models")]
59 no_refresh_models: bool,
60 #[arg(long, value_delimiter = ',')]
62 include: Option<Vec<String>>,
63 #[arg(long, value_delimiter = ',')]
65 exclude: Option<Vec<String>>,
66 #[arg(long, conflicts_with = "all")]
68 catalog: bool,
69 #[arg(long)]
71 unavailable: bool,
72}
73
74#[derive(Debug, Parser)]
75pub struct ResolveAliasArgs {
76 pub name: String,
78 #[arg(long, conflicts_with = "no_refresh_models")]
80 refresh_models: bool,
81 #[arg(long, conflicts_with = "refresh_models")]
83 no_refresh_models: bool,
84}
85
86#[derive(Debug, Parser)]
87pub struct RefreshProbeArgs {
88 #[arg(long)]
89 target: String,
90}
91
92#[derive(Debug, Parser)]
93pub struct AddAliasArgs {
94 pub name: String,
96 pub model_id: String,
98 #[arg(long, default_value = "claude")]
100 pub harness: String,
101 #[arg(long)]
103 pub description: Option<String>,
104}
105
106pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
107 match &args.command {
108 ModelsCommand::Refresh => run_refresh(ctx, json),
109 ModelsCommand::List(args) => run_list(args, ctx, json),
110 ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
111 ModelsCommand::Alias(a) => run_alias(a, ctx, json),
112 ModelsCommand::RefreshProbe(a) => run_refresh_probe(a),
113 }
114}
115
116fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
117 ctx.project_root.join(".mars")
118}
119
120fn collect_models_capability_snapshot(
121 refresh: &models::ModelsRefreshControl,
122) -> CapabilitySnapshot {
123 collect_capability_snapshot(&CapabilityCollectionOptions {
124 offline: models::is_mars_offline(),
125 probe_refresh: refresh.probe_refresh,
126 })
127}
128
129fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
130 let mars = mars_dir(ctx);
131 let project_config = load_project_config_layers_optional(&ctx.project_root)?;
132 let ttl = models_cache_ttl_hours(project_config.as_ref());
133 eprint!("Fetching models catalog... ");
134
135 let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
136 let count = cache.models.len();
137 let cache_warning = cache_warning(&outcome);
138
139 if let Some(warning) = cache_warning.as_deref() {
140 eprintln!("warning: {warning}");
141 } else if !json {
142 eprintln!("done.");
143 }
144
145 if json {
146 let out = serde_json::json!({
147 "status": "ok",
148 "models_count": count,
149 "fetched_at": cache.fetched_at,
150 });
151 let mut out = out;
152 if let Some(warning) = cache_warning.as_deref() {
153 out["cache_warning"] = serde_json::json!(warning);
154 }
155 println!("{}", serde_json::to_string_pretty(&out).unwrap());
156 } else {
157 if cache_warning.is_some() {
158 println!(
159 "Using stale models cache with {} models in .mars/models-cache.json",
160 count
161 );
162 } else {
163 println!("Cached {} models in .mars/models-cache.json", count);
164 }
165 }
166
167 Ok(0)
168}
169
170fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
171 let mars = mars_dir(ctx);
172 let project_config = load_project_config_layers_optional(&ctx.project_root)?;
173 let ttl = models_cache_ttl_hours(project_config.as_ref());
174 let refresh =
175 models::resolve_models_refresh_control(args.refresh_models, args.no_refresh_models)?;
176 let mode = refresh.catalog_mode;
177 let default_settings = crate::config::Settings::default();
178 let settings = project_config
179 .as_ref()
180 .map(|loaded| &loaded.effective.settings)
181 .unwrap_or(&default_settings);
182 let routing_settings = ResolvedRoutingSettings::from_settings(settings);
183 let routing_diagnostics = routing_settings.diagnostic_messages();
184 let visibility = effective_visibility(project_config.as_ref(), args);
185 if !json {
186 emit_routing_settings_warnings(&routing_diagnostics);
187 }
188
189 let merged = (!args.catalog)
193 .then(|| load_merged_aliases(&ctx.project_root, project_config.as_ref()))
194 .transpose()?;
195
196 let (cache, outcome) = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
197 FreshOrJsonError::Fresh(cache, outcome) => (cache, outcome),
198 FreshOrJsonError::JsonError(error_message) => {
199 let mut out = serde_json::json!({
200 "error": error_message,
201 });
202 add_routing_diagnostics_json(&mut out, &routing_diagnostics);
203 println!("{}", serde_json::to_string_pretty(&out).unwrap());
204 return Ok(1);
205 }
206 };
207 if args.catalog {
208 if !args.live {
209 return run_list_catalog_static(ListCatalogStaticInput {
210 cache: &cache,
211 outcome: &outcome,
212 visibility: &visibility,
213 routing_diagnostics: &routing_diagnostics,
214 json,
215 });
216 }
217 let capability_snapshot = collect_models_capability_snapshot(&refresh);
218 return run_list_catalog(ListCatalogInput {
219 cache: &cache,
220 outcome: &outcome,
221 args,
222 visibility: &visibility,
223 routing_settings: &routing_settings,
224 routing_diagnostics: &routing_diagnostics,
225 capability_snapshot: &capability_snapshot,
226 json,
227 });
228 }
229
230 let merged = merged.expect("non-catalog models list loaded runtime aliases");
231 if args.all {
232 if !args.live {
233 return run_list_all_static(
234 &merged,
235 &cache,
236 &outcome,
237 &visibility,
238 &routing_diagnostics,
239 json,
240 );
241 }
242 let capability_snapshot = collect_models_capability_snapshot(&refresh);
243 let installed = capability_snapshot.installed_harnesses();
244 let is_offline = capability_snapshot.offline;
245 let opencode_probe_result = capability_snapshot.opencode.result().cloned();
246 let pi_probe_result = capability_snapshot.pi.result().cloned();
247 let cursor_probe_result = capability_snapshot.cursor.result().cloned();
248 let catalog_slugs = models::catalog_model_slugs(&cache);
249 let availability_ctx = AvailabilityContext {
250 installed: &installed,
251 opencode_probe_result: opencode_probe_result.as_ref(),
252 pi_probe_result: pi_probe_result.as_ref(),
253 cursor_probe_result: cursor_probe_result.as_ref(),
254 catalog_model_slugs: Some(catalog_slugs.as_slice()),
255 is_offline,
256 routing_settings: &routing_settings,
257 };
258 return run_list_all(
259 &merged,
260 &cache,
261 &outcome,
262 &visibility,
263 availability_ctx,
264 &routing_diagnostics,
265 json,
266 );
267 }
268
269 if !args.live {
270 return run_list_aliases_static(
271 &merged,
272 &cache,
273 &outcome,
274 &visibility,
275 &routing_diagnostics,
276 json,
277 );
278 }
279
280 let capability_snapshot = collect_models_capability_snapshot(&refresh);
281 let installed = capability_snapshot.installed_harnesses();
282 let is_offline = capability_snapshot.offline;
283 let opencode_probe_result = capability_snapshot.opencode.result().cloned();
284 let pi_probe_result = capability_snapshot.pi.result().cloned();
285 let cursor_probe_result = capability_snapshot.cursor.result().cloned();
286 let cache_warning = cache_warning(&outcome);
287 let mut diag = DiagnosticCollector::new();
288 let catalog_slugs = models::catalog_model_slugs(&cache);
289
290 let mut resolved = models::resolve_all_with_probe(
291 &merged,
292 &cache,
293 &mut diag,
294 opencode_probe_result.as_ref(),
295 pi_probe_result.as_ref(),
296 cursor_probe_result.as_ref(),
297 );
298 apply_routing_settings_to_resolved_aliases(
299 &mut resolved,
300 &merged,
301 &installed,
302 opencode_probe_result.as_ref(),
303 pi_probe_result.as_ref(),
304 cursor_probe_result.as_ref(),
305 Some(catalog_slugs.as_slice()),
306 &routing_settings,
307 );
308 annotate_resolved_availability(
309 &mut resolved,
310 &installed,
311 opencode_probe_result.as_ref(),
312 pi_probe_result.as_ref(),
313 cursor_probe_result.as_ref(),
314 is_offline,
315 );
316 if !args.unavailable {
317 prune_unavailable(&mut resolved);
318 }
319
320 let resolved = models::filter_by_visibility(resolved, &visibility);
322
323 if json {
324 let entries: Vec<serde_json::Value> = resolved
325 .values()
326 .map(|r| {
327 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
328 let mut obj = serde_json::json!({
329 "name": r.name,
330 "harness": r.harness,
331 "harness_source": r.harness_source,
332 "harness_candidates": r.harness_candidates,
333 "provider": r.provider,
334 "mode": mode,
335 "model_id": r.model_id,
336 "resolved_model": r.model_id,
337 "description": r.description,
338 });
339 if let Some(error) = unavailable_harness_error(r) {
340 obj["error"] = serde_json::json!(error);
341 }
342 if let Some(default_effort) = &r.default_effort {
343 obj["default_effort"] = serde_json::json!(default_effort);
344 }
345 if let Some(autocompact) = r.autocompact {
346 obj["autocompact"] = serde_json::json!(autocompact);
347 }
348 if let Some(autocompact_pct) = r.autocompact_pct {
349 obj["autocompact_pct"] = serde_json::json!(autocompact_pct);
350 }
351 if let Some(model) = cache.models.iter().find(|model| model.id == r.model_id) {
352 add_cost_json_fields(&mut obj, model);
353 }
354 add_availability_json_fields(&mut obj, r.availability.as_ref());
355 obj
356 })
357 .collect();
358 let mut out = serde_json::json!({
359 "aliases": entries,
360 "cache_available": cache.fetched_at.is_some(),
361 });
362 add_probe_results_json(
363 &mut out,
364 opencode_probe_result.as_ref(),
365 pi_probe_result.as_ref(),
366 cursor_probe_result.as_ref(),
367 );
368 if let Some(warning) = cache_warning.as_deref() {
369 out["cache_warning"] = serde_json::json!(warning);
370 }
371 if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
372 out["diagnostics"] = diagnostics;
373 }
374 add_routing_diagnostics_json(&mut out, &routing_diagnostics);
375 println!("{}", serde_json::to_string_pretty(&out).unwrap());
376 } else {
377 if let Some(warning) = cache_warning.as_deref() {
378 eprintln!("warning: {warning}");
379 }
380 println!(
382 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
383 "ALIAS", "HARNESS", "MODE", "RESOLVED", "AVAILABILITY", "DESCRIPTION"
384 );
385 for r in resolved.values() {
386 let harness = r.harness.as_deref().unwrap_or("—");
387 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
388 let availability = availability_status_label(r.availability.as_ref());
389 let desc = r.description.clone().unwrap_or_default();
390 println!(
391 "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
392 r.name, harness, mode, r.model_id, availability, desc
393 );
394 }
395 emit_text_diagnostics(&mut diag);
396 }
397
398 Ok(0)
399}
400
401#[derive(Debug, Clone)]
402struct ListModelEntry {
403 id: String,
404 provider: String,
405 release_date: Option<String>,
406 harness: Option<String>,
407 harness_source: HarnessSource,
408 harness_candidates: Vec<String>,
409 description: Option<String>,
410 cost_input: Option<f64>,
411 cost_output: Option<f64>,
412 cost_cache_read: Option<f64>,
413 cost_cache_write: Option<f64>,
414 cost_reasoning: Option<f64>,
415 matched_aliases: Vec<String>,
416 availability: Option<ModelAvailability>,
417}
418
419#[derive(Clone, Copy)]
420struct AvailabilityContext<'a> {
421 installed: &'a HashSet<String>,
422 opencode_probe_result: Option<&'a OpenCodeProbeResult>,
423 pi_probe_result: Option<&'a PiProbeResult>,
424 cursor_probe_result: Option<&'a CursorProbeResult>,
425 catalog_model_slugs: Option<&'a [String]>,
426 is_offline: bool,
427 routing_settings: &'a ResolvedRoutingSettings,
428}
429
430struct ResolveRuntime<'a> {
431 cache: &'a models::ModelsCache,
432 catalog_model_slugs: &'a [String],
433 outcome: &'a models::RefreshOutcome,
434 installed: &'a HashSet<String>,
435 routing_settings: &'a ResolvedRoutingSettings,
436 probe_refresh: ProbeRefreshMode,
437}
438
439struct RouteTraceInput<'a> {
440 model_id: &'a str,
441 provider_for_order: &'a str,
442 provider_constraint: Option<&'a str>,
443 installed: &'a HashSet<String>,
444 opencode_probe_result: Option<&'a OpenCodeProbeResult>,
445 pi_probe_result: Option<&'a PiProbeResult>,
446 cursor_probe_result: Option<&'a CursorProbeResult>,
447 catalog_model_slugs: Option<&'a [String]>,
448 routing_settings: &'a ResolvedRoutingSettings,
449}
450
451struct SessionProbeResolver<'a> {
452 session: &'a mut CapabilitySession,
453}
454
455impl crate::routing::ProbeResolver for SessionProbeResolver<'_> {
456 fn opencode_probe_result(&mut self) -> Option<OpenCodeProbeResult> {
457 self.session.opencode_probe_result()
458 }
459
460 fn pi_probe_result(&mut self) -> Option<PiProbeResult> {
461 self.session.pi_probe_result()
462 }
463
464 fn cursor_probe_result(&mut self) -> Option<CursorProbeResult> {
465 self.session.cursor_probe_result()
466 }
467}
468
469struct ListCatalogInput<'a> {
470 cache: &'a models::ModelsCache,
471 outcome: &'a models::RefreshOutcome,
472 args: &'a ListArgs,
473 visibility: &'a crate::config::ModelVisibility,
474 routing_settings: &'a ResolvedRoutingSettings,
475 routing_diagnostics: &'a [String],
476 capability_snapshot: &'a CapabilitySnapshot,
477 json: bool,
478}
479
480struct ListCatalogStaticInput<'a> {
481 cache: &'a models::ModelsCache,
482 outcome: &'a models::RefreshOutcome,
483 visibility: &'a crate::config::ModelVisibility,
484 routing_diagnostics: &'a [String],
485 json: bool,
486}
487
488struct OutputResolvedInput<'a> {
489 name: &'a str,
490 resolved: &'a models::ResolvedAlias,
491 source: &'a str,
492 route_trace: &'a crate::routing::RoutingTrace,
493 outcome: &'a models::RefreshOutcome,
494 cache_outcome: &'a CachedProbeOutcome,
495 probe_refresh: ProbeRefreshMode,
496 routing_diagnostics: &'a [String],
497 json: bool,
498}
499
500struct OutputPassthroughInput<'a> {
501 name: &'a str,
502 outcome: &'a models::RefreshOutcome,
503 is_offline: bool,
504 installed: &'a HashSet<String>,
505 capability_session: &'a mut CapabilitySession,
506 catalog_model_slugs: Option<&'a [String]>,
507 routing_settings: &'a ResolvedRoutingSettings,
508 cache_error: Option<&'a str>,
509 routing_diagnostics: &'a [String],
510 json: bool,
511}
512
513fn run_list_all(
514 merged: &IndexMap<String, ModelAlias>,
515 cache: &models::ModelsCache,
516 outcome: &models::RefreshOutcome,
517 visibility: &crate::config::ModelVisibility,
518 availability_ctx: AvailabilityContext<'_>,
519 routing_diagnostics: &[String],
520 json: bool,
521) -> Result<i32, MarsError> {
522 let cache_warning = cache_warning(outcome);
523 let models = collect_all_model_entries(merged, cache, availability_ctx);
524 let models = filter_model_entries_by_visibility(models, visibility);
525
526 if json {
527 let entries: Vec<serde_json::Value> = models
528 .into_iter()
529 .map(|model| {
530 let mut obj = serde_json::json!({
531 "id": model.id,
532 "provider": model.provider,
533 "release_date": model.release_date,
534 "harness": model.harness,
535 "harness_source": model.harness_source,
536 "harness_candidates": model.harness_candidates,
537 "description": model.description,
538 "cost_input": model.cost_input,
539 "cost_output": model.cost_output,
540 "cost_cache_read": model.cost_cache_read,
541 "cost_cache_write": model.cost_cache_write,
542 "cost_reasoning": model.cost_reasoning,
543 "matched_aliases": model.matched_aliases,
544 });
545 add_availability_json_fields(&mut obj, model.availability.as_ref());
546 obj
547 })
548 .collect();
549 let mut out = serde_json::json!({
550 "models": entries,
551 "cache_available": cache.fetched_at.is_some(),
552 });
553 add_probe_results_json(
554 &mut out,
555 availability_ctx.opencode_probe_result,
556 availability_ctx.pi_probe_result,
557 availability_ctx.cursor_probe_result,
558 );
559 if let Some(warning) = cache_warning.as_deref() {
560 out["cache_warning"] = serde_json::json!(warning);
561 }
562 add_routing_diagnostics_json(&mut out, routing_diagnostics);
563 println!("{}", serde_json::to_string_pretty(&out).unwrap());
564 } else {
565 if let Some(warning) = cache_warning.as_deref() {
566 eprintln!("warning: {warning}");
567 }
568 println!(
569 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
570 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY", "ALIASES"
571 );
572 for model in models {
573 let release = model.release_date.as_deref().unwrap_or("—");
574 let harness = model.harness.as_deref().unwrap_or("—");
575 let availability = availability_status_label(model.availability.as_ref());
576 println!(
577 "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
578 model.provider,
579 model.id,
580 release,
581 harness,
582 availability,
583 model.matched_aliases.join(",")
584 );
585 }
586 }
587
588 Ok(0)
589}
590
591fn run_list_aliases_static(
592 merged: &IndexMap<String, ModelAlias>,
593 cache: &models::ModelsCache,
594 outcome: &models::RefreshOutcome,
595 visibility: &crate::config::ModelVisibility,
596 routing_diagnostics: &[String],
597 json: bool,
598) -> Result<i32, MarsError> {
599 let cache_warning = cache_warning(outcome);
600 let resolved = models::resolve_all_static(merged, cache);
601 let resolved = models::filter_by_visibility(resolved, visibility);
602
603 if json {
604 let entries: Vec<serde_json::Value> = resolved
605 .values()
606 .map(|r| {
607 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
608 serde_json::json!({
609 "name": r.name,
610 "provider": r.provider,
611 "mode": mode,
612 "model_id": r.model_id,
613 "resolved_model": r.model_id,
614 "description": r.description,
615 })
616 })
617 .collect();
618 let mut out = serde_json::json!({
619 "aliases": entries,
620 "cache_available": cache.fetched_at.is_some(),
621 });
622 if let Some(warning) = cache_warning.as_deref() {
623 out["cache_warning"] = serde_json::json!(warning);
624 }
625 add_routing_diagnostics_json(&mut out, routing_diagnostics);
626 println!("{}", serde_json::to_string_pretty(&out).unwrap());
627 return Ok(0);
628 }
629
630 if let Some(warning) = cache_warning.as_deref() {
631 eprintln!("warning: {warning}");
632 }
633 println!(
634 "{:<12} {:<14} {:<30} {}",
635 "ALIAS", "MODE", "RESOLVED", "DESCRIPTION"
636 );
637 for r in resolved.values() {
638 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
639 let desc = r.description.clone().unwrap_or_default();
640 println!("{:<12} {:<14} {:<30} {}", r.name, mode, r.model_id, desc);
641 }
642 Ok(0)
643}
644
645fn run_list_all_static(
646 merged: &IndexMap<String, ModelAlias>,
647 cache: &models::ModelsCache,
648 outcome: &models::RefreshOutcome,
649 visibility: &crate::config::ModelVisibility,
650 routing_diagnostics: &[String],
651 json: bool,
652) -> Result<i32, MarsError> {
653 let cache_warning = cache_warning(outcome);
654 let models = collect_all_model_entries_static(merged, cache);
655 let models = filter_model_entries_by_visibility(models, visibility);
656
657 if json {
658 let entries: Vec<serde_json::Value> = models
659 .into_iter()
660 .map(|model| {
661 serde_json::json!({
662 "id": model.id,
663 "provider": model.provider,
664 "release_date": model.release_date,
665 "description": model.description,
666 "cost_input": model.cost_input,
667 "cost_output": model.cost_output,
668 "cost_cache_read": model.cost_cache_read,
669 "cost_cache_write": model.cost_cache_write,
670 "cost_reasoning": model.cost_reasoning,
671 "matched_aliases": model.matched_aliases,
672 })
673 })
674 .collect();
675 let mut out = serde_json::json!({
676 "models": entries,
677 "cache_available": cache.fetched_at.is_some(),
678 });
679 if let Some(warning) = cache_warning.as_deref() {
680 out["cache_warning"] = serde_json::json!(warning);
681 }
682 add_routing_diagnostics_json(&mut out, routing_diagnostics);
683 println!("{}", serde_json::to_string_pretty(&out).unwrap());
684 return Ok(0);
685 }
686
687 if let Some(warning) = cache_warning.as_deref() {
688 eprintln!("warning: {warning}");
689 }
690 println!(
691 "{:<10} {:<34} {:<12} {}",
692 "PROVIDER", "MODEL ID", "RELEASE", "ALIASES"
693 );
694 for model in models {
695 let release = model.release_date.as_deref().unwrap_or("—");
696 println!(
697 "{:<10} {:<34} {:<12} {}",
698 model.provider,
699 model.id,
700 release,
701 model.matched_aliases.join(",")
702 );
703 }
704 Ok(0)
705}
706
707fn run_list_catalog_static(input: ListCatalogStaticInput<'_>) -> Result<i32, MarsError> {
708 let ListCatalogStaticInput {
709 cache,
710 outcome,
711 visibility,
712 routing_diagnostics,
713 json,
714 } = input;
715 let cache_warning = cache_warning(outcome);
716 let models = collect_catalog_model_entries_static(cache);
717 let models = filter_model_entries_by_visibility(models, visibility);
718
719 if json {
720 let entries: Vec<serde_json::Value> = models
721 .into_iter()
722 .map(|model| {
723 serde_json::json!({
724 "provider": model.provider,
725 "id": model.id,
726 "release_date": model.release_date,
727 "description": model.description,
728 "cost_input": model.cost_input,
729 "cost_output": model.cost_output,
730 "cost_cache_read": model.cost_cache_read,
731 "cost_cache_write": model.cost_cache_write,
732 "cost_reasoning": model.cost_reasoning,
733 })
734 })
735 .collect();
736 let mut out = serde_json::json!({
737 "catalog": entries,
738 "cache_available": cache.fetched_at.is_some(),
739 });
740 if let Some(warning) = cache_warning.as_deref() {
741 out["cache_warning"] = serde_json::json!(warning);
742 }
743 add_routing_diagnostics_json(&mut out, routing_diagnostics);
744 println!("{}", serde_json::to_string_pretty(&out).unwrap());
745 return Ok(0);
746 }
747
748 if let Some(warning) = cache_warning.as_deref() {
749 eprintln!("warning: {warning}");
750 }
751 println!("{:<10} {:<34} {:<12}", "PROVIDER", "MODEL ID", "RELEASE");
752 for model in models {
753 let release = model.release_date.as_deref().unwrap_or("—");
754 println!("{:<10} {:<34} {:<12}", model.provider, model.id, release);
755 }
756 Ok(0)
757}
758
759fn run_list_catalog(input: ListCatalogInput<'_>) -> Result<i32, MarsError> {
760 let ListCatalogInput {
761 cache,
762 outcome,
763 args,
764 visibility,
765 routing_settings,
766 routing_diagnostics,
767 capability_snapshot,
768 json,
769 } = input;
770 let cache_warning = cache_warning(outcome);
771 let installed = capability_snapshot.installed_harnesses();
772 let is_offline = capability_snapshot.offline || args.no_refresh_models;
773 let probe_result = capability_snapshot.opencode.result().cloned();
774 let pi_probe_result = capability_snapshot.pi.result().cloned();
775 let cursor_probe_result = capability_snapshot.cursor.result().cloned();
776 let catalog_slugs = models::catalog_model_slugs(cache);
777 let availability_ctx = AvailabilityContext {
778 installed: &installed,
779 opencode_probe_result: probe_result.as_ref(),
780 pi_probe_result: pi_probe_result.as_ref(),
781 cursor_probe_result: cursor_probe_result.as_ref(),
782 catalog_model_slugs: Some(catalog_slugs.as_slice()),
783 is_offline,
784 routing_settings,
785 };
786 let models = collect_catalog_model_entries(cache, availability_ctx);
787 let models = filter_model_entries_by_visibility(models, visibility);
788
789 if json {
790 let entries: Vec<serde_json::Value> = models
791 .into_iter()
792 .map(|model| {
793 let mut obj = serde_json::json!({
794 "id": model.id,
795 "provider": model.provider,
796 "release_date": model.release_date,
797 "harness": model.harness,
798 "harness_source": model.harness_source,
799 "harness_candidates": model.harness_candidates,
800 "description": model.description,
801 "cost_input": model.cost_input,
802 "cost_output": model.cost_output,
803 "cost_cache_read": model.cost_cache_read,
804 "cost_cache_write": model.cost_cache_write,
805 "cost_reasoning": model.cost_reasoning,
806 });
807 add_availability_json_fields(&mut obj, model.availability.as_ref());
808 obj
809 })
810 .collect();
811 let mut out = serde_json::json!({
812 "models": entries,
813 "cache_available": cache.fetched_at.is_some(),
814 });
815 add_probe_results_json(
816 &mut out,
817 probe_result.as_ref(),
818 pi_probe_result.as_ref(),
819 cursor_probe_result.as_ref(),
820 );
821 if let Some(warning) = cache_warning.as_deref() {
822 out["cache_warning"] = serde_json::json!(warning);
823 }
824 add_routing_diagnostics_json(&mut out, routing_diagnostics);
825 println!("{}", serde_json::to_string_pretty(&out).unwrap());
826 } else {
827 if let Some(warning) = cache_warning.as_deref() {
828 eprintln!("warning: {warning}");
829 }
830 println!(
831 "{:<10} {:<34} {:<12} {:<10} {:<12}",
832 "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY"
833 );
834 for model in models {
835 let release = model.release_date.as_deref().unwrap_or("—");
836 let harness = model.harness.as_deref().unwrap_or("—");
837 let availability = availability_status_label(model.availability.as_ref());
838 println!(
839 "{:<10} {:<34} {:<12} {:<10} {:<12}",
840 model.provider, model.id, release, harness, availability
841 );
842 }
843 }
844
845 Ok(0)
846}
847
848fn collect_all_model_entries(
849 merged: &IndexMap<String, ModelAlias>,
850 cache: &models::ModelsCache,
851 availability_ctx: AvailabilityContext<'_>,
852) -> Vec<ListModelEntry> {
853 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
854
855 for (alias_name, alias) in merged {
856 match &alias.spec {
857 ModelSpec::AutoResolve {
858 provider,
859 match_patterns,
860 exclude_patterns,
861 } => {
862 for matched in models::auto_resolve_all(
863 provider.as_deref(),
864 match_patterns,
865 exclude_patterns,
866 cache,
867 ) {
868 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
869 }
870 }
871 ModelSpec::Pinned {
872 model, provider, ..
873 } => {
874 if let Some(matched) = cache
875 .models
876 .iter()
877 .find(|cache_model| cache_model.id == *model)
878 {
879 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
880 } else {
881 append_pinned_alias_match(
882 &mut by_model_id,
883 model,
884 provider.as_deref(),
885 alias.description.as_deref(),
886 availability_ctx,
887 alias_name,
888 );
889 }
890 }
891 ModelSpec::PinnedWithMatch {
892 model,
893 provider,
894 match_patterns,
895 exclude_patterns,
896 } => {
897 if let Some(matched) = cache
898 .models
899 .iter()
900 .find(|cache_model| cache_model.id == *model)
901 {
902 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
903 } else {
904 append_pinned_alias_match(
905 &mut by_model_id,
906 model,
907 provider.as_deref(),
908 alias.description.as_deref(),
909 availability_ctx,
910 alias_name,
911 );
912 }
913
914 let provider_for_discovery = provider
915 .as_deref()
916 .or_else(|| models::infer_provider_from_model_id(model));
917 for matched in models::auto_resolve_all(
918 provider_for_discovery,
919 match_patterns,
920 exclude_patterns,
921 cache,
922 ) {
923 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
924 }
925 }
926 }
927 }
928
929 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
930 sort_list_model_entries(&mut out);
931 out
932}
933
934fn collect_catalog_model_entries(
935 cache: &models::ModelsCache,
936 availability_ctx: AvailabilityContext<'_>,
937) -> Vec<ListModelEntry> {
938 let mut out: Vec<ListModelEntry> = cache
939 .models
940 .iter()
941 .map(|model| model_entry_for_cached(model, availability_ctx))
942 .collect();
943 sort_list_model_entries(&mut out);
944 out
945}
946
947fn collect_all_model_entries_static(
948 merged: &IndexMap<String, ModelAlias>,
949 cache: &models::ModelsCache,
950) -> Vec<ListModelEntry> {
951 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
952
953 for (alias_name, alias) in merged {
954 match &alias.spec {
955 ModelSpec::AutoResolve {
956 provider,
957 match_patterns,
958 exclude_patterns,
959 } => {
960 for matched in models::auto_resolve_all(
961 provider.as_deref(),
962 match_patterns,
963 exclude_patterns,
964 cache,
965 ) {
966 let entry = by_model_id
967 .entry(matched.id.clone())
968 .or_insert_with(|| model_entry_for_cached_static(matched));
969 append_alias_name(entry, alias_name);
970 }
971 }
972 ModelSpec::Pinned {
973 model, provider, ..
974 } => {
975 let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
976 cache
977 .models
978 .iter()
979 .find(|cache_model| cache_model.id == *model)
980 .map(model_entry_for_cached_static)
981 .unwrap_or_else(|| {
982 model_entry_for_pinned_static(
983 model,
984 provider.as_deref(),
985 alias.description.as_deref(),
986 )
987 })
988 });
989 append_alias_name(entry, alias_name);
990 }
991 ModelSpec::PinnedWithMatch {
992 model,
993 provider,
994 match_patterns,
995 exclude_patterns,
996 } => {
997 let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
998 cache
999 .models
1000 .iter()
1001 .find(|cache_model| cache_model.id == *model)
1002 .map(model_entry_for_cached_static)
1003 .unwrap_or_else(|| {
1004 model_entry_for_pinned_static(
1005 model,
1006 provider.as_deref(),
1007 alias.description.as_deref(),
1008 )
1009 })
1010 });
1011 append_alias_name(entry, alias_name);
1012
1013 let provider_for_discovery = provider
1014 .as_deref()
1015 .or_else(|| models::infer_provider_from_model_id(model));
1016 for matched in models::auto_resolve_all(
1017 provider_for_discovery,
1018 match_patterns,
1019 exclude_patterns,
1020 cache,
1021 ) {
1022 let entry = by_model_id
1023 .entry(matched.id.clone())
1024 .or_insert_with(|| model_entry_for_cached_static(matched));
1025 append_alias_name(entry, alias_name);
1026 }
1027 }
1028 }
1029 }
1030
1031 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
1032 sort_list_model_entries(&mut out);
1033 out
1034}
1035
1036fn collect_catalog_model_entries_static(cache: &models::ModelsCache) -> Vec<ListModelEntry> {
1037 let mut out: Vec<ListModelEntry> = cache
1038 .models
1039 .iter()
1040 .map(model_entry_for_cached_static)
1041 .collect();
1042 sort_list_model_entries(&mut out);
1043 out
1044}
1045
1046fn append_alias_match(
1047 by_model_id: &mut IndexMap<String, ListModelEntry>,
1048 model: &models::CachedModel,
1049 availability_ctx: AvailabilityContext<'_>,
1050 alias_name: &str,
1051) {
1052 let entry = by_model_id
1053 .entry(model.id.clone())
1054 .or_insert_with(|| model_entry_for_cached(model, availability_ctx));
1055
1056 append_alias_name(entry, alias_name);
1057}
1058
1059fn append_pinned_alias_match(
1060 by_model_id: &mut IndexMap<String, ListModelEntry>,
1061 model_id: &str,
1062 provider: Option<&str>,
1063 description: Option<&str>,
1064 availability_ctx: AvailabilityContext<'_>,
1065 alias_name: &str,
1066) {
1067 let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
1068 model_entry_for_pinned(model_id, provider, description, availability_ctx)
1069 });
1070
1071 append_alias_name(entry, alias_name);
1072}
1073
1074fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
1075 if !entry
1076 .matched_aliases
1077 .iter()
1078 .any(|existing| existing == alias_name)
1079 {
1080 entry.matched_aliases.push(alias_name.to_string());
1081 }
1082}
1083
1084fn model_entry_for_cached(
1085 model: &models::CachedModel,
1086 availability_ctx: AvailabilityContext<'_>,
1087) -> ListModelEntry {
1088 model_entry_for_cached_with_auth(
1089 model,
1090 availability_ctx,
1091 models::harness::native_harness_authenticated,
1092 )
1093}
1094
1095fn model_entry_for_cached_with_auth<F>(
1096 model: &models::CachedModel,
1097 availability_ctx: AvailabilityContext<'_>,
1098 auth_check: F,
1099) -> ListModelEntry
1100where
1101 F: Fn(&str) -> bool,
1102{
1103 let (harness, harness_source) =
1104 resolve_harness_with_routing_auth(&model.provider, &model.id, availability_ctx, auth_check);
1105
1106 ListModelEntry {
1107 id: model.id.clone(),
1108 provider: model.provider.clone(),
1109 release_date: model.release_date.clone(),
1110 harness,
1111 harness_source,
1112 harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
1113 description: model.description.clone(),
1114 cost_input: model.cost_input,
1115 cost_output: model.cost_output,
1116 cost_cache_read: model.cost_cache_read,
1117 cost_cache_write: model.cost_cache_write,
1118 cost_reasoning: model.cost_reasoning,
1119 matched_aliases: Vec::new(),
1120 availability: Some(models::availability::classify_model(
1121 &model.id,
1122 &model.provider,
1123 availability_ctx.installed,
1124 availability_ctx.opencode_probe_result,
1125 availability_ctx.pi_probe_result,
1126 availability_ctx.cursor_probe_result,
1127 availability_ctx.is_offline,
1128 )),
1129 }
1130}
1131
1132fn model_entry_for_pinned(
1133 model_id: &str,
1134 provider: Option<&str>,
1135 description: Option<&str>,
1136 availability_ctx: AvailabilityContext<'_>,
1137) -> ListModelEntry {
1138 let provider = provider
1139 .map(str::to_string)
1140 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1141 .unwrap_or_else(|| "unknown".to_string());
1142 let (harness, harness_source) =
1143 resolve_harness_with_routing(&provider, model_id, availability_ctx);
1144
1145 ListModelEntry {
1146 id: model_id.to_string(),
1147 provider: provider.clone(),
1148 release_date: None,
1149 harness,
1150 harness_source,
1151 harness_candidates: models::harness::harness_candidates_for_provider(&provider),
1152 description: description.map(str::to_string),
1153 cost_input: None,
1154 cost_output: None,
1155 cost_cache_read: None,
1156 cost_cache_write: None,
1157 cost_reasoning: None,
1158 matched_aliases: Vec::new(),
1159 availability: Some(models::availability::classify_model(
1160 model_id,
1161 &provider,
1162 availability_ctx.installed,
1163 availability_ctx.opencode_probe_result,
1164 availability_ctx.pi_probe_result,
1165 availability_ctx.cursor_probe_result,
1166 availability_ctx.is_offline,
1167 )),
1168 }
1169}
1170
1171fn model_entry_for_cached_static(model: &models::CachedModel) -> ListModelEntry {
1172 ListModelEntry {
1173 id: model.id.clone(),
1174 provider: model.provider.clone(),
1175 release_date: model.release_date.clone(),
1176 harness: None,
1177 harness_source: HarnessSource::Unavailable,
1178 harness_candidates: Vec::new(),
1179 description: model.description.clone(),
1180 cost_input: model.cost_input,
1181 cost_output: model.cost_output,
1182 cost_cache_read: model.cost_cache_read,
1183 cost_cache_write: model.cost_cache_write,
1184 cost_reasoning: model.cost_reasoning,
1185 matched_aliases: Vec::new(),
1186 availability: None,
1187 }
1188}
1189
1190fn model_entry_for_pinned_static(
1191 model_id: &str,
1192 provider: Option<&str>,
1193 description: Option<&str>,
1194) -> ListModelEntry {
1195 let provider = provider
1196 .map(str::to_string)
1197 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1198 .unwrap_or_else(|| "unknown".to_string());
1199 ListModelEntry {
1200 id: model_id.to_string(),
1201 provider,
1202 release_date: None,
1203 harness: None,
1204 harness_source: HarnessSource::Unavailable,
1205 harness_candidates: Vec::new(),
1206 description: description.map(str::to_string),
1207 cost_input: None,
1208 cost_output: None,
1209 cost_cache_read: None,
1210 cost_cache_write: None,
1211 cost_reasoning: None,
1212 matched_aliases: Vec::new(),
1213 availability: None,
1214 }
1215}
1216
1217fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
1218 entries.sort_by(|a, b| {
1219 a.provider
1220 .to_ascii_lowercase()
1221 .cmp(&b.provider.to_ascii_lowercase())
1222 .then_with(|| {
1223 b.release_date
1224 .as_deref()
1225 .unwrap_or("")
1226 .cmp(a.release_date.as_deref().unwrap_or(""))
1227 })
1228 .then_with(|| a.id.cmp(&b.id))
1229 });
1230}
1231
1232fn routing_settings_evidence<'a>(
1233 input: &'a RouteTraceInput<'a>,
1234) -> crate::routing::RoutingSettingsEvidence<'a> {
1235 crate::routing::RoutingSettingsEvidence::new(
1236 input.model_id,
1237 Some(input.provider_for_order),
1238 input.provider_constraint,
1239 input.installed,
1240 input.opencode_probe_result,
1241 input.pi_probe_result,
1242 input.cursor_probe_result,
1243 input.catalog_model_slugs,
1244 input.routing_settings,
1245 )
1246}
1247
1248fn resolve_harness_with_routing(
1249 provider: &str,
1250 model_id: &str,
1251 availability_ctx: AvailabilityContext<'_>,
1252) -> (Option<String>, HarnessSource) {
1253 resolve_harness_with_routing_auth(
1254 provider,
1255 model_id,
1256 availability_ctx,
1257 models::harness::native_harness_authenticated,
1258 )
1259}
1260
1261fn resolve_harness_with_routing_auth<F>(
1262 provider: &str,
1263 model_id: &str,
1264 availability_ctx: AvailabilityContext<'_>,
1265 auth_check: F,
1266) -> (Option<String>, HarnessSource)
1267where
1268 F: Fn(&str) -> bool,
1269{
1270 let route_input = RouteTraceInput {
1271 model_id,
1272 provider_for_order: provider,
1273 provider_constraint: None,
1274 installed: availability_ctx.installed,
1275 opencode_probe_result: availability_ctx.opencode_probe_result,
1276 pi_probe_result: availability_ctx.pi_probe_result,
1277 cursor_probe_result: availability_ctx.cursor_probe_result,
1278 catalog_model_slugs: availability_ctx.catalog_model_slugs,
1279 routing_settings: availability_ctx.routing_settings,
1280 };
1281 let routing_evidence = routing_settings_evidence(&route_input);
1282 let trace = crate::routing::evaluate_candidates_with_auth(
1283 &routing_evidence.routing_input(),
1284 auth_check,
1285 );
1286
1287 match crate::routing::acceptance::accept_route(
1288 &trace,
1289 availability_ctx.installed,
1290 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1291 ) {
1292 Ok(()) => (
1293 Some(trace.selected_harness().to_string()),
1294 HarnessSource::AutoDetected,
1295 ),
1296 Err(_) => (None, HarnessSource::Unavailable),
1297 }
1298}
1299
1300fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
1301 match &alias.spec {
1302 ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
1303 provider.clone()
1304 }
1305 ModelSpec::AutoResolve { provider, .. } => provider.clone(),
1306 }
1307 .map(|provider| provider.trim().to_ascii_lowercase())
1308}
1309
1310fn route_trace_for_resolved_model(input: &RouteTraceInput<'_>) -> crate::routing::RoutingTrace {
1311 let routing_evidence = routing_settings_evidence(input);
1312 crate::routing::evaluate_candidates(&routing_evidence.routing_input())
1313}
1314
1315fn route_trace_for_resolved_model_with_probes(
1316 input: &RouteTraceInput<'_>,
1317 probe_resolver: &mut dyn crate::routing::ProbeResolver,
1318) -> crate::routing::RoutingTrace {
1319 let routing_evidence = routing_settings_evidence(input);
1320 crate::routing::evaluate_candidates_with_auth_and_probes(
1321 &routing_evidence.routing_input(),
1322 probe_resolver,
1323 models::harness::native_harness_authenticated,
1324 )
1325}
1326
1327fn route_trace_for_fixed_harness_with_probes(
1328 input: &RouteTraceInput<'_>,
1329 fixed_harness: &str,
1330 source: crate::routing::RouteSource,
1331 probe_resolver: &mut dyn crate::routing::ProbeResolver,
1332) -> crate::routing::RoutingTrace {
1333 let provider_for_order = crate::routing::provider_for_order_for_fixed_harness(
1334 Some(input.provider_for_order),
1335 fixed_harness,
1336 );
1337 let routing_evidence = routing_settings_evidence(input);
1338 let mut fixed_input = routing_evidence.routing_input();
1339 fixed_input.provider_for_order = provider_for_order;
1340 let assessment = crate::routing::evaluate_fixed_harness_with_auth_and_probes(
1341 &fixed_input,
1342 fixed_harness,
1343 probe_resolver,
1344 models::harness::native_harness_authenticated,
1345 );
1346 crate::routing::trace_for_fixed_harness(source, fixed_harness, assessment, Vec::new())
1347}
1348
1349fn effective_visibility(
1350 project_config: Option<&crate::config::LoadedProjectConfig>,
1351 args: &ListArgs,
1352) -> crate::config::ModelVisibility {
1353 if args.include.is_some() || args.exclude.is_some() {
1354 return crate::config::ModelVisibility {
1355 include: args.include.clone(),
1356 exclude: args.exclude.clone(),
1357 };
1358 }
1359
1360 project_config
1361 .map(|loaded| loaded.effective.settings.model_visibility.clone())
1362 .unwrap_or_default()
1363}
1364
1365#[allow(clippy::too_many_arguments)]
1366fn apply_routing_settings_to_resolved_aliases(
1367 resolved: &mut IndexMap<String, models::ResolvedAlias>,
1368 aliases: &IndexMap<String, ModelAlias>,
1369 installed: &HashSet<String>,
1370 opencode_probe_result: Option<&OpenCodeProbeResult>,
1371 pi_probe_result: Option<&PiProbeResult>,
1372 cursor_probe_result: Option<&CursorProbeResult>,
1373 catalog_model_slugs: Option<&[String]>,
1374 routing_settings: &ResolvedRoutingSettings,
1375) {
1376 for alias in resolved.values_mut() {
1377 let has_explicit_harness = aliases
1378 .get(&alias.name)
1379 .is_some_and(|source_alias| source_alias.harness.is_some());
1380 if has_explicit_harness {
1381 continue;
1382 }
1383 apply_routing_settings_to_resolved_alias(
1384 alias,
1385 installed,
1386 opencode_probe_result,
1387 pi_probe_result,
1388 cursor_probe_result,
1389 catalog_model_slugs,
1390 routing_settings,
1391 );
1392 }
1393}
1394
1395fn apply_routing_settings_to_resolved_alias(
1396 alias: &mut models::ResolvedAlias,
1397 installed: &HashSet<String>,
1398 opencode_probe_result: Option<&OpenCodeProbeResult>,
1399 pi_probe_result: Option<&PiProbeResult>,
1400 cursor_probe_result: Option<&CursorProbeResult>,
1401 catalog_model_slugs: Option<&[String]>,
1402 routing_settings: &ResolvedRoutingSettings,
1403) {
1404 let provider_for_order =
1405 models::infer_provider_from_model_id(&alias.model_id).unwrap_or(alias.provider.as_str());
1406 let route_input = RouteTraceInput {
1407 model_id: &alias.model_id,
1408 provider_for_order,
1409 provider_constraint: None,
1410 installed,
1411 opencode_probe_result,
1412 pi_probe_result,
1413 cursor_probe_result,
1414 catalog_model_slugs,
1415 routing_settings,
1416 };
1417 let trace = route_trace_for_resolved_model(&route_input);
1418 alias.harness = Some(trace.harness.clone());
1419 alias.harness_source = match crate::routing::acceptance::accept_route(
1420 &trace,
1421 installed,
1422 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1423 ) {
1424 Ok(()) => HarnessSource::AutoDetected,
1425 Err(_) => HarnessSource::Unavailable,
1426 };
1427}
1428
1429fn annotate_resolved_availability(
1430 resolved: &mut IndexMap<String, models::ResolvedAlias>,
1431 installed: &HashSet<String>,
1432 opencode_probe_result: Option<&OpenCodeProbeResult>,
1433 pi_probe_result: Option<&PiProbeResult>,
1434 cursor_probe_result: Option<&CursorProbeResult>,
1435 is_offline: bool,
1436) {
1437 for alias in resolved.values_mut() {
1438 alias.availability = Some(models::availability::classify_model(
1439 &alias.model_id,
1440 &alias.provider,
1441 installed,
1442 opencode_probe_result,
1443 pi_probe_result,
1444 cursor_probe_result,
1445 is_offline,
1446 ));
1447 }
1448}
1449
1450fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
1451 resolved.retain(|_, alias| {
1452 alias
1453 .availability
1454 .as_ref()
1455 .map(|availability| availability.status != AvailabilityStatus::Unavailable)
1456 .unwrap_or(true)
1457 });
1458}
1459
1460fn filter_model_entries_by_visibility(
1461 entries: Vec<ListModelEntry>,
1462 visibility: &crate::config::ModelVisibility,
1463) -> Vec<ListModelEntry> {
1464 if visibility.include.is_none() && visibility.exclude.is_none() {
1465 return entries;
1466 }
1467
1468 entries
1469 .into_iter()
1470 .filter(|entry| {
1471 let paths = entry
1472 .availability
1473 .as_ref()
1474 .map(|availability| availability.runnable_paths.as_slice())
1475 .unwrap_or(&[]);
1476 let included = visibility.include.as_ref().is_none_or(|includes| {
1477 includes.iter().any(|pattern| {
1478 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1479 })
1480 });
1481 let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
1482 excludes.iter().any(|pattern| {
1483 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1484 })
1485 });
1486 included && !excluded
1487 })
1488 .collect()
1489}
1490
1491fn add_availability_json_fields(
1492 obj: &mut serde_json::Value,
1493 availability: Option<&ModelAvailability>,
1494) {
1495 if let Some(availability) = availability {
1496 obj["availability"] = serde_json::json!(availability.status);
1497 obj["availability_source"] = serde_json::json!(availability.source);
1498 obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
1499 }
1500}
1501
1502fn add_cost_json_fields(obj: &mut serde_json::Value, model: &models::CachedModel) {
1503 obj["cost_input"] = serde_json::json!(model.cost_input);
1504 obj["cost_output"] = serde_json::json!(model.cost_output);
1505 obj["cost_cache_read"] = serde_json::json!(model.cost_cache_read);
1506 obj["cost_cache_write"] = serde_json::json!(model.cost_cache_write);
1507 obj["cost_reasoning"] = serde_json::json!(model.cost_reasoning);
1508}
1509
1510fn add_probe_results_json(
1511 out: &mut serde_json::Value,
1512 probe_result: Option<&OpenCodeProbeResult>,
1513 pi_probe_result: Option<&PiProbeResult>,
1514 cursor_probe_result: Option<&CursorProbeResult>,
1515) {
1516 if let Some(probe) = probe_result {
1517 out["probe_results"] = serde_json::json!({
1518 "opencode": {
1519 "success": probe.model_probe_success,
1520 "models_found": probe.model_slugs.len(),
1521 }
1522 });
1523 }
1524 if let Some(probe) = pi_probe_result {
1525 if out.get("probe_results").is_none() {
1526 out["probe_results"] = serde_json::json!({});
1527 }
1528 out["probe_results"]["pi"] = serde_json::json!({
1529 "compatible": probe.compatible,
1530 "version": probe.version,
1531 "missing_surface_tokens": probe.help_surface_tokens_missing,
1532 });
1533 }
1534 if let Some(probe) = cursor_probe_result {
1535 if out.get("probe_results").is_none() {
1536 out["probe_results"] = serde_json::json!({});
1537 }
1538 out["probe_results"]["cursor"] = serde_json::json!({
1539 "success": probe.model_probe_success,
1540 "models_found": probe.slugs.len(),
1541 });
1542 }
1543}
1544
1545fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
1546 match availability.map(|value| value.status) {
1547 Some(AvailabilityStatus::Runnable) => "runnable",
1548 Some(AvailabilityStatus::Unavailable) => "unavailable",
1549 Some(AvailabilityStatus::Unknown) => "unknown",
1550 None => "unknown",
1551 }
1552}
1553
1554fn annotate_one_availability(
1555 resolved: &mut models::ResolvedAlias,
1556 args: &ResolveAliasArgs,
1557 installed: &HashSet<String>,
1558 opencode_probe_result: Option<&OpenCodeProbeResult>,
1559 pi_probe_result: Option<&PiProbeResult>,
1560 cursor_probe_result: Option<&CursorProbeResult>,
1561) {
1562 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1563 resolved.availability = Some(models::availability::classify_model(
1564 &resolved.model_id,
1565 &resolved.provider,
1566 installed,
1567 opencode_probe_result,
1568 pi_probe_result,
1569 cursor_probe_result,
1570 is_offline,
1571 ));
1572}
1573
1574fn print_availability_text(availability: Option<&ModelAvailability>) {
1575 if let Some(availability) = availability {
1576 println!(
1577 "Availability: {} ({:?})",
1578 availability_status_label(Some(availability)),
1579 availability.source
1580 );
1581 for (idx, path) in availability.runnable_paths.iter().enumerate() {
1582 let label = if idx == 0 {
1583 "Runnable via:"
1584 } else {
1585 " "
1586 };
1587 println!("{label} {} -> {}", path.harness, path.harness_model_id);
1588 }
1589 }
1590}
1591
1592fn add_route_json_fields(out: &mut serde_json::Value, trace: &crate::routing::RoutingTrace) {
1593 let report = trace.to_report();
1594 out["route"] = serde_json::json!(report.compact_summary());
1595 out["route_trace"] = serde_json::json!(report);
1596}
1597
1598fn print_route_text(trace: &crate::routing::RoutingTrace) {
1599 let report = trace.to_report();
1600 println!(
1601 "Route: {} ({}, {}, {})",
1602 trace.selected_harness(),
1603 trace.source.label(),
1604 trace.selected_selection_kind().label(),
1605 trace.selected_match_evidence().label()
1606 );
1607 if !report.candidates_tried.is_empty() {
1608 println!("Tried: {}", report.candidates_tried.join(", "));
1609 }
1610 for assessment in report.assessments {
1611 if let Some(skip_reason) = assessment.skip_reason {
1612 println!("Skip: {} ({})", assessment.harness, skip_reason);
1613 }
1614 }
1615}
1616
1617fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1618 let project_config = load_project_config_layers_optional(&ctx.project_root)?;
1619 let merged = load_merged_aliases(&ctx.project_root, project_config.as_ref())?;
1620 let mars = mars_dir(ctx);
1621 let ttl = models_cache_ttl_hours(project_config.as_ref());
1622 let refresh =
1623 models::resolve_models_refresh_control(args.refresh_models, args.no_refresh_models)?;
1624 let mode = refresh.catalog_mode;
1625 let default_settings = crate::config::Settings::default();
1626 let settings = project_config
1627 .as_ref()
1628 .map(|loaded| &loaded.effective.settings)
1629 .unwrap_or(&default_settings);
1630 let routing_settings = ResolvedRoutingSettings::from_settings(settings);
1631 let routing_diagnostics = routing_settings.diagnostic_messages();
1632 if !json {
1633 emit_routing_settings_warnings(&routing_diagnostics);
1634 }
1635
1636 let mut cache_error = None;
1638 let cache_result = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
1639 FreshOrJsonError::Fresh(cache, outcome) => Some((cache, outcome)),
1640 FreshOrJsonError::JsonError(error_message) => {
1641 cache_error = Some(error_message);
1642 None
1643 }
1644 };
1645 let mut capability_session = CapabilitySession::collect(&CapabilityCollectionOptions {
1646 offline: models::is_mars_offline(),
1647 probe_refresh: refresh.probe_refresh,
1648 });
1649 let installed = capability_session.installed_harnesses();
1650
1651 if let Some(alias) = merged.get(&args.name) {
1653 if cache_result.is_none() && matches!(alias.spec, ModelSpec::AutoResolve { .. }) {
1654 return run_auto_resolve_alias_cache_unavailable(
1655 AutoResolveAliasCacheUnavailableInput {
1656 name: &args.name,
1657 alias,
1658 project_config: project_config.as_ref(),
1659 cache_error: cache_error.as_deref(),
1660 routing_diagnostics: &routing_diagnostics,
1661 json,
1662 },
1663 );
1664 }
1665
1666 let fallback_cache = models::ModelsCache {
1667 models: Vec::new(),
1668 fetched_at: None,
1669 };
1670 let fallback_outcome = models::RefreshOutcome::Offline;
1671 let fallback_catalog_slugs = models::catalog_model_slugs(&fallback_cache);
1672 let cache_catalog_slugs = cache_result
1673 .as_ref()
1674 .map(|(cache, _)| models::catalog_model_slugs(cache));
1675 let (cache, outcome) = cache_result
1676 .as_ref()
1677 .map(|(cache, outcome)| (cache, outcome))
1678 .unwrap_or((&fallback_cache, &fallback_outcome));
1679 let catalog_model_slugs = cache_catalog_slugs
1680 .as_deref()
1681 .unwrap_or(fallback_catalog_slugs.as_slice());
1682
1683 let runtime = ResolveRuntime {
1684 cache,
1685 catalog_model_slugs,
1686 outcome,
1687 installed: &installed,
1688 routing_settings: &routing_settings,
1689 probe_refresh: refresh.probe_refresh,
1690 };
1691 return run_resolve_exact_alias(
1692 ResolveExactAliasInput {
1693 args,
1694 alias,
1695 merged: &merged,
1696 project_config: project_config.as_ref(),
1697 runtime,
1698 routing_diagnostics: &routing_diagnostics,
1699 json,
1700 },
1701 &mut capability_session,
1702 );
1703 }
1704
1705 if let Some((cache, outcome)) = &cache_result
1707 && let Some(mut resolved) = models::resolve_with_alias_prefix_with_probe(
1708 &args.name, &merged, cache, None, None, None,
1709 )
1710 {
1711 let catalog_slugs = models::catalog_model_slugs(cache);
1712 let route_input = RouteTraceInput {
1713 model_id: &resolved.model_id,
1714 provider_for_order: models::infer_provider_from_model_id(&resolved.model_id)
1715 .unwrap_or(resolved.provider.as_str()),
1716 provider_constraint: None,
1717 installed: &installed,
1718 opencode_probe_result: None,
1719 pi_probe_result: None,
1720 cursor_probe_result: None,
1721 catalog_model_slugs: Some(catalog_slugs.as_slice()),
1722 routing_settings: &routing_settings,
1723 };
1724 let route_trace = {
1725 let mut probe_resolver = SessionProbeResolver {
1726 session: &mut capability_session,
1727 };
1728 route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1729 };
1730 resolved.harness = Some(route_trace.selected_harness().to_string());
1731 resolved.harness_source = match crate::routing::acceptance::accept_route(
1732 &route_trace,
1733 &installed,
1734 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1735 ) {
1736 Ok(()) => HarnessSource::AutoDetected,
1737 Err(_) => HarnessSource::Unavailable,
1738 };
1739 annotate_one_availability(
1740 &mut resolved,
1741 args,
1742 &installed,
1743 capability_session.loaded_opencode_probe_result(),
1744 capability_session.loaded_pi_probe_result(),
1745 capability_session.loaded_cursor_probe_result(),
1746 );
1747 let cache_outcome = capability_session
1748 .loaded_opencode_outcome()
1749 .cloned()
1750 .unwrap_or(CachedProbeOutcome::Unavailable);
1751 return run_output_resolved(OutputResolvedInput {
1752 name: &args.name,
1753 resolved: &resolved,
1754 source: "alias_prefix",
1755 route_trace: &route_trace,
1756 outcome,
1757 cache_outcome: &cache_outcome,
1758 probe_refresh: refresh.probe_refresh,
1759 routing_diagnostics: &routing_diagnostics,
1760 json,
1761 });
1762 }
1763
1764 let outcome = cache_result
1766 .as_ref()
1767 .map(|(_, o)| o.clone())
1768 .unwrap_or(models::RefreshOutcome::Offline);
1769 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1770 let passthrough_catalog_slugs = cache_result
1771 .as_ref()
1772 .map(|(cache, _)| models::catalog_model_slugs(cache));
1773 run_output_passthrough(OutputPassthroughInput {
1774 name: &args.name,
1775 outcome: &outcome,
1776 is_offline,
1777 installed: &installed,
1778 capability_session: &mut capability_session,
1779 catalog_model_slugs: passthrough_catalog_slugs.as_deref(),
1780 routing_settings: &routing_settings,
1781 cache_error: cache_error.as_deref(),
1782 routing_diagnostics: &routing_diagnostics,
1783 json,
1784 })
1785}
1786
1787fn run_refresh_probe(args: &RefreshProbeArgs) -> Result<i32, MarsError> {
1788 match args.target.as_str() {
1789 "opencode" => opencode_cache::run_refresh_probe_command(),
1790 "pi" => pi_cache::run_refresh_probe_command(),
1791 "cursor" => cursor_cache::run_refresh_probe_command(),
1792 _ => Ok(1),
1793 }
1794}
1795
1796fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1797 let normalized_harness =
1798 models::harness::normalize_harness_name(&args.harness).ok_or_else(|| {
1799 MarsError::Config(ConfigError::Invalid {
1800 message: format!(
1801 "invalid harness '{}'; valid harnesses: {}",
1802 args.harness,
1803 models::harness::VALID_HARNESSES.join(", ")
1804 ),
1805 })
1806 })?;
1807 let mut config = crate::config::load(&ctx.project_root)?;
1808 config.models.insert(
1809 args.name.clone(),
1810 ModelAlias {
1811 harness: Some(normalized_harness.clone()),
1812 description: args.description.clone(),
1813 default_effort: None,
1814 autocompact: None,
1815 autocompact_pct: None,
1816 spec: ModelSpec::Pinned {
1817 model: args.model_id.clone(),
1818 provider: None,
1819 },
1820 },
1821 );
1822 crate::config::save(&ctx.project_root, &config)?;
1823
1824 if json {
1825 println!(
1826 "{}",
1827 serde_json::to_string_pretty(&serde_json::json!({
1828 "status": "ok",
1829 "alias": args.name,
1830 "model": args.model_id,
1831 "harness": normalized_harness,
1832 }))
1833 .unwrap()
1834 );
1835 } else {
1836 println!(
1837 "Added alias `{}` → {} (harness: {})",
1838 args.name, args.model_id, normalized_harness
1839 );
1840 }
1841
1842 Ok(0)
1843}
1844
1845enum FreshOrJsonError {
1846 Fresh(models::ModelsCache, models::RefreshOutcome),
1847 JsonError(String),
1848}
1849
1850fn ensure_fresh_or_json_error(
1851 mars: &std::path::Path,
1852 ttl: u32,
1853 mode: models::RefreshMode,
1854 json: bool,
1855) -> Result<FreshOrJsonError, MarsError> {
1856 match models::ensure_fresh(mars, ttl, mode) {
1857 Ok((cache, outcome)) => Ok(FreshOrJsonError::Fresh(cache, outcome)),
1858 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
1859 Ok(FreshOrJsonError::JsonError(format!("{err}")))
1860 }
1861 Err(err) => Err(err),
1862 }
1863}
1864
1865struct ResolveExactAliasInput<'a> {
1866 args: &'a ResolveAliasArgs,
1867 alias: &'a ModelAlias,
1868 merged: &'a IndexMap<String, ModelAlias>,
1869 project_config: Option<&'a crate::config::LoadedProjectConfig>,
1870 runtime: ResolveRuntime<'a>,
1871 routing_diagnostics: &'a [String],
1872 json: bool,
1873}
1874
1875fn run_resolve_exact_alias(
1876 input: ResolveExactAliasInput<'_>,
1877 capability_session: &mut CapabilitySession,
1878) -> Result<i32, MarsError> {
1879 let ResolveExactAliasInput {
1880 args,
1881 alias,
1882 merged,
1883 project_config,
1884 runtime,
1885 routing_diagnostics,
1886 json,
1887 } = input;
1888 let cache_warning = cache_warning(runtime.outcome);
1889 if let Some(warning) = cache_warning.as_deref()
1890 && !json
1891 {
1892 eprintln!("warning: {warning}");
1893 }
1894
1895 let name = &args.name;
1896 let source = determine_source(name, project_config);
1897 let mut diag = DiagnosticCollector::new();
1898 let mut resolved_entry =
1899 models::resolve_one_with_probe(name, merged, runtime.cache, &mut diag, None, None, None);
1900 let mut route_trace = None;
1901 let mut fixed_harness_route_rejection = None;
1902 if let Some(r) = resolved_entry.as_mut() {
1903 let provider_constraint = provider_constraint_for_alias(alias);
1904 let route_input = RouteTraceInput {
1905 model_id: &r.model_id,
1906 provider_for_order: &r.provider,
1907 provider_constraint: provider_constraint.as_deref(),
1908 installed: runtime.installed,
1909 opencode_probe_result: None,
1910 pi_probe_result: None,
1911 cursor_probe_result: None,
1912 catalog_model_slugs: Some(runtime.catalog_model_slugs),
1913 routing_settings: runtime.routing_settings,
1914 };
1915 route_trace = Some(if let Some(fixed_harness) = alias.harness.as_deref() {
1916 let mut probe_resolver = SessionProbeResolver {
1917 session: capability_session,
1918 };
1919 let fixed_trace = route_trace_for_fixed_harness_with_probes(
1920 &route_input,
1921 fixed_harness,
1922 crate::routing::RouteSource::Alias,
1923 &mut probe_resolver,
1924 );
1925 let assessed = fixed_trace
1926 .assessments
1927 .iter()
1928 .find(|assessment| assessment.harness == fixed_harness)
1929 .or_else(|| fixed_trace.assessments.first());
1930 fixed_harness_route_rejection = match assessed {
1931 Some(assessment) => crate::routing::acceptance::accept_assessment(assessment).err(),
1932 None => Some(
1933 crate::routing::acceptance::RejectionReason::AssessmentFailed {
1934 harness: fixed_harness.to_string(),
1935 skip_reason: Some("missing_assessment".to_string()),
1936 },
1937 ),
1938 };
1939 fixed_trace
1940 } else {
1941 let mut probe_resolver = SessionProbeResolver {
1942 session: capability_session,
1943 };
1944 route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1945 });
1946 if let Some(trace) = route_trace.as_ref() {
1947 r.harness = Some(trace.selected_harness().to_string());
1948 r.harness_source = match crate::routing::acceptance::accept_route(
1949 trace,
1950 runtime.installed,
1951 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1952 ) {
1953 Ok(()) => HarnessSource::AutoDetected,
1954 Err(_) => HarnessSource::Unavailable,
1955 };
1956 if alias.harness.is_some() {
1957 r.harness_source = HarnessSource::Explicit;
1958 }
1959 }
1960 annotate_one_availability(
1961 r,
1962 args,
1963 runtime.installed,
1964 capability_session.loaded_opencode_probe_result(),
1965 capability_session.loaded_pi_probe_result(),
1966 capability_session.loaded_cursor_probe_result(),
1967 );
1968 }
1969 let diagnostics = diag.drain();
1970 let probe_outcome = capability_session
1971 .loaded_opencode_outcome()
1972 .cloned()
1973 .unwrap_or(CachedProbeOutcome::Unavailable);
1974
1975 if let Some(rejection_reason) = fixed_harness_route_rejection {
1976 let trace = route_trace
1977 .as_ref()
1978 .expect("fixed harness route trace exists");
1979 let Some(resolved) = resolved_entry.as_ref() else {
1980 return Ok(1);
1981 };
1982 return run_resolve_fixed_harness_failure(ResolveFixedHarnessFailureInput {
1983 name,
1984 source: source.as_str(),
1985 resolved,
1986 trace,
1987 cache_warning: cache_warning.as_deref(),
1988 diagnostics: &diagnostics,
1989 rejection_reason: &rejection_reason,
1990 routing_diagnostics,
1991 json,
1992 });
1993 }
1994
1995 if json {
1996 if let Some(r) = resolved_entry.as_ref() {
1997 let mut out = serde_json::json!({
1998 "name": r.name,
1999 "source": source,
2000 "provider": r.provider,
2001 "harness": r.harness,
2002 "harness_source": r.harness_source,
2003 "harness_candidates": r.harness_candidates,
2004 "model_id": r.model_id,
2005 "resolved_model": r.model_id,
2006 "spec": format_spec(&alias.spec),
2007 "description": r.description,
2008 });
2009 out["probe_cache"] = serde_json::json!(probe_outcome.cache_status());
2010 if let Some(error) = unavailable_harness_error(r) {
2011 out["error"] = serde_json::json!(error);
2012 }
2013 if let Some(default_effort) = &r.default_effort {
2014 out["default_effort"] = serde_json::json!(default_effort);
2015 }
2016 if let Some(autocompact) = r.autocompact {
2017 out["autocompact"] = serde_json::json!(autocompact);
2018 }
2019 if let Some(autocompact_pct) = r.autocompact_pct {
2020 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2021 }
2022 add_availability_json_fields(&mut out, r.availability.as_ref());
2023 if let Some(warning) = cache_warning.as_deref() {
2024 out["cache_warning"] = serde_json::json!(warning);
2025 }
2026 if !diagnostics.is_empty() {
2027 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2028 }
2029 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2030 if let Some(trace) = route_trace.as_ref() {
2031 add_route_json_fields(&mut out, trace);
2032 }
2033 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2034 } else {
2035 let mut out = serde_json::json!({
2036 "error": format!("alias `{}` did not resolve to a model ID", name),
2037 });
2038 if let Some(warning) = cache_warning.as_deref() {
2039 out["cache_warning"] = serde_json::json!(warning);
2040 }
2041 if !diagnostics.is_empty() {
2042 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2043 }
2044 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2045 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2046 return Ok(1);
2047 }
2048 } else {
2049 if runtime.probe_refresh == ProbeRefreshMode::Background
2050 && matches!(probe_outcome, CachedProbeOutcome::Stale(_))
2051 {
2052 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2053 }
2054 let Some(r) = resolved_entry.as_ref() else {
2055 eprintln!("error: alias `{}` did not resolve to a model ID", name);
2056 return Ok(1);
2057 };
2058 let harness = r.harness.as_deref().unwrap_or("—");
2059 println!("Alias: {}", name);
2060 println!("Source: {}", source);
2061 println!(
2062 "Harness: {} ({})",
2063 harness,
2064 harness_source_label(&r.harness_source)
2065 );
2066 println!("Provider: {}", r.provider);
2067 match &alias.spec {
2068 ModelSpec::Pinned { model, provider: _ } => {
2069 println!("Mode: pinned");
2070 println!("Model: {}", model);
2071 }
2072 ModelSpec::PinnedWithMatch {
2073 model,
2074 provider: _,
2075 match_patterns,
2076 exclude_patterns,
2077 } => {
2078 println!("Mode: pinned");
2079 println!("Model: {}", model);
2080 println!("Match: {}", match_patterns.join(", "));
2081 if !exclude_patterns.is_empty() {
2082 println!("Exclude: {}", exclude_patterns.join(", "));
2083 }
2084 println!("Resolved: {}", r.model_id);
2085 }
2086 ModelSpec::AutoResolve {
2087 provider: _,
2088 match_patterns,
2089 exclude_patterns,
2090 } => {
2091 println!("Mode: auto-resolve");
2092 println!("Match: {}", match_patterns.join(", "));
2093 if !exclude_patterns.is_empty() {
2094 println!("Exclude: {}", exclude_patterns.join(", "));
2095 }
2096 println!("Resolved: {}", r.model_id);
2097 }
2098 }
2099 if let Some(error) = unavailable_harness_error(r) {
2100 println!("Error: {}", error);
2101 }
2102 print_availability_text(r.availability.as_ref());
2103 if let Some(desc) = &r.description {
2104 println!("Desc: {}", desc);
2105 }
2106 if let Some(trace) = route_trace.as_ref() {
2107 print_route_text(trace);
2108 }
2109 emit_drained_text_diagnostics(&diagnostics);
2110 }
2111
2112 Ok(0)
2113}
2114
2115struct ResolveFixedHarnessFailureInput<'a> {
2116 name: &'a str,
2117 source: &'a str,
2118 resolved: &'a models::ResolvedAlias,
2119 trace: &'a crate::routing::RoutingTrace,
2120 cache_warning: Option<&'a str>,
2121 diagnostics: &'a [Diagnostic],
2122 rejection_reason: &'a crate::routing::acceptance::RejectionReason,
2123 routing_diagnostics: &'a [String],
2124 json: bool,
2125}
2126
2127struct AutoResolveAliasCacheUnavailableInput<'a> {
2128 name: &'a str,
2129 alias: &'a ModelAlias,
2130 project_config: Option<&'a crate::config::LoadedProjectConfig>,
2131 cache_error: Option<&'a str>,
2132 routing_diagnostics: &'a [String],
2133 json: bool,
2134}
2135
2136fn run_auto_resolve_alias_cache_unavailable(
2137 input: AutoResolveAliasCacheUnavailableInput<'_>,
2138) -> Result<i32, MarsError> {
2139 let AutoResolveAliasCacheUnavailableInput {
2140 name,
2141 alias,
2142 project_config,
2143 cache_error,
2144 routing_diagnostics,
2145 json,
2146 } = input;
2147 let source = determine_source(name, project_config);
2148 let detail = cache_error.unwrap_or("models cache unavailable");
2149 let error = format!(
2150 "alias `{name}` requires models cache for auto-resolve, but cache is unavailable ({detail})"
2151 );
2152
2153 if json {
2154 let mut out = serde_json::json!({
2155 "name": name,
2156 "source": source,
2157 "spec": format_spec(&alias.spec),
2158 "error": error,
2159 });
2160 if let Some(cache_error) = cache_error {
2161 out["cache_error"] = serde_json::json!(cache_error);
2162 }
2163 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2164 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2165 } else {
2166 eprintln!("error: {error}");
2167 }
2168
2169 Ok(1)
2170}
2171
2172fn run_resolve_fixed_harness_failure(
2173 input: ResolveFixedHarnessFailureInput<'_>,
2174) -> Result<i32, MarsError> {
2175 let ResolveFixedHarnessFailureInput {
2176 name,
2177 source,
2178 resolved,
2179 trace,
2180 cache_warning,
2181 diagnostics,
2182 rejection_reason,
2183 routing_diagnostics,
2184 json,
2185 } = input;
2186 let error_message = fixed_alias_rejection_message(rejection_reason);
2187
2188 if json {
2189 let mut out = serde_json::json!({
2190 "name": name,
2191 "source": source,
2192 "provider": resolved.provider,
2193 "harness": trace.selected_harness(),
2194 "model_id": resolved.model_id,
2195 "resolved_model": resolved.model_id,
2196 "error": error_message,
2197 "route_rejection": route_rejection_json(rejection_reason),
2198 "harnesses_tried": trace.candidates_tried,
2199 });
2200 add_route_json_fields(&mut out, trace);
2201 if let Some(warning) = cache_warning {
2202 out["cache_warning"] = serde_json::json!(warning);
2203 }
2204 if !diagnostics.is_empty() {
2205 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(diagnostics));
2206 }
2207 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2208 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2209 } else {
2210 eprintln!("error: {error_message}");
2211 println!("Alias: {name}");
2212 println!("Source: {source}");
2213 println!("Provider: {}", resolved.provider);
2214 println!("Resolved: {}", resolved.model_id);
2215 print_route_text(trace);
2216 emit_drained_text_diagnostics(diagnostics);
2217 }
2218
2219 Ok(1)
2220}
2221
2222fn run_output_resolved(input: OutputResolvedInput<'_>) -> Result<i32, MarsError> {
2223 let OutputResolvedInput {
2224 name,
2225 resolved,
2226 source,
2227 route_trace,
2228 outcome,
2229 cache_outcome,
2230 probe_refresh,
2231 routing_diagnostics,
2232 json,
2233 } = input;
2234 let cache_warning = cache_warning(outcome);
2235 if let Some(warning) = cache_warning.as_deref()
2236 && !json
2237 {
2238 eprintln!("warning: {warning}");
2239 }
2240
2241 if json {
2242 let mut out = serde_json::json!({
2243 "name": name,
2244 "source": source,
2245 "provider": resolved.provider,
2246 "harness": resolved.harness,
2247 "harness_source": resolved.harness_source,
2248 "harness_candidates": resolved.harness_candidates,
2249 "model_id": resolved.model_id,
2250 "resolved_model": resolved.model_id,
2251 "description": resolved.description,
2252 });
2253 if let Some(error) = unavailable_harness_error(resolved) {
2254 out["error"] = serde_json::json!(error);
2255 }
2256 if let Some(default_effort) = &resolved.default_effort {
2257 out["default_effort"] = serde_json::json!(default_effort);
2258 }
2259 if let Some(autocompact) = resolved.autocompact {
2260 out["autocompact"] = serde_json::json!(autocompact);
2261 }
2262 if let Some(autocompact_pct) = resolved.autocompact_pct {
2263 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2264 }
2265 out["probe_cache"] = serde_json::json!(cache_outcome.cache_status());
2266 add_availability_json_fields(&mut out, resolved.availability.as_ref());
2267 if let Some(warning) = cache_warning.as_deref() {
2268 out["cache_warning"] = serde_json::json!(warning);
2269 }
2270 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2271 add_route_json_fields(&mut out, route_trace);
2272 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2273 } else {
2274 if probe_refresh == ProbeRefreshMode::Background
2275 && matches!(cache_outcome, CachedProbeOutcome::Stale(_))
2276 {
2277 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2278 }
2279 let harness = resolved.harness.as_deref().unwrap_or("—");
2280 println!("Alias: {}", name);
2281 println!("Source: {}", source);
2282 println!(
2283 "Harness: {} ({})",
2284 harness,
2285 harness_source_label(&resolved.harness_source)
2286 );
2287 println!("Provider: {}", resolved.provider);
2288 println!("Resolved: {}", resolved.model_id);
2289 if let Some(error) = unavailable_harness_error(resolved) {
2290 println!("Error: {}", error);
2291 }
2292 print_availability_text(resolved.availability.as_ref());
2293 if let Some(desc) = &resolved.description {
2294 println!("Desc: {}", desc);
2295 }
2296 print_route_text(route_trace);
2297 }
2298
2299 Ok(0)
2300}
2301
2302fn run_output_passthrough(input: OutputPassthroughInput<'_>) -> Result<i32, MarsError> {
2303 let OutputPassthroughInput {
2304 name,
2305 outcome,
2306 is_offline,
2307 installed,
2308 capability_session,
2309 catalog_model_slugs,
2310 routing_settings,
2311 cache_error,
2312 routing_diagnostics,
2313 json,
2314 } = input;
2315 if name.trim().is_empty() {
2316 if json {
2317 let mut out = serde_json::json!({
2318 "error": "model name cannot be empty",
2319 });
2320 if let Some(cache_error) = cache_error {
2321 out["cache_error"] = serde_json::json!(cache_error);
2322 }
2323 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2324 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2325 } else {
2326 eprintln!("error: model name cannot be empty");
2327 }
2328 return Ok(1);
2329 }
2330
2331 let cache_warning = cache_warning(outcome);
2332 if let Some(warning) = cache_warning.as_deref()
2333 && !json
2334 {
2335 eprintln!("warning: {warning}");
2336 }
2337
2338 let (passthrough_model_id, provider_constraint) =
2339 models::split_provider_constrained_model_token(name);
2340 let guessed_provider =
2341 models::infer_provider_from_model_id(&passthrough_model_id).map(str::to_string);
2342 let provider_for_order = provider_constraint.as_deref().unwrap_or("unknown");
2343 let provider_for_classification = guessed_provider
2344 .as_deref()
2345 .or(provider_constraint.as_deref())
2346 .unwrap_or("unknown");
2347 let routing_evidence = crate::routing::RoutingSettingsEvidence::new(
2348 &passthrough_model_id,
2349 Some(provider_for_order),
2350 provider_constraint.as_deref(),
2351 installed,
2352 None,
2353 None,
2354 None,
2355 catalog_model_slugs,
2356 routing_settings,
2357 );
2358 let trace = {
2359 let mut probe_resolver = SessionProbeResolver {
2360 session: capability_session,
2361 };
2362 crate::routing::evaluate_candidates_with_auth_and_probes(
2363 &routing_evidence.routing_input(),
2364 &mut probe_resolver,
2365 models::harness::native_harness_authenticated,
2366 )
2367 };
2368 if let Err(rejection_reason) = crate::routing::acceptance::accept_route(
2369 &trace,
2370 installed,
2371 crate::routing::acceptance::MatchPolicy::RequireSlugEvidence,
2372 ) {
2373 let message = passthrough_rejection_message(name, &rejection_reason);
2374 if json {
2375 let mut out = serde_json::json!({
2376 "error": message,
2377 "source": "passthrough",
2378 "model_id": passthrough_model_id,
2379 "resolved_model": passthrough_model_id,
2380 "provider_constraint": provider_constraint,
2381 "harnesses_tried": trace.candidates_tried,
2382 "route_rejection": route_rejection_json(&rejection_reason),
2383 });
2384 add_route_json_fields(&mut out, &trace);
2385 if !trace.selected_diagnostics().is_empty() {
2386 out["diagnostics"] = serde_json::json!(trace.selected_diagnostics());
2387 }
2388 if let Some(warning) = cache_warning.as_deref() {
2389 out["cache_warning"] = serde_json::json!(warning);
2390 }
2391 if let Some(cache_error) = cache_error {
2392 out["cache_error"] = serde_json::json!(cache_error);
2393 }
2394 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2395 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2396 } else {
2397 eprintln!("error: {message}");
2398 print_route_text(&trace);
2399 }
2400 return Ok(1);
2401 }
2402
2403 let harness = installed
2404 .contains(trace.selected_harness())
2405 .then_some(trace.selected_harness().to_string());
2406 let harness_source = "pattern_guess";
2407 let harness_candidates = models::harness::harness_candidates_for_provider(provider_for_order);
2408 let availability = models::availability::classify_model(
2409 &passthrough_model_id,
2410 provider_for_classification,
2411 installed,
2412 capability_session.loaded_opencode_probe_result(),
2413 capability_session.loaded_pi_probe_result(),
2414 capability_session.loaded_cursor_probe_result(),
2415 is_offline,
2416 );
2417
2418 let warning = passthrough_catalog_warning(name, &trace);
2419
2420 if json {
2421 let mut out = serde_json::json!({
2422 "name": name,
2423 "source": "passthrough",
2424 "model_id": passthrough_model_id,
2425 "resolved_model": passthrough_model_id,
2426 "provider": guessed_provider,
2427 "harness": harness,
2428 "harness_source": harness_source,
2429 "harness_candidates": harness_candidates,
2430 "description": serde_json::Value::Null,
2431 });
2432 if let Some(warning) = warning.as_deref() {
2433 out["warning"] = serde_json::json!(warning);
2434 }
2435 add_availability_json_fields(&mut out, Some(&availability));
2436 add_route_json_fields(&mut out, &trace);
2437 if let Some(warning) = cache_warning.as_deref() {
2438 out["cache_warning"] = serde_json::json!(warning);
2439 }
2440 if let Some(cache_error) = cache_error {
2441 out["cache_error"] = serde_json::json!(cache_error);
2442 }
2443 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2444 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2445 } else {
2446 if let Some(warning) = warning.as_deref() {
2447 eprintln!("warning: {}", warning);
2448 }
2449 let h = harness.as_deref().unwrap_or("—");
2450 println!("Model: {}", name);
2451 println!("Source: passthrough");
2452 println!("Harness: {} ({})", h, harness_source);
2453 if let Some(provider) = guessed_provider {
2454 println!("Provider: {}", provider);
2455 }
2456 if !harness_candidates.is_empty() {
2457 println!("Candidates: {}", harness_candidates.join(", "));
2458 }
2459 print_route_text(&trace);
2460 }
2461
2462 Ok(0)
2463}
2464
2465fn load_project_config_layers_optional(
2470 project_root: &std::path::Path,
2471) -> Result<Option<crate::config::LoadedProjectConfig>, MarsError> {
2472 match crate::config::load_project_config_layers(project_root) {
2473 Ok(loaded) => Ok(Some(loaded)),
2474 Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(None),
2475 Err(err) => Err(err),
2476 }
2477}
2478
2479fn models_cache_ttl_hours(project_config: Option<&crate::config::LoadedProjectConfig>) -> u32 {
2480 project_config
2481 .map(|loaded| loaded.effective.settings.models_cache_ttl_hours)
2482 .unwrap_or_else(|| crate::config::Settings::default().models_cache_ttl_hours)
2483}
2484
2485fn load_merged_aliases(
2488 project_root: &std::path::Path,
2489 project_config: Option<&crate::config::LoadedProjectConfig>,
2490) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
2491 let lock = crate::lock::load_for_runtime_aliases(project_root)?;
2492 Ok(models::merged_runtime_aliases(
2493 &lock.dependency_model_aliases,
2494 project_config.map(|loaded| &loaded.effective.models),
2495 ))
2496}
2497
2498fn determine_source(
2500 name: &str,
2501 project_config: Option<&crate::config::LoadedProjectConfig>,
2502) -> String {
2503 let Some(project_config) = project_config else {
2504 return "unknown".to_string();
2505 };
2506
2507 if project_config.local.models.contains_key(name) {
2508 return "consumer local (mars.local.toml)".to_string();
2509 }
2510
2511 if project_config.config.models.contains_key(name) {
2512 return "consumer (mars.toml)".to_string();
2513 }
2514
2515 "dependency".to_string()
2516}
2517
2518fn format_spec(spec: &ModelSpec) -> serde_json::Value {
2519 match spec {
2520 ModelSpec::Pinned { model, provider } => {
2521 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
2522 if let Some(provider) = provider {
2523 out["provider"] = serde_json::json!(provider);
2524 }
2525 out
2526 }
2527 ModelSpec::PinnedWithMatch {
2528 model,
2529 provider,
2530 match_patterns,
2531 exclude_patterns,
2532 } => {
2533 let mut out = serde_json::json!({
2534 "mode": "pinned",
2535 "model": model,
2536 "match": match_patterns,
2537 "exclude": exclude_patterns,
2538 });
2539 if let Some(provider) = provider {
2540 out["provider"] = serde_json::json!(provider);
2541 }
2542 out
2543 }
2544 ModelSpec::AutoResolve {
2545 provider,
2546 match_patterns,
2547 exclude_patterns,
2548 } => {
2549 let mut obj = serde_json::json!({
2550 "mode": "auto-resolve",
2551 "match": match_patterns,
2552 "exclude": exclude_patterns,
2553 });
2554 if let Some(provider) = provider {
2555 obj["provider"] = serde_json::json!(provider);
2556 }
2557 obj
2558 }
2559 }
2560}
2561
2562fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
2563 match spec {
2564 Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
2565 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
2566 None => "unknown",
2567 }
2568}
2569
2570fn harness_source_label(source: &HarnessSource) -> &'static str {
2571 match source {
2572 HarnessSource::Explicit => "explicit",
2573 HarnessSource::AutoDetected => "auto-detected",
2574 HarnessSource::Unavailable => "unavailable",
2575 }
2576}
2577
2578fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
2579 if resolved.harness_source != HarnessSource::Unavailable {
2580 return None;
2581 }
2582 if let Some(h) = &resolved.harness {
2583 Some(format!("Harness '{}' is not installed", h))
2584 } else {
2585 Some(format!(
2586 "No installed harness for provider '{}'. Install one of: {}",
2587 resolved.provider,
2588 resolved.harness_candidates.join(", ")
2589 ))
2590 }
2591}
2592
2593fn fixed_alias_rejection_message(
2594 rejection: &crate::routing::acceptance::RejectionReason,
2595) -> String {
2596 match rejection {
2597 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2598 "alias harness `{harness}` is not installed and cannot run resolved model under model-first routing"
2599 ),
2600 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => format!(
2601 "alias harness `{harness}` did not provide required model slug evidence under model-first routing"
2602 ),
2603 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2604 harness,
2605 skip_reason,
2606 } => format!(
2607 "alias harness `{harness}` cannot run resolved model under model-first routing ({})",
2608 skip_reason.as_deref().unwrap_or("unavailable")
2609 ),
2610 }
2611}
2612
2613fn passthrough_rejection_message(
2614 model_name: &str,
2615 rejection: &crate::routing::acceptance::RejectionReason,
2616) -> String {
2617 match rejection {
2618 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2619 "model '{model_name}' selected harness '{harness}', but that harness is not installed"
2620 ),
2621 crate::routing::acceptance::RejectionReason::NoSlugEvidence { .. } => format!(
2622 "model '{model_name}' did not match any harness-reported model slug under model-first routing"
2623 ),
2624 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2625 harness,
2626 skip_reason,
2627 } => format!(
2628 "model '{model_name}' failed model-first routing assessment on harness '{harness}' ({})",
2629 skip_reason.as_deref().unwrap_or("unavailable")
2630 ),
2631 }
2632}
2633
2634fn passthrough_catalog_warning(name: &str, trace: &crate::routing::RoutingTrace) -> Option<String> {
2635 match trace.selected_match_evidence() {
2636 crate::routing::MatchEvidence::Passthrough => Some(format!(
2637 "model '{}' not found in catalog, passing through to harness",
2638 name
2639 )),
2640 crate::routing::MatchEvidence::Confirmed | crate::routing::MatchEvidence::Constrained => {
2641 None
2642 }
2643 crate::routing::MatchEvidence::None => None,
2644 }
2645}
2646
2647fn route_rejection_json(
2648 rejection: &crate::routing::acceptance::RejectionReason,
2649) -> serde_json::Value {
2650 match rejection {
2651 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => {
2652 serde_json::json!({
2653 "reason": "harness_not_installed",
2654 "harness": harness,
2655 })
2656 }
2657 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => {
2658 serde_json::json!({
2659 "reason": "no_slug_evidence",
2660 "harness": harness,
2661 })
2662 }
2663 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2664 harness,
2665 skip_reason,
2666 } => {
2667 serde_json::json!({
2668 "reason": "assessment_failed",
2669 "harness": harness,
2670 "skip_reason": skip_reason,
2671 })
2672 }
2673 }
2674}
2675
2676fn stale_warning(reason: &str) -> String {
2677 format!("models cache refresh failed: {reason}; using stale cache")
2678}
2679
2680fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
2681 match outcome {
2682 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
2683 _ => None,
2684 }
2685}
2686
2687fn emit_routing_settings_warnings(routing_diagnostics: &[String]) {
2688 for message in routing_diagnostics {
2689 eprintln!("warning: {message}");
2690 }
2691}
2692
2693fn add_routing_diagnostics_json(out: &mut serde_json::Value, routing_diagnostics: &[String]) {
2694 if !routing_diagnostics.is_empty() {
2695 out["routing_diagnostics"] = serde_json::json!(routing_diagnostics);
2696 }
2697}
2698
2699fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
2700 diagnostics
2701 .iter()
2702 .map(|diagnostic| {
2703 serde_json::json!({
2704 "level": diagnostic_level_label(diagnostic.level),
2705 "code": diagnostic.code,
2706 "message": diagnostic.message,
2707 "context": diagnostic.context,
2708 })
2709 })
2710 .collect()
2711}
2712
2713fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
2714 let diagnostics = diag.drain();
2715 if diagnostics.is_empty() {
2716 None
2717 } else {
2718 Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
2719 }
2720}
2721
2722fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
2723 for diagnostic in diagnostics {
2724 let label = diagnostic_level_label(diagnostic.level);
2725 eprintln!("{label}: {}", diagnostic.message);
2726 }
2727}
2728
2729fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
2730 let diagnostics = diag.drain();
2731 emit_drained_text_diagnostics(&diagnostics);
2732}
2733
2734fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
2735 match level {
2736 DiagnosticLevel::Error => "error",
2737 DiagnosticLevel::Warning => "warning",
2738 DiagnosticLevel::Info => "info",
2739 }
2740}
2741
2742#[cfg(test)]
2743mod tests {
2744 use super::*;
2745 use clap::Parser;
2746 use indexmap::IndexMap;
2747 use tempfile::TempDir;
2748
2749 fn write_mars_toml(temp: &TempDir, contents: &str) {
2750 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
2751 }
2752
2753 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
2754 match result {
2755 Ok(code) => code,
2756 Err(err) => err.exit_code(),
2757 }
2758 }
2759
2760 #[test]
2761 fn list_args_parses_no_refresh_models() {
2762 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
2763 assert!(args.no_refresh_models);
2764 }
2765
2766 #[test]
2767 fn list_args_parses_refresh_models() {
2768 let args = ListArgs::try_parse_from(["mars", "--refresh-models"]).unwrap();
2769 assert!(args.refresh_models);
2770 }
2771
2772 #[test]
2773 fn list_refresh_and_no_refresh_conflict() {
2774 assert!(
2775 ListArgs::try_parse_from(["mars", "--refresh-models", "--no-refresh-models"]).is_err()
2776 );
2777 }
2778
2779 #[test]
2780 fn list_args_parses_catalog() {
2781 let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
2782 assert!(args.catalog);
2783 }
2784
2785 #[test]
2786 fn list_all_and_catalog_conflict() {
2787 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
2788 assert!(parsed.is_err());
2789 }
2790
2791 #[test]
2792 fn list_all_and_include_can_combine() {
2793 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
2794 assert!(parsed.is_ok());
2795 }
2796
2797 #[test]
2798 fn list_catalog_and_include_can_combine() {
2799 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
2800 assert!(parsed.is_ok());
2801 }
2802
2803 #[test]
2804 fn resolve_alias_args_parses_no_refresh_models() {
2805 let args =
2806 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
2807 assert!(args.no_refresh_models);
2808 }
2809
2810 #[test]
2811 fn list_no_refresh_without_cache_is_non_zero() {
2812 let temp = TempDir::new().unwrap();
2813 write_mars_toml(&temp, "[settings]\n");
2814 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2815 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
2816
2817 let exit = normalized_exit_code(run(&args, &ctx, false));
2818 assert_ne!(exit, 0);
2819 }
2820
2821 #[test]
2822 fn resolve_no_refresh_without_cache_is_non_zero() {
2823 let temp = TempDir::new().unwrap();
2824 write_mars_toml(
2825 &temp,
2826 r#"[settings]
2827
2828[models.opus]
2829harness = "claude"
2830model = "claude-opus-4-6"
2831"#,
2832 );
2833 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2834 let args =
2835 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
2836
2837 let exit = normalized_exit_code(run(&args, &ctx, false));
2838 assert_ne!(exit, 0);
2839 }
2840
2841 #[test]
2842 fn alias_updates_existing_model_entry() {
2843 let temp = TempDir::new().unwrap();
2844 write_mars_toml(
2845 &temp,
2846 r#"[settings]
2847
2848[models.fast]
2849harness = "claude"
2850model = "claude-3-5-sonnet"
2851description = "Old alias"
2852"#,
2853 );
2854 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2855
2856 let args = AddAliasArgs {
2857 name: "fast".to_string(),
2858 model_id: "gpt-5.3-codex".to_string(),
2859 harness: "codex".to_string(),
2860 description: Some("Updated alias".to_string()),
2861 };
2862
2863 let exit = run_alias(&args, &ctx, false).unwrap();
2864 assert_eq!(exit, 0);
2865
2866 let config = crate::config::load(temp.path()).unwrap();
2867 assert_eq!(config.models.len(), 1);
2868
2869 let alias = config.models.get("fast").unwrap();
2870 assert_eq!(alias.harness.as_deref(), Some("codex"));
2871 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
2872 match &alias.spec {
2873 ModelSpec::Pinned { model, provider } => {
2874 assert_eq!(model, "gpt-5.3-codex");
2875 assert_eq!(provider, &None);
2876 }
2877 _ => panic!("expected pinned alias"),
2878 }
2879 }
2880
2881 #[test]
2882 fn alias_rejects_invalid_harness_at_write_boundary() {
2883 let temp = TempDir::new().unwrap();
2884 write_mars_toml(&temp, "[settings]\n");
2885 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2886
2887 let args = AddAliasArgs {
2888 name: "fast".to_string(),
2889 model_id: "gpt-5.3-codex".to_string(),
2890 harness: "gemini".to_string(),
2891 description: None,
2892 };
2893
2894 let err = run_alias(&args, &ctx, false).unwrap_err().to_string();
2895 assert!(err.contains("invalid harness 'gemini'"));
2896 assert!(err.contains("valid harnesses: claude, codex, pi, opencode, cursor"));
2897 }
2898
2899 #[test]
2900 fn alias_normalizes_mixed_case_harness_before_write() {
2901 let temp = TempDir::new().unwrap();
2902 write_mars_toml(&temp, "[settings]\n");
2903 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2904
2905 let args = AddAliasArgs {
2906 name: "fast".to_string(),
2907 model_id: "gpt-5.3-codex".to_string(),
2908 harness: "OpenCode".to_string(),
2909 description: None,
2910 };
2911
2912 let exit = run_alias(&args, &ctx, false).unwrap();
2913 assert_eq!(exit, 0);
2914
2915 let config = crate::config::load(temp.path()).unwrap();
2916 let alias = config.models.get("fast").unwrap();
2917 assert_eq!(alias.harness.as_deref(), Some("opencode"));
2918 }
2919
2920 fn auto_alias(
2921 provider: &str,
2922 match_patterns: &[&str],
2923 exclude_patterns: &[&str],
2924 ) -> ModelAlias {
2925 ModelAlias {
2926 harness: None,
2927 description: None,
2928 default_effort: None,
2929 autocompact: None,
2930 autocompact_pct: None,
2931 spec: ModelSpec::AutoResolve {
2932 provider: Some(provider.to_string()),
2933 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2934 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2935 },
2936 }
2937 }
2938
2939 fn pinned_with_match_alias(
2940 model: &str,
2941 provider: &str,
2942 match_patterns: &[&str],
2943 exclude_patterns: &[&str],
2944 ) -> ModelAlias {
2945 ModelAlias {
2946 harness: None,
2947 description: None,
2948 default_effort: None,
2949 autocompact: None,
2950 autocompact_pct: None,
2951 spec: ModelSpec::PinnedWithMatch {
2952 model: model.to_string(),
2953 provider: Some(provider.to_string()),
2954 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2955 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2956 },
2957 }
2958 }
2959
2960 fn pinned_alias(model: &str) -> ModelAlias {
2961 ModelAlias {
2962 harness: None,
2963 description: None,
2964 default_effort: None,
2965 autocompact: None,
2966 autocompact_pct: None,
2967 spec: ModelSpec::Pinned {
2968 model: model.to_string(),
2969 provider: None,
2970 },
2971 }
2972 }
2973
2974 fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
2975 ModelAlias {
2976 harness: None,
2977 description: None,
2978 default_effort: None,
2979 autocompact: None,
2980 autocompact_pct: None,
2981 spec: ModelSpec::Pinned {
2982 model: model.to_string(),
2983 provider: Some(provider.to_string()),
2984 },
2985 }
2986 }
2987
2988 fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
2989 models::CachedModel {
2990 id: id.to_string(),
2991 provider: provider.to_string(),
2992 release_date: release_date.map(|value| value.to_string()),
2993 description: Some(format!("desc-{id}")),
2994 context_window: None,
2995 max_output: None,
2996 cost_input: None,
2997 cost_output: None,
2998 cost_cache_read: None,
2999 cost_cache_write: None,
3000 cost_reasoning: None,
3001 }
3002 }
3003
3004 fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
3005 models::ModelsCache {
3006 models,
3007 fetched_at: Some("123".to_string()),
3008 }
3009 }
3010
3011 fn installed(names: &[&str]) -> HashSet<String> {
3012 names.iter().map(|name| (*name).to_string()).collect()
3013 }
3014
3015 fn default_routing_settings() -> ResolvedRoutingSettings {
3016 crate::config::routing_settings::resolve(&crate::config::Settings::default())
3017 }
3018
3019 #[allow(clippy::too_many_arguments)]
3020 fn collect_all_model_entries(
3021 merged: &IndexMap<String, ModelAlias>,
3022 cache: &models::ModelsCache,
3023 installed: &HashSet<String>,
3024 opencode_probe_result: Option<&OpenCodeProbeResult>,
3025 pi_probe_result: Option<&PiProbeResult>,
3026 cursor_probe_result: Option<&CursorProbeResult>,
3027 is_offline: bool,
3028 routing_settings: &ResolvedRoutingSettings,
3029 ) -> Vec<ListModelEntry> {
3030 let catalog_slugs = models::catalog_model_slugs(cache);
3031 super::collect_all_model_entries(
3032 merged,
3033 cache,
3034 AvailabilityContext {
3035 installed,
3036 opencode_probe_result,
3037 pi_probe_result,
3038 cursor_probe_result,
3039 catalog_model_slugs: Some(catalog_slugs.as_slice()),
3040 is_offline,
3041 routing_settings,
3042 },
3043 )
3044 }
3045
3046 fn collect_catalog_model_entries(
3047 cache: &models::ModelsCache,
3048 installed: &HashSet<String>,
3049 opencode_probe_result: Option<&OpenCodeProbeResult>,
3050 pi_probe_result: Option<&PiProbeResult>,
3051 cursor_probe_result: Option<&CursorProbeResult>,
3052 is_offline: bool,
3053 routing_settings: &ResolvedRoutingSettings,
3054 ) -> Vec<ListModelEntry> {
3055 collect_catalog_model_entries_with_auth(
3056 cache,
3057 installed,
3058 opencode_probe_result,
3059 pi_probe_result,
3060 cursor_probe_result,
3061 is_offline,
3062 routing_settings,
3063 models::harness::native_harness_authenticated,
3064 )
3065 }
3066
3067 #[allow(clippy::too_many_arguments)]
3068 fn collect_catalog_model_entries_with_auth<F>(
3069 cache: &models::ModelsCache,
3070 installed: &HashSet<String>,
3071 opencode_probe_result: Option<&OpenCodeProbeResult>,
3072 pi_probe_result: Option<&PiProbeResult>,
3073 cursor_probe_result: Option<&CursorProbeResult>,
3074 is_offline: bool,
3075 routing_settings: &ResolvedRoutingSettings,
3076 auth_check: F,
3077 ) -> Vec<ListModelEntry>
3078 where
3079 F: Fn(&str) -> bool + Copy,
3080 {
3081 let catalog_slugs = models::catalog_model_slugs(cache);
3082 let availability_ctx = AvailabilityContext {
3083 installed,
3084 opencode_probe_result,
3085 pi_probe_result,
3086 cursor_probe_result,
3087 catalog_model_slugs: Some(catalog_slugs.as_slice()),
3088 is_offline,
3089 routing_settings,
3090 };
3091 let mut out: Vec<ListModelEntry> = cache
3092 .models
3093 .iter()
3094 .map(|model| {
3095 super::model_entry_for_cached_with_auth(model, availability_ctx, auth_check)
3096 })
3097 .collect();
3098 super::sort_list_model_entries(&mut out);
3099 out
3100 }
3101
3102 #[test]
3103 fn list_all_shows_multiple_per_alias() {
3104 let mut merged = IndexMap::new();
3105 merged.insert(
3106 "opus".to_string(),
3107 auto_alias("Anthropic", &["claude-opus-*"], &[]),
3108 );
3109
3110 let models_cache = cache(vec![
3111 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3112 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
3113 ]);
3114
3115 let installed = installed(&[]);
3116 let rows = collect_all_model_entries(
3117 &merged,
3118 &models_cache,
3119 &installed,
3120 None,
3121 None,
3122 None,
3123 false,
3124 &default_routing_settings(),
3125 );
3126 assert_eq!(rows.len(), 2);
3127 assert_eq!(rows[0].id, "claude-opus-4-7");
3128 assert_eq!(rows[1].id, "claude-opus-4-6");
3129 }
3130
3131 #[test]
3132 fn list_all_includes_matched_aliases_with_dedup() {
3133 let mut merged = IndexMap::new();
3134 merged.insert(
3135 "opus".to_string(),
3136 auto_alias("Anthropic", &["claude-opus-*"], &[]),
3137 );
3138 merged.insert(
3139 "legacy".to_string(),
3140 auto_alias("Anthropic", &["*4-6"], &[]),
3141 );
3142
3143 let models_cache = cache(vec![cached_model(
3144 "claude-opus-4-6",
3145 "Anthropic",
3146 Some("2026-02-05"),
3147 )]);
3148
3149 let installed = installed(&[]);
3150 let rows = collect_all_model_entries(
3151 &merged,
3152 &models_cache,
3153 &installed,
3154 None,
3155 None,
3156 None,
3157 false,
3158 &default_routing_settings(),
3159 );
3160 assert_eq!(rows.len(), 1);
3161 assert_eq!(rows[0].id, "claude-opus-4-6");
3162 assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
3163 }
3164
3165 #[test]
3166 fn list_all_includes_pinned_cache_entries() {
3167 let mut merged = IndexMap::new();
3168 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3169
3170 let models_cache = cache(vec![cached_model(
3171 "gpt-5.3-codex",
3172 "OpenAI",
3173 Some("2026-01-01"),
3174 )]);
3175 let installed = installed(&[]);
3176 let rows = collect_all_model_entries(
3177 &merged,
3178 &models_cache,
3179 &installed,
3180 None,
3181 None,
3182 None,
3183 false,
3184 &default_routing_settings(),
3185 );
3186 assert_eq!(rows.len(), 1);
3187 assert_eq!(rows[0].id, "gpt-5.3-codex");
3188 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3189 }
3190
3191 #[test]
3192 fn list_all_includes_pinned_cache_miss_entries() {
3193 let mut merged = IndexMap::new();
3194 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3195
3196 let models_cache = cache(Vec::new());
3197 let installed = installed(&[]);
3198 let rows = collect_all_model_entries(
3199 &merged,
3200 &models_cache,
3201 &installed,
3202 None,
3203 None,
3204 None,
3205 false,
3206 &default_routing_settings(),
3207 );
3208 assert_eq!(rows.len(), 1);
3209 assert_eq!(rows[0].id, "gpt-5.3-codex");
3210 assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
3211 assert_eq!(rows[0].release_date, None);
3212 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3213 }
3214
3215 #[test]
3216 fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
3217 let mut merged = IndexMap::new();
3218 merged.insert(
3219 "custom".to_string(),
3220 pinned_alias_with_provider("custom-model-id", "Anthropic"),
3221 );
3222
3223 let models_cache = cache(Vec::new());
3224 let installed = installed(&[]);
3225 let rows = collect_all_model_entries(
3226 &merged,
3227 &models_cache,
3228 &installed,
3229 None,
3230 None,
3231 None,
3232 false,
3233 &default_routing_settings(),
3234 );
3235 assert_eq!(rows.len(), 1);
3236 assert_eq!(rows[0].id, "custom-model-id");
3237 assert_eq!(rows[0].provider, "Anthropic");
3238 assert_eq!(rows[0].release_date, None);
3239 assert_eq!(rows[0].matched_aliases, vec!["custom"]);
3240 }
3241
3242 #[test]
3243 fn list_all_includes_unavailable_harness_entries_with_fallback_candidates() {
3244 let mut merged = IndexMap::new();
3245 merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
3246 let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
3247
3248 let installed = installed(&[]);
3249 let rows = collect_all_model_entries(
3250 &merged,
3251 &models_cache,
3252 &installed,
3253 None,
3254 None,
3255 None,
3256 false,
3257 &default_routing_settings(),
3258 );
3259 assert_eq!(rows.len(), 1);
3260 assert_eq!(rows[0].harness, None);
3261 assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
3262 assert_eq!(rows[0].harness_candidates, vec!["pi", "opencode", "cursor"]);
3263 }
3264
3265 #[test]
3266 fn list_catalog_shows_all_cache_sorted() {
3267 let models_cache = cache(vec![
3268 cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
3269 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3270 cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
3271 ]);
3272
3273 let installed = installed(&[]);
3274 let rows = collect_catalog_model_entries(
3275 &models_cache,
3276 &installed,
3277 None,
3278 None,
3279 None,
3280 false,
3281 &default_routing_settings(),
3282 );
3283 assert_eq!(rows.len(), 3);
3284 assert_eq!(rows[0].id, "claude-opus-4-6");
3285 assert_eq!(rows[1].id, "claude-sonnet-4-5");
3286 assert_eq!(rows[2].id, "gpt-5");
3287 }
3288
3289 #[test]
3290 fn list_catalog_uses_catalog_slugs_for_native_harness_matching() {
3291 let models_cache = cache(vec![cached_model(
3292 "claude-opus-4-6",
3293 "Anthropic",
3294 Some("2026-02-05"),
3295 )]);
3296
3297 let installed = installed(&["claude"]);
3298 let rows = collect_catalog_model_entries_with_auth(
3299 &models_cache,
3300 &installed,
3301 None,
3302 None,
3303 None,
3304 false,
3305 &default_routing_settings(),
3306 |_| true,
3307 );
3308
3309 assert_eq!(rows.len(), 1);
3310 assert_eq!(rows[0].harness.as_deref(), Some("claude"));
3311 assert_eq!(rows[0].harness_source, HarnessSource::AutoDetected);
3312 }
3313
3314 #[test]
3315 fn list_all_includes_pinned_with_match_discovery_candidates() {
3316 let mut merged = IndexMap::new();
3317 merged.insert(
3318 "opus".to_string(),
3319 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3320 );
3321 let models_cache = cache(vec![
3322 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3323 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3324 ]);
3325
3326 let installed = installed(&[]);
3327 let rows = collect_all_model_entries(
3328 &merged,
3329 &models_cache,
3330 &installed,
3331 None,
3332 None,
3333 None,
3334 false,
3335 &default_routing_settings(),
3336 );
3337 assert_eq!(rows.len(), 2);
3338 assert_eq!(rows[0].id, "claude-opus-4-7");
3339 assert_eq!(rows[1].id, "claude-opus-4-6");
3340 assert_eq!(rows[0].matched_aliases, vec!["opus"]);
3341 assert_eq!(rows[1].matched_aliases, vec!["opus"]);
3342 }
3343
3344 #[test]
3345 fn resolve_pinned_with_match_uses_model_field() {
3346 let mut merged = IndexMap::new();
3347 merged.insert(
3348 "opus".to_string(),
3349 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3350 );
3351 let models_cache = cache(vec![
3352 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3353 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3354 ]);
3355 let mut diag = DiagnosticCollector::new();
3356 let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
3357 assert_eq!(resolved.model_id, "claude-opus-4-6");
3358 assert!(diag.drain().is_empty());
3359 }
3360
3361 fn passthrough_trace(
3362 match_evidence: crate::routing::MatchEvidence,
3363 ) -> crate::routing::RoutingTrace {
3364 crate::routing::RoutingTrace {
3365 source: crate::routing::RouteSource::Provider,
3366 selection_kind: crate::routing::SelectionKind::Auto,
3367 match_evidence,
3368 harness: "opencode".to_string(),
3369 harness_order_position: None,
3370 candidates_tried: vec!["opencode".to_string()],
3371 assessments: Vec::new(),
3372 diagnostics: Vec::new(),
3373 }
3374 }
3375
3376 #[test]
3377 fn passthrough_catalog_warning_omits_warning_for_confirmed_and_constrained_routes() {
3378 assert!(
3379 passthrough_catalog_warning(
3380 "openai/gpt-5.4-mini",
3381 &passthrough_trace(crate::routing::MatchEvidence::Confirmed)
3382 )
3383 .is_none()
3384 );
3385 assert!(
3386 passthrough_catalog_warning(
3387 "openai/gpt-5.4-mini",
3388 &passthrough_trace(crate::routing::MatchEvidence::Constrained)
3389 )
3390 .is_none()
3391 );
3392 }
3393
3394 #[test]
3395 fn passthrough_catalog_warning_keeps_warning_for_passthrough_routes() {
3396 let warning = passthrough_catalog_warning(
3397 "unknown-model",
3398 &passthrough_trace(crate::routing::MatchEvidence::Passthrough),
3399 )
3400 .expect("passthrough warning expected");
3401 assert!(warning.contains("not found in catalog"));
3402 }
3403}