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