Skip to main content

mars_agents/cli/
models.rs

1//! CLI handlers for `mars models` subcommands.
2#![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/// Manage model aliases and the models cache.
31#[derive(Debug, Parser)]
32pub struct ModelsArgs {
33    #[command(subcommand)]
34    pub command: ModelsCommand,
35}
36
37#[derive(Debug, Subcommand)]
38pub enum ModelsCommand {
39    /// Fetch models from API and update the local cache.
40    Refresh,
41    /// List all model aliases (consumer + deps) with resolved IDs.
42    List(ListArgs),
43    /// Show resolution chain for a specific alias.
44    Resolve(ResolveAliasArgs),
45    /// Show prompting guidance for an agent or model alias.
46    Prompting(PromptingArgs),
47    /// Quick-add a pinned alias to mars.toml [models].
48    Alias(AddAliasArgs),
49    #[command(name = "__refresh-probe", hide = true)]
50    RefreshProbe(RefreshProbeArgs),
51}
52
53#[derive(Debug, Parser)]
54pub struct ListArgs {
55    /// Show all alias candidates. Does NOT show raw catalog - use --catalog for that.
56    #[arg(long, conflicts_with = "catalog", conflicts_with = "unavailable")]
57    all: bool,
58    /// Enable routed live availability details (selected harness, availability, runnable paths).
59    #[arg(long)]
60    live: bool,
61    /// Refresh models.dev catalog and harness probes synchronously before running (blocks until complete).
62    #[arg(long, conflicts_with = "no_refresh_models")]
63    refresh_models: bool,
64    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
65    #[arg(long, conflicts_with = "refresh_models")]
66    no_refresh_models: bool,
67    /// Only show aliases matching these patterns (overrides config).
68    #[arg(long, value_delimiter = ',')]
69    include: Option<Vec<String>>,
70    /// Hide aliases matching these patterns (overrides config).
71    #[arg(long, value_delimiter = ',')]
72    exclude: Option<Vec<String>>,
73    /// Show raw models.dev cache entries (diagnostic view). Ignores aliases.
74    #[arg(long, conflicts_with = "all")]
75    catalog: bool,
76    /// Include unavailable models in output (only affects --live output).
77    #[arg(long)]
78    unavailable: bool,
79}
80
81#[derive(Debug, Parser)]
82pub struct ResolveAliasArgs {
83    /// Alias name to resolve.
84    pub name: String,
85    /// Refresh models.dev catalog and harness probes synchronously before running (blocks until complete).
86    #[arg(long, conflicts_with = "no_refresh_models")]
87    refresh_models: bool,
88    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
89    #[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    /// Alias name.
102    pub name: String,
103    /// Model ID to pin.
104    pub model_id: String,
105    /// Harness for this alias (default: claude).
106    #[arg(long, default_value = "claude")]
107    pub harness: String,
108    /// Optional description.
109    #[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    // Load runtime aliases before cache refresh so legacy locks that predate
198    // dependency alias authority fail with an explicit sync remediation instead
199    // of surfacing an unrelated cache error first.
200    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    // Build effective visibility: CLI overrides config entirely.
329    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        // Table output
389        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    // Cache is enrichment, not a gate. If unavailable, skip to passthrough.
1645    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    // Step 1: exact alias lookup
1660    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    // Step 2: alias-prefix resolution
1714    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    // Step 3: passthrough — no cache needed
1773    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
2474// ---------------------------------------------------------------------------
2475// Helpers
2476// ---------------------------------------------------------------------------
2477
2478/// Determine which layer provides an alias (consumer or dependency).
2479fn 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}