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
863 models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
864 {
865 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
866 }
867 }
868 ModelSpec::Pinned {
869 model, provider, ..
870 } => {
871 if let Some(matched) = cache
872 .models
873 .iter()
874 .find(|cache_model| cache_model.id == *model)
875 {
876 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
877 } else {
878 append_pinned_alias_match(
879 &mut by_model_id,
880 model,
881 provider.as_deref(),
882 alias.description.as_deref(),
883 availability_ctx,
884 alias_name,
885 );
886 }
887 }
888 ModelSpec::PinnedWithMatch {
889 model,
890 provider,
891 match_patterns,
892 exclude_patterns,
893 } => {
894 if let Some(matched) = cache
895 .models
896 .iter()
897 .find(|cache_model| cache_model.id == *model)
898 {
899 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
900 } else {
901 append_pinned_alias_match(
902 &mut by_model_id,
903 model,
904 provider.as_deref(),
905 alias.description.as_deref(),
906 availability_ctx,
907 alias_name,
908 );
909 }
910
911 let provider_for_discovery = provider
912 .as_deref()
913 .or_else(|| models::infer_provider_from_model_id(model));
914 if let Some(provider_for_discovery) = provider_for_discovery {
915 for matched in models::auto_resolve_all(
916 provider_for_discovery,
917 match_patterns,
918 exclude_patterns,
919 cache,
920 ) {
921 append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
922 }
923 }
924 }
925 }
926 }
927
928 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
929 sort_list_model_entries(&mut out);
930 out
931}
932
933fn collect_catalog_model_entries(
934 cache: &models::ModelsCache,
935 availability_ctx: AvailabilityContext<'_>,
936) -> Vec<ListModelEntry> {
937 let mut out: Vec<ListModelEntry> = cache
938 .models
939 .iter()
940 .map(|model| model_entry_for_cached(model, availability_ctx))
941 .collect();
942 sort_list_model_entries(&mut out);
943 out
944}
945
946fn collect_all_model_entries_static(
947 merged: &IndexMap<String, ModelAlias>,
948 cache: &models::ModelsCache,
949) -> Vec<ListModelEntry> {
950 let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
951
952 for (alias_name, alias) in merged {
953 match &alias.spec {
954 ModelSpec::AutoResolve {
955 provider,
956 match_patterns,
957 exclude_patterns,
958 } => {
959 for matched in
960 models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
961 {
962 let entry = by_model_id
963 .entry(matched.id.clone())
964 .or_insert_with(|| model_entry_for_cached_static(matched));
965 append_alias_name(entry, alias_name);
966 }
967 }
968 ModelSpec::Pinned {
969 model, provider, ..
970 } => {
971 let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
972 cache
973 .models
974 .iter()
975 .find(|cache_model| cache_model.id == *model)
976 .map(model_entry_for_cached_static)
977 .unwrap_or_else(|| {
978 model_entry_for_pinned_static(
979 model,
980 provider.as_deref(),
981 alias.description.as_deref(),
982 )
983 })
984 });
985 append_alias_name(entry, alias_name);
986 }
987 ModelSpec::PinnedWithMatch {
988 model,
989 provider,
990 match_patterns,
991 exclude_patterns,
992 } => {
993 let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
994 cache
995 .models
996 .iter()
997 .find(|cache_model| cache_model.id == *model)
998 .map(model_entry_for_cached_static)
999 .unwrap_or_else(|| {
1000 model_entry_for_pinned_static(
1001 model,
1002 provider.as_deref(),
1003 alias.description.as_deref(),
1004 )
1005 })
1006 });
1007 append_alias_name(entry, alias_name);
1008
1009 let provider_for_discovery = provider
1010 .as_deref()
1011 .or_else(|| models::infer_provider_from_model_id(model));
1012 if let Some(provider_for_discovery) = provider_for_discovery {
1013 for matched in models::auto_resolve_all(
1014 provider_for_discovery,
1015 match_patterns,
1016 exclude_patterns,
1017 cache,
1018 ) {
1019 let entry = by_model_id
1020 .entry(matched.id.clone())
1021 .or_insert_with(|| model_entry_for_cached_static(matched));
1022 append_alias_name(entry, alias_name);
1023 }
1024 }
1025 }
1026 }
1027 }
1028
1029 let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
1030 sort_list_model_entries(&mut out);
1031 out
1032}
1033
1034fn collect_catalog_model_entries_static(cache: &models::ModelsCache) -> Vec<ListModelEntry> {
1035 let mut out: Vec<ListModelEntry> = cache
1036 .models
1037 .iter()
1038 .map(model_entry_for_cached_static)
1039 .collect();
1040 sort_list_model_entries(&mut out);
1041 out
1042}
1043
1044fn append_alias_match(
1045 by_model_id: &mut IndexMap<String, ListModelEntry>,
1046 model: &models::CachedModel,
1047 availability_ctx: AvailabilityContext<'_>,
1048 alias_name: &str,
1049) {
1050 let entry = by_model_id
1051 .entry(model.id.clone())
1052 .or_insert_with(|| model_entry_for_cached(model, availability_ctx));
1053
1054 append_alias_name(entry, alias_name);
1055}
1056
1057fn append_pinned_alias_match(
1058 by_model_id: &mut IndexMap<String, ListModelEntry>,
1059 model_id: &str,
1060 provider: Option<&str>,
1061 description: Option<&str>,
1062 availability_ctx: AvailabilityContext<'_>,
1063 alias_name: &str,
1064) {
1065 let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
1066 model_entry_for_pinned(model_id, provider, description, availability_ctx)
1067 });
1068
1069 append_alias_name(entry, alias_name);
1070}
1071
1072fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
1073 if !entry
1074 .matched_aliases
1075 .iter()
1076 .any(|existing| existing == alias_name)
1077 {
1078 entry.matched_aliases.push(alias_name.to_string());
1079 }
1080}
1081
1082fn model_entry_for_cached(
1083 model: &models::CachedModel,
1084 availability_ctx: AvailabilityContext<'_>,
1085) -> ListModelEntry {
1086 model_entry_for_cached_with_auth(
1087 model,
1088 availability_ctx,
1089 models::harness::native_harness_authenticated,
1090 )
1091}
1092
1093fn model_entry_for_cached_with_auth<F>(
1094 model: &models::CachedModel,
1095 availability_ctx: AvailabilityContext<'_>,
1096 auth_check: F,
1097) -> ListModelEntry
1098where
1099 F: Fn(&str) -> bool,
1100{
1101 let (harness, harness_source) =
1102 resolve_harness_with_routing_auth(&model.provider, &model.id, availability_ctx, auth_check);
1103
1104 ListModelEntry {
1105 id: model.id.clone(),
1106 provider: model.provider.clone(),
1107 release_date: model.release_date.clone(),
1108 harness,
1109 harness_source,
1110 harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
1111 description: model.description.clone(),
1112 cost_input: model.cost_input,
1113 cost_output: model.cost_output,
1114 cost_cache_read: model.cost_cache_read,
1115 cost_cache_write: model.cost_cache_write,
1116 cost_reasoning: model.cost_reasoning,
1117 matched_aliases: Vec::new(),
1118 availability: Some(models::availability::classify_model(
1119 &model.id,
1120 &model.provider,
1121 availability_ctx.installed,
1122 availability_ctx.opencode_probe_result,
1123 availability_ctx.pi_probe_result,
1124 availability_ctx.cursor_probe_result,
1125 availability_ctx.is_offline,
1126 )),
1127 }
1128}
1129
1130fn model_entry_for_pinned(
1131 model_id: &str,
1132 provider: Option<&str>,
1133 description: Option<&str>,
1134 availability_ctx: AvailabilityContext<'_>,
1135) -> ListModelEntry {
1136 let provider = provider
1137 .map(str::to_string)
1138 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1139 .unwrap_or_else(|| "unknown".to_string());
1140 let (harness, harness_source) =
1141 resolve_harness_with_routing(&provider, model_id, availability_ctx);
1142
1143 ListModelEntry {
1144 id: model_id.to_string(),
1145 provider: provider.clone(),
1146 release_date: None,
1147 harness,
1148 harness_source,
1149 harness_candidates: models::harness::harness_candidates_for_provider(&provider),
1150 description: description.map(str::to_string),
1151 cost_input: None,
1152 cost_output: None,
1153 cost_cache_read: None,
1154 cost_cache_write: None,
1155 cost_reasoning: None,
1156 matched_aliases: Vec::new(),
1157 availability: Some(models::availability::classify_model(
1158 model_id,
1159 &provider,
1160 availability_ctx.installed,
1161 availability_ctx.opencode_probe_result,
1162 availability_ctx.pi_probe_result,
1163 availability_ctx.cursor_probe_result,
1164 availability_ctx.is_offline,
1165 )),
1166 }
1167}
1168
1169fn model_entry_for_cached_static(model: &models::CachedModel) -> ListModelEntry {
1170 ListModelEntry {
1171 id: model.id.clone(),
1172 provider: model.provider.clone(),
1173 release_date: model.release_date.clone(),
1174 harness: None,
1175 harness_source: HarnessSource::Unavailable,
1176 harness_candidates: Vec::new(),
1177 description: model.description.clone(),
1178 cost_input: model.cost_input,
1179 cost_output: model.cost_output,
1180 cost_cache_read: model.cost_cache_read,
1181 cost_cache_write: model.cost_cache_write,
1182 cost_reasoning: model.cost_reasoning,
1183 matched_aliases: Vec::new(),
1184 availability: None,
1185 }
1186}
1187
1188fn model_entry_for_pinned_static(
1189 model_id: &str,
1190 provider: Option<&str>,
1191 description: Option<&str>,
1192) -> ListModelEntry {
1193 let provider = provider
1194 .map(str::to_string)
1195 .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1196 .unwrap_or_else(|| "unknown".to_string());
1197 ListModelEntry {
1198 id: model_id.to_string(),
1199 provider,
1200 release_date: None,
1201 harness: None,
1202 harness_source: HarnessSource::Unavailable,
1203 harness_candidates: Vec::new(),
1204 description: description.map(str::to_string),
1205 cost_input: None,
1206 cost_output: None,
1207 cost_cache_read: None,
1208 cost_cache_write: None,
1209 cost_reasoning: None,
1210 matched_aliases: Vec::new(),
1211 availability: None,
1212 }
1213}
1214
1215fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
1216 entries.sort_by(|a, b| {
1217 a.provider
1218 .to_ascii_lowercase()
1219 .cmp(&b.provider.to_ascii_lowercase())
1220 .then_with(|| {
1221 b.release_date
1222 .as_deref()
1223 .unwrap_or("")
1224 .cmp(a.release_date.as_deref().unwrap_or(""))
1225 })
1226 .then_with(|| a.id.cmp(&b.id))
1227 });
1228}
1229
1230fn routing_settings_evidence<'a>(
1231 input: &'a RouteTraceInput<'a>,
1232) -> crate::routing::RoutingSettingsEvidence<'a> {
1233 crate::routing::RoutingSettingsEvidence::new(
1234 input.model_id,
1235 Some(input.provider_for_order),
1236 input.provider_constraint,
1237 input.installed,
1238 input.opencode_probe_result,
1239 input.pi_probe_result,
1240 input.cursor_probe_result,
1241 input.catalog_model_slugs,
1242 input.routing_settings,
1243 )
1244}
1245
1246fn resolve_harness_with_routing(
1247 provider: &str,
1248 model_id: &str,
1249 availability_ctx: AvailabilityContext<'_>,
1250) -> (Option<String>, HarnessSource) {
1251 resolve_harness_with_routing_auth(
1252 provider,
1253 model_id,
1254 availability_ctx,
1255 models::harness::native_harness_authenticated,
1256 )
1257}
1258
1259fn resolve_harness_with_routing_auth<F>(
1260 provider: &str,
1261 model_id: &str,
1262 availability_ctx: AvailabilityContext<'_>,
1263 auth_check: F,
1264) -> (Option<String>, HarnessSource)
1265where
1266 F: Fn(&str) -> bool,
1267{
1268 let route_input = RouteTraceInput {
1269 model_id,
1270 provider_for_order: provider,
1271 provider_constraint: None,
1272 installed: availability_ctx.installed,
1273 opencode_probe_result: availability_ctx.opencode_probe_result,
1274 pi_probe_result: availability_ctx.pi_probe_result,
1275 cursor_probe_result: availability_ctx.cursor_probe_result,
1276 catalog_model_slugs: availability_ctx.catalog_model_slugs,
1277 routing_settings: availability_ctx.routing_settings,
1278 };
1279 let routing_evidence = routing_settings_evidence(&route_input);
1280 let trace = crate::routing::evaluate_candidates_with_auth(
1281 &routing_evidence.routing_input(),
1282 auth_check,
1283 );
1284
1285 match crate::routing::acceptance::accept_route(
1286 &trace,
1287 availability_ctx.installed,
1288 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1289 ) {
1290 Ok(()) => (
1291 Some(trace.selected_harness().to_string()),
1292 HarnessSource::AutoDetected,
1293 ),
1294 Err(_) => (None, HarnessSource::Unavailable),
1295 }
1296}
1297
1298fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
1299 match &alias.spec {
1300 ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
1301 provider.clone()
1302 }
1303 ModelSpec::AutoResolve { provider, .. } => Some(provider.clone()),
1304 }
1305 .map(|provider| provider.trim().to_ascii_lowercase())
1306}
1307
1308fn route_trace_for_resolved_model(input: &RouteTraceInput<'_>) -> crate::routing::RoutingTrace {
1309 let routing_evidence = routing_settings_evidence(input);
1310 crate::routing::evaluate_candidates(&routing_evidence.routing_input())
1311}
1312
1313fn route_trace_for_resolved_model_with_probes(
1314 input: &RouteTraceInput<'_>,
1315 probe_resolver: &mut dyn crate::routing::ProbeResolver,
1316) -> crate::routing::RoutingTrace {
1317 let routing_evidence = routing_settings_evidence(input);
1318 crate::routing::evaluate_candidates_with_auth_and_probes(
1319 &routing_evidence.routing_input(),
1320 probe_resolver,
1321 models::harness::native_harness_authenticated,
1322 )
1323}
1324
1325fn route_trace_for_fixed_harness_with_probes(
1326 input: &RouteTraceInput<'_>,
1327 fixed_harness: &str,
1328 source: crate::routing::RouteSource,
1329 probe_resolver: &mut dyn crate::routing::ProbeResolver,
1330) -> crate::routing::RoutingTrace {
1331 let provider_for_order = crate::routing::provider_for_order_for_fixed_harness(
1332 Some(input.provider_for_order),
1333 fixed_harness,
1334 );
1335 let routing_evidence = routing_settings_evidence(input);
1336 let mut fixed_input = routing_evidence.routing_input();
1337 fixed_input.provider_for_order = provider_for_order;
1338 let assessment = crate::routing::evaluate_fixed_harness_with_auth_and_probes(
1339 &fixed_input,
1340 fixed_harness,
1341 probe_resolver,
1342 models::harness::native_harness_authenticated,
1343 );
1344 crate::routing::trace_for_fixed_harness(source, fixed_harness, assessment, Vec::new())
1345}
1346
1347fn effective_visibility(
1348 project_config: Option<&crate::config::LoadedProjectConfig>,
1349 args: &ListArgs,
1350) -> crate::config::ModelVisibility {
1351 if args.include.is_some() || args.exclude.is_some() {
1352 return crate::config::ModelVisibility {
1353 include: args.include.clone(),
1354 exclude: args.exclude.clone(),
1355 };
1356 }
1357
1358 project_config
1359 .map(|loaded| loaded.effective.settings.model_visibility.clone())
1360 .unwrap_or_default()
1361}
1362
1363#[allow(clippy::too_many_arguments)]
1364fn apply_routing_settings_to_resolved_aliases(
1365 resolved: &mut IndexMap<String, models::ResolvedAlias>,
1366 aliases: &IndexMap<String, ModelAlias>,
1367 installed: &HashSet<String>,
1368 opencode_probe_result: Option<&OpenCodeProbeResult>,
1369 pi_probe_result: Option<&PiProbeResult>,
1370 cursor_probe_result: Option<&CursorProbeResult>,
1371 catalog_model_slugs: Option<&[String]>,
1372 routing_settings: &ResolvedRoutingSettings,
1373) {
1374 for alias in resolved.values_mut() {
1375 let has_explicit_harness = aliases
1376 .get(&alias.name)
1377 .is_some_and(|source_alias| source_alias.harness.is_some());
1378 if has_explicit_harness {
1379 continue;
1380 }
1381 apply_routing_settings_to_resolved_alias(
1382 alias,
1383 installed,
1384 opencode_probe_result,
1385 pi_probe_result,
1386 cursor_probe_result,
1387 catalog_model_slugs,
1388 routing_settings,
1389 );
1390 }
1391}
1392
1393fn apply_routing_settings_to_resolved_alias(
1394 alias: &mut models::ResolvedAlias,
1395 installed: &HashSet<String>,
1396 opencode_probe_result: Option<&OpenCodeProbeResult>,
1397 pi_probe_result: Option<&PiProbeResult>,
1398 cursor_probe_result: Option<&CursorProbeResult>,
1399 catalog_model_slugs: Option<&[String]>,
1400 routing_settings: &ResolvedRoutingSettings,
1401) {
1402 let provider_for_order =
1403 models::infer_provider_from_model_id(&alias.model_id).unwrap_or(alias.provider.as_str());
1404 let route_input = RouteTraceInput {
1405 model_id: &alias.model_id,
1406 provider_for_order,
1407 provider_constraint: None,
1408 installed,
1409 opencode_probe_result,
1410 pi_probe_result,
1411 cursor_probe_result,
1412 catalog_model_slugs,
1413 routing_settings,
1414 };
1415 let trace = route_trace_for_resolved_model(&route_input);
1416 alias.harness = Some(trace.harness.clone());
1417 alias.harness_source = match crate::routing::acceptance::accept_route(
1418 &trace,
1419 installed,
1420 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1421 ) {
1422 Ok(()) => HarnessSource::AutoDetected,
1423 Err(_) => HarnessSource::Unavailable,
1424 };
1425}
1426
1427fn annotate_resolved_availability(
1428 resolved: &mut IndexMap<String, models::ResolvedAlias>,
1429 installed: &HashSet<String>,
1430 opencode_probe_result: Option<&OpenCodeProbeResult>,
1431 pi_probe_result: Option<&PiProbeResult>,
1432 cursor_probe_result: Option<&CursorProbeResult>,
1433 is_offline: bool,
1434) {
1435 for alias in resolved.values_mut() {
1436 alias.availability = Some(models::availability::classify_model(
1437 &alias.model_id,
1438 &alias.provider,
1439 installed,
1440 opencode_probe_result,
1441 pi_probe_result,
1442 cursor_probe_result,
1443 is_offline,
1444 ));
1445 }
1446}
1447
1448fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
1449 resolved.retain(|_, alias| {
1450 alias
1451 .availability
1452 .as_ref()
1453 .map(|availability| availability.status != AvailabilityStatus::Unavailable)
1454 .unwrap_or(true)
1455 });
1456}
1457
1458fn filter_model_entries_by_visibility(
1459 entries: Vec<ListModelEntry>,
1460 visibility: &crate::config::ModelVisibility,
1461) -> Vec<ListModelEntry> {
1462 if visibility.include.is_none() && visibility.exclude.is_none() {
1463 return entries;
1464 }
1465
1466 entries
1467 .into_iter()
1468 .filter(|entry| {
1469 let paths = entry
1470 .availability
1471 .as_ref()
1472 .map(|availability| availability.runnable_paths.as_slice())
1473 .unwrap_or(&[]);
1474 let included = visibility.include.as_ref().is_none_or(|includes| {
1475 includes.iter().any(|pattern| {
1476 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1477 })
1478 });
1479 let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
1480 excludes.iter().any(|pattern| {
1481 models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1482 })
1483 });
1484 included && !excluded
1485 })
1486 .collect()
1487}
1488
1489fn add_availability_json_fields(
1490 obj: &mut serde_json::Value,
1491 availability: Option<&ModelAvailability>,
1492) {
1493 if let Some(availability) = availability {
1494 obj["availability"] = serde_json::json!(availability.status);
1495 obj["availability_source"] = serde_json::json!(availability.source);
1496 obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
1497 }
1498}
1499
1500fn add_cost_json_fields(obj: &mut serde_json::Value, model: &models::CachedModel) {
1501 obj["cost_input"] = serde_json::json!(model.cost_input);
1502 obj["cost_output"] = serde_json::json!(model.cost_output);
1503 obj["cost_cache_read"] = serde_json::json!(model.cost_cache_read);
1504 obj["cost_cache_write"] = serde_json::json!(model.cost_cache_write);
1505 obj["cost_reasoning"] = serde_json::json!(model.cost_reasoning);
1506}
1507
1508fn add_probe_results_json(
1509 out: &mut serde_json::Value,
1510 probe_result: Option<&OpenCodeProbeResult>,
1511 pi_probe_result: Option<&PiProbeResult>,
1512 cursor_probe_result: Option<&CursorProbeResult>,
1513) {
1514 if let Some(probe) = probe_result {
1515 out["probe_results"] = serde_json::json!({
1516 "opencode": {
1517 "success": probe.model_probe_success,
1518 "models_found": probe.model_slugs.len(),
1519 }
1520 });
1521 }
1522 if let Some(probe) = pi_probe_result {
1523 if out.get("probe_results").is_none() {
1524 out["probe_results"] = serde_json::json!({});
1525 }
1526 out["probe_results"]["pi"] = serde_json::json!({
1527 "compatible": probe.compatible,
1528 "version": probe.version,
1529 "missing_surface_tokens": probe.help_surface_tokens_missing,
1530 });
1531 }
1532 if let Some(probe) = cursor_probe_result {
1533 if out.get("probe_results").is_none() {
1534 out["probe_results"] = serde_json::json!({});
1535 }
1536 out["probe_results"]["cursor"] = serde_json::json!({
1537 "success": probe.model_probe_success,
1538 "models_found": probe.slugs.len(),
1539 });
1540 }
1541}
1542
1543fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
1544 match availability.map(|value| value.status) {
1545 Some(AvailabilityStatus::Runnable) => "runnable",
1546 Some(AvailabilityStatus::Unavailable) => "unavailable",
1547 Some(AvailabilityStatus::Unknown) => "unknown",
1548 None => "unknown",
1549 }
1550}
1551
1552fn annotate_one_availability(
1553 resolved: &mut models::ResolvedAlias,
1554 args: &ResolveAliasArgs,
1555 installed: &HashSet<String>,
1556 opencode_probe_result: Option<&OpenCodeProbeResult>,
1557 pi_probe_result: Option<&PiProbeResult>,
1558 cursor_probe_result: Option<&CursorProbeResult>,
1559) {
1560 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1561 resolved.availability = Some(models::availability::classify_model(
1562 &resolved.model_id,
1563 &resolved.provider,
1564 installed,
1565 opencode_probe_result,
1566 pi_probe_result,
1567 cursor_probe_result,
1568 is_offline,
1569 ));
1570}
1571
1572fn print_availability_text(availability: Option<&ModelAvailability>) {
1573 if let Some(availability) = availability {
1574 println!(
1575 "Availability: {} ({:?})",
1576 availability_status_label(Some(availability)),
1577 availability.source
1578 );
1579 for (idx, path) in availability.runnable_paths.iter().enumerate() {
1580 let label = if idx == 0 {
1581 "Runnable via:"
1582 } else {
1583 " "
1584 };
1585 println!("{label} {} -> {}", path.harness, path.harness_model_id);
1586 }
1587 }
1588}
1589
1590fn add_route_json_fields(out: &mut serde_json::Value, trace: &crate::routing::RoutingTrace) {
1591 let report = trace.to_report();
1592 out["route"] = serde_json::json!(report.compact_summary());
1593 out["route_trace"] = serde_json::json!(report);
1594}
1595
1596fn print_route_text(trace: &crate::routing::RoutingTrace) {
1597 let report = trace.to_report();
1598 println!(
1599 "Route: {} ({}, {}, {})",
1600 trace.selected_harness(),
1601 trace.source.label(),
1602 trace.selected_selection_kind().label(),
1603 trace.selected_match_evidence().label()
1604 );
1605 if !report.candidates_tried.is_empty() {
1606 println!("Tried: {}", report.candidates_tried.join(", "));
1607 }
1608 for assessment in report.assessments {
1609 if let Some(skip_reason) = assessment.skip_reason {
1610 println!("Skip: {} ({})", assessment.harness, skip_reason);
1611 }
1612 }
1613}
1614
1615fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1616 let project_config = load_project_config_layers_optional(&ctx.project_root)?;
1617 let merged = load_merged_aliases(&ctx.project_root, project_config.as_ref())?;
1618 let mars = mars_dir(ctx);
1619 let ttl = models_cache_ttl_hours(project_config.as_ref());
1620 let refresh =
1621 models::resolve_models_refresh_control(args.refresh_models, args.no_refresh_models)?;
1622 let mode = refresh.catalog_mode;
1623 let default_settings = crate::config::Settings::default();
1624 let settings = project_config
1625 .as_ref()
1626 .map(|loaded| &loaded.effective.settings)
1627 .unwrap_or(&default_settings);
1628 let routing_settings = ResolvedRoutingSettings::from_settings(settings);
1629 let routing_diagnostics = routing_settings.diagnostic_messages();
1630 if !json {
1631 emit_routing_settings_warnings(&routing_diagnostics);
1632 }
1633
1634 let mut cache_error = None;
1636 let cache_result = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
1637 FreshOrJsonError::Fresh(cache, outcome) => Some((cache, outcome)),
1638 FreshOrJsonError::JsonError(error_message) => {
1639 cache_error = Some(error_message);
1640 None
1641 }
1642 };
1643 let mut capability_session = CapabilitySession::collect(&CapabilityCollectionOptions {
1644 offline: models::is_mars_offline(),
1645 probe_refresh: refresh.probe_refresh,
1646 });
1647 let installed = capability_session.installed_harnesses();
1648
1649 if let Some(alias) = merged.get(&args.name) {
1651 if cache_result.is_none() && matches!(alias.spec, ModelSpec::AutoResolve { .. }) {
1652 return run_auto_resolve_alias_cache_unavailable(
1653 AutoResolveAliasCacheUnavailableInput {
1654 name: &args.name,
1655 alias,
1656 project_config: project_config.as_ref(),
1657 cache_error: cache_error.as_deref(),
1658 routing_diagnostics: &routing_diagnostics,
1659 json,
1660 },
1661 );
1662 }
1663
1664 let fallback_cache = models::ModelsCache {
1665 models: Vec::new(),
1666 fetched_at: None,
1667 };
1668 let fallback_outcome = models::RefreshOutcome::Offline;
1669 let fallback_catalog_slugs = models::catalog_model_slugs(&fallback_cache);
1670 let cache_catalog_slugs = cache_result
1671 .as_ref()
1672 .map(|(cache, _)| models::catalog_model_slugs(cache));
1673 let (cache, outcome) = cache_result
1674 .as_ref()
1675 .map(|(cache, outcome)| (cache, outcome))
1676 .unwrap_or((&fallback_cache, &fallback_outcome));
1677 let catalog_model_slugs = cache_catalog_slugs
1678 .as_deref()
1679 .unwrap_or(fallback_catalog_slugs.as_slice());
1680
1681 let runtime = ResolveRuntime {
1682 cache,
1683 catalog_model_slugs,
1684 outcome,
1685 installed: &installed,
1686 routing_settings: &routing_settings,
1687 probe_refresh: refresh.probe_refresh,
1688 };
1689 return run_resolve_exact_alias(
1690 ResolveExactAliasInput {
1691 args,
1692 alias,
1693 merged: &merged,
1694 project_config: project_config.as_ref(),
1695 runtime,
1696 routing_diagnostics: &routing_diagnostics,
1697 json,
1698 },
1699 &mut capability_session,
1700 );
1701 }
1702
1703 if let Some((cache, outcome)) = &cache_result
1705 && let Some(mut resolved) = models::resolve_with_alias_prefix_with_probe(
1706 &args.name, &merged, cache, None, None, None,
1707 )
1708 {
1709 let catalog_slugs = models::catalog_model_slugs(cache);
1710 let route_input = RouteTraceInput {
1711 model_id: &resolved.model_id,
1712 provider_for_order: models::infer_provider_from_model_id(&resolved.model_id)
1713 .unwrap_or(resolved.provider.as_str()),
1714 provider_constraint: None,
1715 installed: &installed,
1716 opencode_probe_result: None,
1717 pi_probe_result: None,
1718 cursor_probe_result: None,
1719 catalog_model_slugs: Some(catalog_slugs.as_slice()),
1720 routing_settings: &routing_settings,
1721 };
1722 let route_trace = {
1723 let mut probe_resolver = SessionProbeResolver {
1724 session: &mut capability_session,
1725 };
1726 route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1727 };
1728 resolved.harness = Some(route_trace.selected_harness().to_string());
1729 resolved.harness_source = match crate::routing::acceptance::accept_route(
1730 &route_trace,
1731 &installed,
1732 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1733 ) {
1734 Ok(()) => HarnessSource::AutoDetected,
1735 Err(_) => HarnessSource::Unavailable,
1736 };
1737 annotate_one_availability(
1738 &mut resolved,
1739 args,
1740 &installed,
1741 capability_session.loaded_opencode_probe_result(),
1742 capability_session.loaded_pi_probe_result(),
1743 capability_session.loaded_cursor_probe_result(),
1744 );
1745 let cache_outcome = capability_session
1746 .loaded_opencode_outcome()
1747 .cloned()
1748 .unwrap_or(CachedProbeOutcome::Unavailable);
1749 return run_output_resolved(OutputResolvedInput {
1750 name: &args.name,
1751 resolved: &resolved,
1752 source: "alias_prefix",
1753 route_trace: &route_trace,
1754 outcome,
1755 cache_outcome: &cache_outcome,
1756 probe_refresh: refresh.probe_refresh,
1757 routing_diagnostics: &routing_diagnostics,
1758 json,
1759 });
1760 }
1761
1762 let outcome = cache_result
1764 .as_ref()
1765 .map(|(_, o)| o.clone())
1766 .unwrap_or(models::RefreshOutcome::Offline);
1767 let is_offline = models::is_mars_offline() || args.no_refresh_models;
1768 let passthrough_catalog_slugs = cache_result
1769 .as_ref()
1770 .map(|(cache, _)| models::catalog_model_slugs(cache));
1771 run_output_passthrough(OutputPassthroughInput {
1772 name: &args.name,
1773 outcome: &outcome,
1774 is_offline,
1775 installed: &installed,
1776 capability_session: &mut capability_session,
1777 catalog_model_slugs: passthrough_catalog_slugs.as_deref(),
1778 routing_settings: &routing_settings,
1779 cache_error: cache_error.as_deref(),
1780 routing_diagnostics: &routing_diagnostics,
1781 json,
1782 })
1783}
1784
1785fn run_refresh_probe(args: &RefreshProbeArgs) -> Result<i32, MarsError> {
1786 match args.target.as_str() {
1787 "opencode" => opencode_cache::run_refresh_probe_command(),
1788 "pi" => pi_cache::run_refresh_probe_command(),
1789 "cursor" => cursor_cache::run_refresh_probe_command(),
1790 _ => Ok(1),
1791 }
1792}
1793
1794fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1795 let normalized_harness =
1796 models::harness::normalize_harness_name(&args.harness).ok_or_else(|| {
1797 MarsError::Config(ConfigError::Invalid {
1798 message: format!(
1799 "invalid harness '{}'; valid harnesses: {}",
1800 args.harness,
1801 models::harness::VALID_HARNESSES.join(", ")
1802 ),
1803 })
1804 })?;
1805 let mut config = crate::config::load(&ctx.project_root)?;
1806 config.models.insert(
1807 args.name.clone(),
1808 ModelAlias {
1809 harness: Some(normalized_harness.clone()),
1810 description: args.description.clone(),
1811 default_effort: None,
1812 autocompact: None,
1813 autocompact_pct: None,
1814 spec: ModelSpec::Pinned {
1815 model: args.model_id.clone(),
1816 provider: None,
1817 },
1818 },
1819 );
1820 crate::config::save(&ctx.project_root, &config)?;
1821
1822 if json {
1823 println!(
1824 "{}",
1825 serde_json::to_string_pretty(&serde_json::json!({
1826 "status": "ok",
1827 "alias": args.name,
1828 "model": args.model_id,
1829 "harness": normalized_harness,
1830 }))
1831 .unwrap()
1832 );
1833 } else {
1834 println!(
1835 "Added alias `{}` → {} (harness: {})",
1836 args.name, args.model_id, normalized_harness
1837 );
1838 }
1839
1840 Ok(0)
1841}
1842
1843enum FreshOrJsonError {
1844 Fresh(models::ModelsCache, models::RefreshOutcome),
1845 JsonError(String),
1846}
1847
1848fn ensure_fresh_or_json_error(
1849 mars: &std::path::Path,
1850 ttl: u32,
1851 mode: models::RefreshMode,
1852 json: bool,
1853) -> Result<FreshOrJsonError, MarsError> {
1854 match models::ensure_fresh(mars, ttl, mode) {
1855 Ok((cache, outcome)) => Ok(FreshOrJsonError::Fresh(cache, outcome)),
1856 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
1857 Ok(FreshOrJsonError::JsonError(format!("{err}")))
1858 }
1859 Err(err) => Err(err),
1860 }
1861}
1862
1863struct ResolveExactAliasInput<'a> {
1864 args: &'a ResolveAliasArgs,
1865 alias: &'a ModelAlias,
1866 merged: &'a IndexMap<String, ModelAlias>,
1867 project_config: Option<&'a crate::config::LoadedProjectConfig>,
1868 runtime: ResolveRuntime<'a>,
1869 routing_diagnostics: &'a [String],
1870 json: bool,
1871}
1872
1873fn run_resolve_exact_alias(
1874 input: ResolveExactAliasInput<'_>,
1875 capability_session: &mut CapabilitySession,
1876) -> Result<i32, MarsError> {
1877 let ResolveExactAliasInput {
1878 args,
1879 alias,
1880 merged,
1881 project_config,
1882 runtime,
1883 routing_diagnostics,
1884 json,
1885 } = input;
1886 let cache_warning = cache_warning(runtime.outcome);
1887 if let Some(warning) = cache_warning.as_deref()
1888 && !json
1889 {
1890 eprintln!("warning: {warning}");
1891 }
1892
1893 let name = &args.name;
1894 let source = determine_source(name, project_config);
1895 let mut diag = DiagnosticCollector::new();
1896 let mut resolved_entry =
1897 models::resolve_one_with_probe(name, merged, runtime.cache, &mut diag, None, None, None);
1898 let mut route_trace = None;
1899 let mut fixed_harness_route_rejection = None;
1900 if let Some(r) = resolved_entry.as_mut() {
1901 let provider_constraint = provider_constraint_for_alias(alias);
1902 let route_input = RouteTraceInput {
1903 model_id: &r.model_id,
1904 provider_for_order: &r.provider,
1905 provider_constraint: provider_constraint.as_deref(),
1906 installed: runtime.installed,
1907 opencode_probe_result: None,
1908 pi_probe_result: None,
1909 cursor_probe_result: None,
1910 catalog_model_slugs: Some(runtime.catalog_model_slugs),
1911 routing_settings: runtime.routing_settings,
1912 };
1913 route_trace = Some(if let Some(fixed_harness) = alias.harness.as_deref() {
1914 let mut probe_resolver = SessionProbeResolver {
1915 session: capability_session,
1916 };
1917 let fixed_trace = route_trace_for_fixed_harness_with_probes(
1918 &route_input,
1919 fixed_harness,
1920 crate::routing::RouteSource::Alias,
1921 &mut probe_resolver,
1922 );
1923 let assessed = fixed_trace
1924 .assessments
1925 .iter()
1926 .find(|assessment| assessment.harness == fixed_harness)
1927 .or_else(|| fixed_trace.assessments.first());
1928 fixed_harness_route_rejection = match assessed {
1929 Some(assessment) => crate::routing::acceptance::accept_assessment(assessment).err(),
1930 None => Some(
1931 crate::routing::acceptance::RejectionReason::AssessmentFailed {
1932 harness: fixed_harness.to_string(),
1933 skip_reason: Some("missing_assessment".to_string()),
1934 },
1935 ),
1936 };
1937 fixed_trace
1938 } else {
1939 let mut probe_resolver = SessionProbeResolver {
1940 session: capability_session,
1941 };
1942 route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1943 });
1944 if let Some(trace) = route_trace.as_ref() {
1945 r.harness = Some(trace.selected_harness().to_string());
1946 r.harness_source = match crate::routing::acceptance::accept_route(
1947 trace,
1948 runtime.installed,
1949 crate::routing::acceptance::MatchPolicy::InstalledOnly,
1950 ) {
1951 Ok(()) => HarnessSource::AutoDetected,
1952 Err(_) => HarnessSource::Unavailable,
1953 };
1954 if alias.harness.is_some() {
1955 r.harness_source = HarnessSource::Explicit;
1956 }
1957 }
1958 annotate_one_availability(
1959 r,
1960 args,
1961 runtime.installed,
1962 capability_session.loaded_opencode_probe_result(),
1963 capability_session.loaded_pi_probe_result(),
1964 capability_session.loaded_cursor_probe_result(),
1965 );
1966 }
1967 let diagnostics = diag.drain();
1968 let probe_outcome = capability_session
1969 .loaded_opencode_outcome()
1970 .cloned()
1971 .unwrap_or(CachedProbeOutcome::Unavailable);
1972
1973 if let Some(rejection_reason) = fixed_harness_route_rejection {
1974 let trace = route_trace
1975 .as_ref()
1976 .expect("fixed harness route trace exists");
1977 let Some(resolved) = resolved_entry.as_ref() else {
1978 return Ok(1);
1979 };
1980 return run_resolve_fixed_harness_failure(ResolveFixedHarnessFailureInput {
1981 name,
1982 source: source.as_str(),
1983 resolved,
1984 trace,
1985 cache_warning: cache_warning.as_deref(),
1986 diagnostics: &diagnostics,
1987 rejection_reason: &rejection_reason,
1988 routing_diagnostics,
1989 json,
1990 });
1991 }
1992
1993 if json {
1994 if let Some(r) = resolved_entry.as_ref() {
1995 let mut out = serde_json::json!({
1996 "name": r.name,
1997 "source": source,
1998 "provider": r.provider,
1999 "harness": r.harness,
2000 "harness_source": r.harness_source,
2001 "harness_candidates": r.harness_candidates,
2002 "model_id": r.model_id,
2003 "resolved_model": r.model_id,
2004 "spec": format_spec(&alias.spec),
2005 "description": r.description,
2006 });
2007 out["probe_cache"] = serde_json::json!(probe_outcome.cache_status());
2008 if let Some(error) = unavailable_harness_error(r) {
2009 out["error"] = serde_json::json!(error);
2010 }
2011 if let Some(default_effort) = &r.default_effort {
2012 out["default_effort"] = serde_json::json!(default_effort);
2013 }
2014 if let Some(autocompact) = r.autocompact {
2015 out["autocompact"] = serde_json::json!(autocompact);
2016 }
2017 if let Some(autocompact_pct) = r.autocompact_pct {
2018 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2019 }
2020 add_availability_json_fields(&mut out, r.availability.as_ref());
2021 if let Some(warning) = cache_warning.as_deref() {
2022 out["cache_warning"] = serde_json::json!(warning);
2023 }
2024 if !diagnostics.is_empty() {
2025 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2026 }
2027 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2028 if let Some(trace) = route_trace.as_ref() {
2029 add_route_json_fields(&mut out, trace);
2030 }
2031 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2032 } else {
2033 let mut out = serde_json::json!({
2034 "error": format!("alias `{}` did not resolve to a model ID", name),
2035 });
2036 if let Some(warning) = cache_warning.as_deref() {
2037 out["cache_warning"] = serde_json::json!(warning);
2038 }
2039 if !diagnostics.is_empty() {
2040 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2041 }
2042 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2043 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2044 return Ok(1);
2045 }
2046 } else {
2047 if runtime.probe_refresh == ProbeRefreshMode::Background
2048 && matches!(probe_outcome, CachedProbeOutcome::Stale(_))
2049 {
2050 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2051 }
2052 let Some(r) = resolved_entry.as_ref() else {
2053 eprintln!("error: alias `{}` did not resolve to a model ID", name);
2054 return Ok(1);
2055 };
2056 let harness = r.harness.as_deref().unwrap_or("—");
2057 println!("Alias: {}", name);
2058 println!("Source: {}", source);
2059 println!(
2060 "Harness: {} ({})",
2061 harness,
2062 harness_source_label(&r.harness_source)
2063 );
2064 println!("Provider: {}", r.provider);
2065 match &alias.spec {
2066 ModelSpec::Pinned { model, provider: _ } => {
2067 println!("Mode: pinned");
2068 println!("Model: {}", model);
2069 }
2070 ModelSpec::PinnedWithMatch {
2071 model,
2072 provider: _,
2073 match_patterns,
2074 exclude_patterns,
2075 } => {
2076 println!("Mode: pinned");
2077 println!("Model: {}", model);
2078 println!("Match: {}", match_patterns.join(", "));
2079 if !exclude_patterns.is_empty() {
2080 println!("Exclude: {}", exclude_patterns.join(", "));
2081 }
2082 println!("Resolved: {}", r.model_id);
2083 }
2084 ModelSpec::AutoResolve {
2085 provider: _,
2086 match_patterns,
2087 exclude_patterns,
2088 } => {
2089 println!("Mode: auto-resolve");
2090 println!("Match: {}", match_patterns.join(", "));
2091 if !exclude_patterns.is_empty() {
2092 println!("Exclude: {}", exclude_patterns.join(", "));
2093 }
2094 println!("Resolved: {}", r.model_id);
2095 }
2096 }
2097 if let Some(error) = unavailable_harness_error(r) {
2098 println!("Error: {}", error);
2099 }
2100 print_availability_text(r.availability.as_ref());
2101 if let Some(desc) = &r.description {
2102 println!("Desc: {}", desc);
2103 }
2104 if let Some(trace) = route_trace.as_ref() {
2105 print_route_text(trace);
2106 }
2107 emit_drained_text_diagnostics(&diagnostics);
2108 }
2109
2110 Ok(0)
2111}
2112
2113struct ResolveFixedHarnessFailureInput<'a> {
2114 name: &'a str,
2115 source: &'a str,
2116 resolved: &'a models::ResolvedAlias,
2117 trace: &'a crate::routing::RoutingTrace,
2118 cache_warning: Option<&'a str>,
2119 diagnostics: &'a [Diagnostic],
2120 rejection_reason: &'a crate::routing::acceptance::RejectionReason,
2121 routing_diagnostics: &'a [String],
2122 json: bool,
2123}
2124
2125struct AutoResolveAliasCacheUnavailableInput<'a> {
2126 name: &'a str,
2127 alias: &'a ModelAlias,
2128 project_config: Option<&'a crate::config::LoadedProjectConfig>,
2129 cache_error: Option<&'a str>,
2130 routing_diagnostics: &'a [String],
2131 json: bool,
2132}
2133
2134fn run_auto_resolve_alias_cache_unavailable(
2135 input: AutoResolveAliasCacheUnavailableInput<'_>,
2136) -> Result<i32, MarsError> {
2137 let AutoResolveAliasCacheUnavailableInput {
2138 name,
2139 alias,
2140 project_config,
2141 cache_error,
2142 routing_diagnostics,
2143 json,
2144 } = input;
2145 let source = determine_source(name, project_config);
2146 let detail = cache_error.unwrap_or("models cache unavailable");
2147 let error = format!(
2148 "alias `{name}` requires models cache for auto-resolve, but cache is unavailable ({detail})"
2149 );
2150
2151 if json {
2152 let mut out = serde_json::json!({
2153 "name": name,
2154 "source": source,
2155 "spec": format_spec(&alias.spec),
2156 "error": error,
2157 });
2158 if let Some(cache_error) = cache_error {
2159 out["cache_error"] = serde_json::json!(cache_error);
2160 }
2161 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2162 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2163 } else {
2164 eprintln!("error: {error}");
2165 }
2166
2167 Ok(1)
2168}
2169
2170fn run_resolve_fixed_harness_failure(
2171 input: ResolveFixedHarnessFailureInput<'_>,
2172) -> Result<i32, MarsError> {
2173 let ResolveFixedHarnessFailureInput {
2174 name,
2175 source,
2176 resolved,
2177 trace,
2178 cache_warning,
2179 diagnostics,
2180 rejection_reason,
2181 routing_diagnostics,
2182 json,
2183 } = input;
2184 let error_message = fixed_alias_rejection_message(rejection_reason);
2185
2186 if json {
2187 let mut out = serde_json::json!({
2188 "name": name,
2189 "source": source,
2190 "provider": resolved.provider,
2191 "harness": trace.selected_harness(),
2192 "model_id": resolved.model_id,
2193 "resolved_model": resolved.model_id,
2194 "error": error_message,
2195 "route_rejection": route_rejection_json(rejection_reason),
2196 "harnesses_tried": trace.candidates_tried,
2197 });
2198 add_route_json_fields(&mut out, trace);
2199 if let Some(warning) = cache_warning {
2200 out["cache_warning"] = serde_json::json!(warning);
2201 }
2202 if !diagnostics.is_empty() {
2203 out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(diagnostics));
2204 }
2205 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2206 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2207 } else {
2208 eprintln!("error: {error_message}");
2209 println!("Alias: {name}");
2210 println!("Source: {source}");
2211 println!("Provider: {}", resolved.provider);
2212 println!("Resolved: {}", resolved.model_id);
2213 print_route_text(trace);
2214 emit_drained_text_diagnostics(diagnostics);
2215 }
2216
2217 Ok(1)
2218}
2219
2220fn run_output_resolved(input: OutputResolvedInput<'_>) -> Result<i32, MarsError> {
2221 let OutputResolvedInput {
2222 name,
2223 resolved,
2224 source,
2225 route_trace,
2226 outcome,
2227 cache_outcome,
2228 probe_refresh,
2229 routing_diagnostics,
2230 json,
2231 } = input;
2232 let cache_warning = cache_warning(outcome);
2233 if let Some(warning) = cache_warning.as_deref()
2234 && !json
2235 {
2236 eprintln!("warning: {warning}");
2237 }
2238
2239 if json {
2240 let mut out = serde_json::json!({
2241 "name": name,
2242 "source": source,
2243 "provider": resolved.provider,
2244 "harness": resolved.harness,
2245 "harness_source": resolved.harness_source,
2246 "harness_candidates": resolved.harness_candidates,
2247 "model_id": resolved.model_id,
2248 "resolved_model": resolved.model_id,
2249 "description": resolved.description,
2250 });
2251 if let Some(error) = unavailable_harness_error(resolved) {
2252 out["error"] = serde_json::json!(error);
2253 }
2254 if let Some(default_effort) = &resolved.default_effort {
2255 out["default_effort"] = serde_json::json!(default_effort);
2256 }
2257 if let Some(autocompact) = resolved.autocompact {
2258 out["autocompact"] = serde_json::json!(autocompact);
2259 }
2260 if let Some(autocompact_pct) = resolved.autocompact_pct {
2261 out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2262 }
2263 out["probe_cache"] = serde_json::json!(cache_outcome.cache_status());
2264 add_availability_json_fields(&mut out, resolved.availability.as_ref());
2265 if let Some(warning) = cache_warning.as_deref() {
2266 out["cache_warning"] = serde_json::json!(warning);
2267 }
2268 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2269 add_route_json_fields(&mut out, route_trace);
2270 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2271 } else {
2272 if probe_refresh == ProbeRefreshMode::Background
2273 && matches!(cache_outcome, CachedProbeOutcome::Stale(_))
2274 {
2275 eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2276 }
2277 let harness = resolved.harness.as_deref().unwrap_or("—");
2278 println!("Alias: {}", name);
2279 println!("Source: {}", source);
2280 println!(
2281 "Harness: {} ({})",
2282 harness,
2283 harness_source_label(&resolved.harness_source)
2284 );
2285 println!("Provider: {}", resolved.provider);
2286 println!("Resolved: {}", resolved.model_id);
2287 if let Some(error) = unavailable_harness_error(resolved) {
2288 println!("Error: {}", error);
2289 }
2290 print_availability_text(resolved.availability.as_ref());
2291 if let Some(desc) = &resolved.description {
2292 println!("Desc: {}", desc);
2293 }
2294 print_route_text(route_trace);
2295 }
2296
2297 Ok(0)
2298}
2299
2300fn run_output_passthrough(input: OutputPassthroughInput<'_>) -> Result<i32, MarsError> {
2301 let OutputPassthroughInput {
2302 name,
2303 outcome,
2304 is_offline,
2305 installed,
2306 capability_session,
2307 catalog_model_slugs,
2308 routing_settings,
2309 cache_error,
2310 routing_diagnostics,
2311 json,
2312 } = input;
2313 if name.trim().is_empty() {
2314 if json {
2315 let mut out = serde_json::json!({
2316 "error": "model name cannot be empty",
2317 });
2318 if let Some(cache_error) = cache_error {
2319 out["cache_error"] = serde_json::json!(cache_error);
2320 }
2321 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2322 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2323 } else {
2324 eprintln!("error: model name cannot be empty");
2325 }
2326 return Ok(1);
2327 }
2328
2329 let cache_warning = cache_warning(outcome);
2330 if let Some(warning) = cache_warning.as_deref()
2331 && !json
2332 {
2333 eprintln!("warning: {warning}");
2334 }
2335
2336 let (passthrough_model_id, provider_constraint) =
2337 models::split_provider_constrained_model_token(name);
2338 let guessed_provider =
2339 models::infer_provider_from_model_id(&passthrough_model_id).map(str::to_string);
2340 let provider_for_order = provider_constraint.as_deref().unwrap_or("unknown");
2341 let provider_for_classification = guessed_provider
2342 .as_deref()
2343 .or(provider_constraint.as_deref())
2344 .unwrap_or("unknown");
2345 let routing_evidence = crate::routing::RoutingSettingsEvidence::new(
2346 &passthrough_model_id,
2347 Some(provider_for_order),
2348 provider_constraint.as_deref(),
2349 installed,
2350 None,
2351 None,
2352 None,
2353 catalog_model_slugs,
2354 routing_settings,
2355 );
2356 let trace = {
2357 let mut probe_resolver = SessionProbeResolver {
2358 session: capability_session,
2359 };
2360 crate::routing::evaluate_candidates_with_auth_and_probes(
2361 &routing_evidence.routing_input(),
2362 &mut probe_resolver,
2363 models::harness::native_harness_authenticated,
2364 )
2365 };
2366 if let Err(rejection_reason) = crate::routing::acceptance::accept_route(
2367 &trace,
2368 installed,
2369 crate::routing::acceptance::MatchPolicy::RequireSlugEvidence,
2370 ) {
2371 let message = passthrough_rejection_message(name, &rejection_reason);
2372 if json {
2373 let mut out = serde_json::json!({
2374 "error": message,
2375 "source": "passthrough",
2376 "model_id": passthrough_model_id,
2377 "resolved_model": passthrough_model_id,
2378 "provider_constraint": provider_constraint,
2379 "harnesses_tried": trace.candidates_tried,
2380 "route_rejection": route_rejection_json(&rejection_reason),
2381 });
2382 add_route_json_fields(&mut out, &trace);
2383 if !trace.selected_diagnostics().is_empty() {
2384 out["diagnostics"] = serde_json::json!(trace.selected_diagnostics());
2385 }
2386 if let Some(warning) = cache_warning.as_deref() {
2387 out["cache_warning"] = serde_json::json!(warning);
2388 }
2389 if let Some(cache_error) = cache_error {
2390 out["cache_error"] = serde_json::json!(cache_error);
2391 }
2392 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2393 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2394 } else {
2395 eprintln!("error: {message}");
2396 print_route_text(&trace);
2397 }
2398 return Ok(1);
2399 }
2400
2401 let harness = installed
2402 .contains(trace.selected_harness())
2403 .then_some(trace.selected_harness().to_string());
2404 let harness_source = "pattern_guess";
2405 let harness_candidates = models::harness::harness_candidates_for_provider(provider_for_order);
2406 let availability = models::availability::classify_model(
2407 &passthrough_model_id,
2408 provider_for_classification,
2409 installed,
2410 capability_session.loaded_opencode_probe_result(),
2411 capability_session.loaded_pi_probe_result(),
2412 capability_session.loaded_cursor_probe_result(),
2413 is_offline,
2414 );
2415
2416 let warning = passthrough_catalog_warning(name, &trace);
2417
2418 if json {
2419 let mut out = serde_json::json!({
2420 "name": name,
2421 "source": "passthrough",
2422 "model_id": passthrough_model_id,
2423 "resolved_model": passthrough_model_id,
2424 "provider": guessed_provider,
2425 "harness": harness,
2426 "harness_source": harness_source,
2427 "harness_candidates": harness_candidates,
2428 "description": serde_json::Value::Null,
2429 });
2430 if let Some(warning) = warning.as_deref() {
2431 out["warning"] = serde_json::json!(warning);
2432 }
2433 add_availability_json_fields(&mut out, Some(&availability));
2434 add_route_json_fields(&mut out, &trace);
2435 if let Some(warning) = cache_warning.as_deref() {
2436 out["cache_warning"] = serde_json::json!(warning);
2437 }
2438 if let Some(cache_error) = cache_error {
2439 out["cache_error"] = serde_json::json!(cache_error);
2440 }
2441 add_routing_diagnostics_json(&mut out, routing_diagnostics);
2442 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2443 } else {
2444 if let Some(warning) = warning.as_deref() {
2445 eprintln!("warning: {}", warning);
2446 }
2447 let h = harness.as_deref().unwrap_or("—");
2448 println!("Model: {}", name);
2449 println!("Source: passthrough");
2450 println!("Harness: {} ({})", h, harness_source);
2451 if let Some(provider) = guessed_provider {
2452 println!("Provider: {}", provider);
2453 }
2454 if !harness_candidates.is_empty() {
2455 println!("Candidates: {}", harness_candidates.join(", "));
2456 }
2457 print_route_text(&trace);
2458 }
2459
2460 Ok(0)
2461}
2462
2463fn load_project_config_layers_optional(
2468 project_root: &std::path::Path,
2469) -> Result<Option<crate::config::LoadedProjectConfig>, MarsError> {
2470 match crate::config::load_project_config_layers(project_root) {
2471 Ok(loaded) => Ok(Some(loaded)),
2472 Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(None),
2473 Err(err) => Err(err),
2474 }
2475}
2476
2477fn models_cache_ttl_hours(project_config: Option<&crate::config::LoadedProjectConfig>) -> u32 {
2478 project_config
2479 .map(|loaded| loaded.effective.settings.models_cache_ttl_hours)
2480 .unwrap_or_else(|| crate::config::Settings::default().models_cache_ttl_hours)
2481}
2482
2483fn load_merged_aliases(
2486 project_root: &std::path::Path,
2487 project_config: Option<&crate::config::LoadedProjectConfig>,
2488) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
2489 let lock = crate::lock::load_for_runtime_aliases(project_root)?;
2490 Ok(models::merged_runtime_aliases(
2491 &lock.dependency_model_aliases,
2492 project_config.map(|loaded| &loaded.effective.models),
2493 ))
2494}
2495
2496fn determine_source(
2498 name: &str,
2499 project_config: Option<&crate::config::LoadedProjectConfig>,
2500) -> String {
2501 let Some(project_config) = project_config else {
2502 return "unknown".to_string();
2503 };
2504
2505 if project_config.local.models.contains_key(name) {
2506 return "consumer local (mars.local.toml)".to_string();
2507 }
2508
2509 if project_config.config.models.contains_key(name) {
2510 return "consumer (mars.toml)".to_string();
2511 }
2512
2513 "dependency".to_string()
2514}
2515
2516fn format_spec(spec: &ModelSpec) -> serde_json::Value {
2517 match spec {
2518 ModelSpec::Pinned { model, provider } => {
2519 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
2520 if let Some(provider) = provider {
2521 out["provider"] = serde_json::json!(provider);
2522 }
2523 out
2524 }
2525 ModelSpec::PinnedWithMatch {
2526 model,
2527 provider,
2528 match_patterns,
2529 exclude_patterns,
2530 } => {
2531 let mut out = serde_json::json!({
2532 "mode": "pinned",
2533 "model": model,
2534 "match": match_patterns,
2535 "exclude": exclude_patterns,
2536 });
2537 if let Some(provider) = provider {
2538 out["provider"] = serde_json::json!(provider);
2539 }
2540 out
2541 }
2542 ModelSpec::AutoResolve {
2543 provider,
2544 match_patterns,
2545 exclude_patterns,
2546 } => {
2547 serde_json::json!({
2548 "mode": "auto-resolve",
2549 "provider": provider,
2550 "match": match_patterns,
2551 "exclude": exclude_patterns,
2552 })
2553 }
2554 }
2555}
2556
2557fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
2558 match spec {
2559 Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
2560 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
2561 None => "unknown",
2562 }
2563}
2564
2565fn harness_source_label(source: &HarnessSource) -> &'static str {
2566 match source {
2567 HarnessSource::Explicit => "explicit",
2568 HarnessSource::AutoDetected => "auto-detected",
2569 HarnessSource::Unavailable => "unavailable",
2570 }
2571}
2572
2573fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
2574 if resolved.harness_source != HarnessSource::Unavailable {
2575 return None;
2576 }
2577 if let Some(h) = &resolved.harness {
2578 Some(format!("Harness '{}' is not installed", h))
2579 } else {
2580 Some(format!(
2581 "No installed harness for provider '{}'. Install one of: {}",
2582 resolved.provider,
2583 resolved.harness_candidates.join(", ")
2584 ))
2585 }
2586}
2587
2588fn fixed_alias_rejection_message(
2589 rejection: &crate::routing::acceptance::RejectionReason,
2590) -> String {
2591 match rejection {
2592 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2593 "alias harness `{harness}` is not installed and cannot run resolved model under model-first routing"
2594 ),
2595 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => format!(
2596 "alias harness `{harness}` did not provide required model slug evidence under model-first routing"
2597 ),
2598 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2599 harness,
2600 skip_reason,
2601 } => format!(
2602 "alias harness `{harness}` cannot run resolved model under model-first routing ({})",
2603 skip_reason.as_deref().unwrap_or("unavailable")
2604 ),
2605 }
2606}
2607
2608fn passthrough_rejection_message(
2609 model_name: &str,
2610 rejection: &crate::routing::acceptance::RejectionReason,
2611) -> String {
2612 match rejection {
2613 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2614 "model '{model_name}' selected harness '{harness}', but that harness is not installed"
2615 ),
2616 crate::routing::acceptance::RejectionReason::NoSlugEvidence { .. } => format!(
2617 "model '{model_name}' did not match any harness-reported model slug under model-first routing"
2618 ),
2619 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2620 harness,
2621 skip_reason,
2622 } => format!(
2623 "model '{model_name}' failed model-first routing assessment on harness '{harness}' ({})",
2624 skip_reason.as_deref().unwrap_or("unavailable")
2625 ),
2626 }
2627}
2628
2629fn passthrough_catalog_warning(name: &str, trace: &crate::routing::RoutingTrace) -> Option<String> {
2630 match trace.selected_match_evidence() {
2631 crate::routing::MatchEvidence::Passthrough => Some(format!(
2632 "model '{}' not found in catalog, passing through to harness",
2633 name
2634 )),
2635 crate::routing::MatchEvidence::Confirmed | crate::routing::MatchEvidence::Constrained => {
2636 None
2637 }
2638 crate::routing::MatchEvidence::None => None,
2639 }
2640}
2641
2642fn route_rejection_json(
2643 rejection: &crate::routing::acceptance::RejectionReason,
2644) -> serde_json::Value {
2645 match rejection {
2646 crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => {
2647 serde_json::json!({
2648 "reason": "harness_not_installed",
2649 "harness": harness,
2650 })
2651 }
2652 crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => {
2653 serde_json::json!({
2654 "reason": "no_slug_evidence",
2655 "harness": harness,
2656 })
2657 }
2658 crate::routing::acceptance::RejectionReason::AssessmentFailed {
2659 harness,
2660 skip_reason,
2661 } => {
2662 serde_json::json!({
2663 "reason": "assessment_failed",
2664 "harness": harness,
2665 "skip_reason": skip_reason,
2666 })
2667 }
2668 }
2669}
2670
2671fn stale_warning(reason: &str) -> String {
2672 format!("models cache refresh failed: {reason}; using stale cache")
2673}
2674
2675fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
2676 match outcome {
2677 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
2678 _ => None,
2679 }
2680}
2681
2682fn emit_routing_settings_warnings(routing_diagnostics: &[String]) {
2683 for message in routing_diagnostics {
2684 eprintln!("warning: {message}");
2685 }
2686}
2687
2688fn add_routing_diagnostics_json(out: &mut serde_json::Value, routing_diagnostics: &[String]) {
2689 if !routing_diagnostics.is_empty() {
2690 out["routing_diagnostics"] = serde_json::json!(routing_diagnostics);
2691 }
2692}
2693
2694fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
2695 diagnostics
2696 .iter()
2697 .map(|diagnostic| {
2698 serde_json::json!({
2699 "level": diagnostic_level_label(diagnostic.level),
2700 "code": diagnostic.code,
2701 "message": diagnostic.message,
2702 "context": diagnostic.context,
2703 })
2704 })
2705 .collect()
2706}
2707
2708fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
2709 let diagnostics = diag.drain();
2710 if diagnostics.is_empty() {
2711 None
2712 } else {
2713 Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
2714 }
2715}
2716
2717fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
2718 for diagnostic in diagnostics {
2719 let label = diagnostic_level_label(diagnostic.level);
2720 eprintln!("{label}: {}", diagnostic.message);
2721 }
2722}
2723
2724fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
2725 let diagnostics = diag.drain();
2726 emit_drained_text_diagnostics(&diagnostics);
2727}
2728
2729fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
2730 match level {
2731 DiagnosticLevel::Error => "error",
2732 DiagnosticLevel::Warning => "warning",
2733 DiagnosticLevel::Info => "info",
2734 }
2735}
2736
2737#[cfg(test)]
2738mod tests {
2739 use super::*;
2740 use clap::Parser;
2741 use indexmap::IndexMap;
2742 use tempfile::TempDir;
2743
2744 fn write_mars_toml(temp: &TempDir, contents: &str) {
2745 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
2746 }
2747
2748 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
2749 match result {
2750 Ok(code) => code,
2751 Err(err) => err.exit_code(),
2752 }
2753 }
2754
2755 #[test]
2756 fn list_args_parses_no_refresh_models() {
2757 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
2758 assert!(args.no_refresh_models);
2759 }
2760
2761 #[test]
2762 fn list_args_parses_refresh_models() {
2763 let args = ListArgs::try_parse_from(["mars", "--refresh-models"]).unwrap();
2764 assert!(args.refresh_models);
2765 }
2766
2767 #[test]
2768 fn list_refresh_and_no_refresh_conflict() {
2769 assert!(
2770 ListArgs::try_parse_from(["mars", "--refresh-models", "--no-refresh-models"]).is_err()
2771 );
2772 }
2773
2774 #[test]
2775 fn list_args_parses_catalog() {
2776 let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
2777 assert!(args.catalog);
2778 }
2779
2780 #[test]
2781 fn list_all_and_catalog_conflict() {
2782 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
2783 assert!(parsed.is_err());
2784 }
2785
2786 #[test]
2787 fn list_all_and_include_can_combine() {
2788 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
2789 assert!(parsed.is_ok());
2790 }
2791
2792 #[test]
2793 fn list_catalog_and_include_can_combine() {
2794 let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
2795 assert!(parsed.is_ok());
2796 }
2797
2798 #[test]
2799 fn resolve_alias_args_parses_no_refresh_models() {
2800 let args =
2801 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
2802 assert!(args.no_refresh_models);
2803 }
2804
2805 #[test]
2806 fn list_no_refresh_without_cache_is_non_zero() {
2807 let temp = TempDir::new().unwrap();
2808 write_mars_toml(&temp, "[settings]\n");
2809 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2810 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
2811
2812 let exit = normalized_exit_code(run(&args, &ctx, false));
2813 assert_ne!(exit, 0);
2814 }
2815
2816 #[test]
2817 fn resolve_no_refresh_without_cache_is_non_zero() {
2818 let temp = TempDir::new().unwrap();
2819 write_mars_toml(
2820 &temp,
2821 r#"[settings]
2822
2823[models.opus]
2824harness = "claude"
2825model = "claude-opus-4-6"
2826"#,
2827 );
2828 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2829 let args =
2830 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
2831
2832 let exit = normalized_exit_code(run(&args, &ctx, false));
2833 assert_ne!(exit, 0);
2834 }
2835
2836 #[test]
2837 fn alias_updates_existing_model_entry() {
2838 let temp = TempDir::new().unwrap();
2839 write_mars_toml(
2840 &temp,
2841 r#"[settings]
2842
2843[models.fast]
2844harness = "claude"
2845model = "claude-3-5-sonnet"
2846description = "Old alias"
2847"#,
2848 );
2849 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2850
2851 let args = AddAliasArgs {
2852 name: "fast".to_string(),
2853 model_id: "gpt-5.3-codex".to_string(),
2854 harness: "codex".to_string(),
2855 description: Some("Updated alias".to_string()),
2856 };
2857
2858 let exit = run_alias(&args, &ctx, false).unwrap();
2859 assert_eq!(exit, 0);
2860
2861 let config = crate::config::load(temp.path()).unwrap();
2862 assert_eq!(config.models.len(), 1);
2863
2864 let alias = config.models.get("fast").unwrap();
2865 assert_eq!(alias.harness.as_deref(), Some("codex"));
2866 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
2867 match &alias.spec {
2868 ModelSpec::Pinned { model, provider } => {
2869 assert_eq!(model, "gpt-5.3-codex");
2870 assert_eq!(provider, &None);
2871 }
2872 _ => panic!("expected pinned alias"),
2873 }
2874 }
2875
2876 #[test]
2877 fn alias_rejects_invalid_harness_at_write_boundary() {
2878 let temp = TempDir::new().unwrap();
2879 write_mars_toml(&temp, "[settings]\n");
2880 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2881
2882 let args = AddAliasArgs {
2883 name: "fast".to_string(),
2884 model_id: "gpt-5.3-codex".to_string(),
2885 harness: "gemini".to_string(),
2886 description: None,
2887 };
2888
2889 let err = run_alias(&args, &ctx, false).unwrap_err().to_string();
2890 assert!(err.contains("invalid harness 'gemini'"));
2891 assert!(err.contains("valid harnesses: claude, codex, pi, opencode, cursor"));
2892 }
2893
2894 #[test]
2895 fn alias_normalizes_mixed_case_harness_before_write() {
2896 let temp = TempDir::new().unwrap();
2897 write_mars_toml(&temp, "[settings]\n");
2898 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2899
2900 let args = AddAliasArgs {
2901 name: "fast".to_string(),
2902 model_id: "gpt-5.3-codex".to_string(),
2903 harness: "OpenCode".to_string(),
2904 description: None,
2905 };
2906
2907 let exit = run_alias(&args, &ctx, false).unwrap();
2908 assert_eq!(exit, 0);
2909
2910 let config = crate::config::load(temp.path()).unwrap();
2911 let alias = config.models.get("fast").unwrap();
2912 assert_eq!(alias.harness.as_deref(), Some("opencode"));
2913 }
2914
2915 fn auto_alias(
2916 provider: &str,
2917 match_patterns: &[&str],
2918 exclude_patterns: &[&str],
2919 ) -> ModelAlias {
2920 ModelAlias {
2921 harness: None,
2922 description: None,
2923 default_effort: None,
2924 autocompact: None,
2925 autocompact_pct: None,
2926 spec: ModelSpec::AutoResolve {
2927 provider: provider.to_string(),
2928 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2929 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2930 },
2931 }
2932 }
2933
2934 fn pinned_with_match_alias(
2935 model: &str,
2936 provider: &str,
2937 match_patterns: &[&str],
2938 exclude_patterns: &[&str],
2939 ) -> ModelAlias {
2940 ModelAlias {
2941 harness: None,
2942 description: None,
2943 default_effort: None,
2944 autocompact: None,
2945 autocompact_pct: None,
2946 spec: ModelSpec::PinnedWithMatch {
2947 model: model.to_string(),
2948 provider: Some(provider.to_string()),
2949 match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2950 exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2951 },
2952 }
2953 }
2954
2955 fn pinned_alias(model: &str) -> ModelAlias {
2956 ModelAlias {
2957 harness: None,
2958 description: None,
2959 default_effort: None,
2960 autocompact: None,
2961 autocompact_pct: None,
2962 spec: ModelSpec::Pinned {
2963 model: model.to_string(),
2964 provider: None,
2965 },
2966 }
2967 }
2968
2969 fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
2970 ModelAlias {
2971 harness: None,
2972 description: None,
2973 default_effort: None,
2974 autocompact: None,
2975 autocompact_pct: None,
2976 spec: ModelSpec::Pinned {
2977 model: model.to_string(),
2978 provider: Some(provider.to_string()),
2979 },
2980 }
2981 }
2982
2983 fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
2984 models::CachedModel {
2985 id: id.to_string(),
2986 provider: provider.to_string(),
2987 release_date: release_date.map(|value| value.to_string()),
2988 description: Some(format!("desc-{id}")),
2989 context_window: None,
2990 max_output: None,
2991 cost_input: None,
2992 cost_output: None,
2993 cost_cache_read: None,
2994 cost_cache_write: None,
2995 cost_reasoning: None,
2996 }
2997 }
2998
2999 fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
3000 models::ModelsCache {
3001 models,
3002 fetched_at: Some("123".to_string()),
3003 }
3004 }
3005
3006 fn installed(names: &[&str]) -> HashSet<String> {
3007 names.iter().map(|name| (*name).to_string()).collect()
3008 }
3009
3010 fn default_routing_settings() -> ResolvedRoutingSettings {
3011 crate::config::routing_settings::resolve(&crate::config::Settings::default())
3012 }
3013
3014 #[allow(clippy::too_many_arguments)]
3015 fn collect_all_model_entries(
3016 merged: &IndexMap<String, ModelAlias>,
3017 cache: &models::ModelsCache,
3018 installed: &HashSet<String>,
3019 opencode_probe_result: Option<&OpenCodeProbeResult>,
3020 pi_probe_result: Option<&PiProbeResult>,
3021 cursor_probe_result: Option<&CursorProbeResult>,
3022 is_offline: bool,
3023 routing_settings: &ResolvedRoutingSettings,
3024 ) -> Vec<ListModelEntry> {
3025 let catalog_slugs = models::catalog_model_slugs(cache);
3026 super::collect_all_model_entries(
3027 merged,
3028 cache,
3029 AvailabilityContext {
3030 installed,
3031 opencode_probe_result,
3032 pi_probe_result,
3033 cursor_probe_result,
3034 catalog_model_slugs: Some(catalog_slugs.as_slice()),
3035 is_offline,
3036 routing_settings,
3037 },
3038 )
3039 }
3040
3041 fn collect_catalog_model_entries(
3042 cache: &models::ModelsCache,
3043 installed: &HashSet<String>,
3044 opencode_probe_result: Option<&OpenCodeProbeResult>,
3045 pi_probe_result: Option<&PiProbeResult>,
3046 cursor_probe_result: Option<&CursorProbeResult>,
3047 is_offline: bool,
3048 routing_settings: &ResolvedRoutingSettings,
3049 ) -> Vec<ListModelEntry> {
3050 collect_catalog_model_entries_with_auth(
3051 cache,
3052 installed,
3053 opencode_probe_result,
3054 pi_probe_result,
3055 cursor_probe_result,
3056 is_offline,
3057 routing_settings,
3058 models::harness::native_harness_authenticated,
3059 )
3060 }
3061
3062 #[allow(clippy::too_many_arguments)]
3063 fn collect_catalog_model_entries_with_auth<F>(
3064 cache: &models::ModelsCache,
3065 installed: &HashSet<String>,
3066 opencode_probe_result: Option<&OpenCodeProbeResult>,
3067 pi_probe_result: Option<&PiProbeResult>,
3068 cursor_probe_result: Option<&CursorProbeResult>,
3069 is_offline: bool,
3070 routing_settings: &ResolvedRoutingSettings,
3071 auth_check: F,
3072 ) -> Vec<ListModelEntry>
3073 where
3074 F: Fn(&str) -> bool + Copy,
3075 {
3076 let catalog_slugs = models::catalog_model_slugs(cache);
3077 let availability_ctx = AvailabilityContext {
3078 installed,
3079 opencode_probe_result,
3080 pi_probe_result,
3081 cursor_probe_result,
3082 catalog_model_slugs: Some(catalog_slugs.as_slice()),
3083 is_offline,
3084 routing_settings,
3085 };
3086 let mut out: Vec<ListModelEntry> = cache
3087 .models
3088 .iter()
3089 .map(|model| {
3090 super::model_entry_for_cached_with_auth(model, availability_ctx, auth_check)
3091 })
3092 .collect();
3093 super::sort_list_model_entries(&mut out);
3094 out
3095 }
3096
3097 #[test]
3098 fn list_all_shows_multiple_per_alias() {
3099 let mut merged = IndexMap::new();
3100 merged.insert(
3101 "opus".to_string(),
3102 auto_alias("Anthropic", &["claude-opus-*"], &[]),
3103 );
3104
3105 let models_cache = cache(vec![
3106 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3107 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
3108 ]);
3109
3110 let installed = installed(&[]);
3111 let rows = collect_all_model_entries(
3112 &merged,
3113 &models_cache,
3114 &installed,
3115 None,
3116 None,
3117 None,
3118 false,
3119 &default_routing_settings(),
3120 );
3121 assert_eq!(rows.len(), 2);
3122 assert_eq!(rows[0].id, "claude-opus-4-7");
3123 assert_eq!(rows[1].id, "claude-opus-4-6");
3124 }
3125
3126 #[test]
3127 fn list_all_includes_matched_aliases_with_dedup() {
3128 let mut merged = IndexMap::new();
3129 merged.insert(
3130 "opus".to_string(),
3131 auto_alias("Anthropic", &["claude-opus-*"], &[]),
3132 );
3133 merged.insert(
3134 "legacy".to_string(),
3135 auto_alias("Anthropic", &["*4-6"], &[]),
3136 );
3137
3138 let models_cache = cache(vec![cached_model(
3139 "claude-opus-4-6",
3140 "Anthropic",
3141 Some("2026-02-05"),
3142 )]);
3143
3144 let installed = installed(&[]);
3145 let rows = collect_all_model_entries(
3146 &merged,
3147 &models_cache,
3148 &installed,
3149 None,
3150 None,
3151 None,
3152 false,
3153 &default_routing_settings(),
3154 );
3155 assert_eq!(rows.len(), 1);
3156 assert_eq!(rows[0].id, "claude-opus-4-6");
3157 assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
3158 }
3159
3160 #[test]
3161 fn list_all_includes_pinned_cache_entries() {
3162 let mut merged = IndexMap::new();
3163 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3164
3165 let models_cache = cache(vec![cached_model(
3166 "gpt-5.3-codex",
3167 "OpenAI",
3168 Some("2026-01-01"),
3169 )]);
3170 let installed = installed(&[]);
3171 let rows = collect_all_model_entries(
3172 &merged,
3173 &models_cache,
3174 &installed,
3175 None,
3176 None,
3177 None,
3178 false,
3179 &default_routing_settings(),
3180 );
3181 assert_eq!(rows.len(), 1);
3182 assert_eq!(rows[0].id, "gpt-5.3-codex");
3183 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3184 }
3185
3186 #[test]
3187 fn list_all_includes_pinned_cache_miss_entries() {
3188 let mut merged = IndexMap::new();
3189 merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3190
3191 let models_cache = cache(Vec::new());
3192 let installed = installed(&[]);
3193 let rows = collect_all_model_entries(
3194 &merged,
3195 &models_cache,
3196 &installed,
3197 None,
3198 None,
3199 None,
3200 false,
3201 &default_routing_settings(),
3202 );
3203 assert_eq!(rows.len(), 1);
3204 assert_eq!(rows[0].id, "gpt-5.3-codex");
3205 assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
3206 assert_eq!(rows[0].release_date, None);
3207 assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3208 }
3209
3210 #[test]
3211 fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
3212 let mut merged = IndexMap::new();
3213 merged.insert(
3214 "custom".to_string(),
3215 pinned_alias_with_provider("custom-model-id", "Anthropic"),
3216 );
3217
3218 let models_cache = cache(Vec::new());
3219 let installed = installed(&[]);
3220 let rows = collect_all_model_entries(
3221 &merged,
3222 &models_cache,
3223 &installed,
3224 None,
3225 None,
3226 None,
3227 false,
3228 &default_routing_settings(),
3229 );
3230 assert_eq!(rows.len(), 1);
3231 assert_eq!(rows[0].id, "custom-model-id");
3232 assert_eq!(rows[0].provider, "Anthropic");
3233 assert_eq!(rows[0].release_date, None);
3234 assert_eq!(rows[0].matched_aliases, vec!["custom"]);
3235 }
3236
3237 #[test]
3238 fn list_all_includes_unavailable_harness_entries_with_fallback_candidates() {
3239 let mut merged = IndexMap::new();
3240 merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
3241 let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
3242
3243 let installed = installed(&[]);
3244 let rows = collect_all_model_entries(
3245 &merged,
3246 &models_cache,
3247 &installed,
3248 None,
3249 None,
3250 None,
3251 false,
3252 &default_routing_settings(),
3253 );
3254 assert_eq!(rows.len(), 1);
3255 assert_eq!(rows[0].harness, None);
3256 assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
3257 assert_eq!(rows[0].harness_candidates, vec!["pi", "opencode", "cursor"]);
3258 }
3259
3260 #[test]
3261 fn list_catalog_shows_all_cache_sorted() {
3262 let models_cache = cache(vec![
3263 cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
3264 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3265 cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
3266 ]);
3267
3268 let installed = installed(&[]);
3269 let rows = collect_catalog_model_entries(
3270 &models_cache,
3271 &installed,
3272 None,
3273 None,
3274 None,
3275 false,
3276 &default_routing_settings(),
3277 );
3278 assert_eq!(rows.len(), 3);
3279 assert_eq!(rows[0].id, "claude-opus-4-6");
3280 assert_eq!(rows[1].id, "claude-sonnet-4-5");
3281 assert_eq!(rows[2].id, "gpt-5");
3282 }
3283
3284 #[test]
3285 fn list_catalog_uses_catalog_slugs_for_native_harness_matching() {
3286 let models_cache = cache(vec![cached_model(
3287 "claude-opus-4-6",
3288 "Anthropic",
3289 Some("2026-02-05"),
3290 )]);
3291
3292 let installed = installed(&["claude"]);
3293 let rows = collect_catalog_model_entries_with_auth(
3294 &models_cache,
3295 &installed,
3296 None,
3297 None,
3298 None,
3299 false,
3300 &default_routing_settings(),
3301 |_| true,
3302 );
3303
3304 assert_eq!(rows.len(), 1);
3305 assert_eq!(rows[0].harness.as_deref(), Some("claude"));
3306 assert_eq!(rows[0].harness_source, HarnessSource::AutoDetected);
3307 }
3308
3309 #[test]
3310 fn list_all_includes_pinned_with_match_discovery_candidates() {
3311 let mut merged = IndexMap::new();
3312 merged.insert(
3313 "opus".to_string(),
3314 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3315 );
3316 let models_cache = cache(vec![
3317 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3318 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3319 ]);
3320
3321 let installed = installed(&[]);
3322 let rows = collect_all_model_entries(
3323 &merged,
3324 &models_cache,
3325 &installed,
3326 None,
3327 None,
3328 None,
3329 false,
3330 &default_routing_settings(),
3331 );
3332 assert_eq!(rows.len(), 2);
3333 assert_eq!(rows[0].id, "claude-opus-4-7");
3334 assert_eq!(rows[1].id, "claude-opus-4-6");
3335 assert_eq!(rows[0].matched_aliases, vec!["opus"]);
3336 assert_eq!(rows[1].matched_aliases, vec!["opus"]);
3337 }
3338
3339 #[test]
3340 fn resolve_pinned_with_match_uses_model_field() {
3341 let mut merged = IndexMap::new();
3342 merged.insert(
3343 "opus".to_string(),
3344 pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3345 );
3346 let models_cache = cache(vec![
3347 cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3348 cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3349 ]);
3350 let mut diag = DiagnosticCollector::new();
3351 let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
3352 assert_eq!(resolved.model_id, "claude-opus-4-6");
3353 assert!(diag.drain().is_empty());
3354 }
3355
3356 fn passthrough_trace(
3357 match_evidence: crate::routing::MatchEvidence,
3358 ) -> crate::routing::RoutingTrace {
3359 crate::routing::RoutingTrace {
3360 source: crate::routing::RouteSource::Provider,
3361 selection_kind: crate::routing::SelectionKind::Auto,
3362 match_evidence,
3363 harness: "opencode".to_string(),
3364 harness_order_position: None,
3365 candidates_tried: vec!["opencode".to_string()],
3366 assessments: Vec::new(),
3367 diagnostics: Vec::new(),
3368 }
3369 }
3370
3371 #[test]
3372 fn passthrough_catalog_warning_omits_warning_for_confirmed_and_constrained_routes() {
3373 assert!(
3374 passthrough_catalog_warning(
3375 "openai/gpt-5.4-mini",
3376 &passthrough_trace(crate::routing::MatchEvidence::Confirmed)
3377 )
3378 .is_none()
3379 );
3380 assert!(
3381 passthrough_catalog_warning(
3382 "openai/gpt-5.4-mini",
3383 &passthrough_trace(crate::routing::MatchEvidence::Constrained)
3384 )
3385 .is_none()
3386 );
3387 }
3388
3389 #[test]
3390 fn passthrough_catalog_warning_keeps_warning_for_passthrough_routes() {
3391 let warning = passthrough_catalog_warning(
3392 "unknown-model",
3393 &passthrough_trace(crate::routing::MatchEvidence::Passthrough),
3394 )
3395 .expect("passthrough warning expected");
3396 assert!(warning.contains("not found in catalog"));
3397 }
3398}