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