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