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