Skip to main content

mars_agents/cli/
models.rs

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