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