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 models::auto_resolve_all(
863                    provider.as_deref(),
864                    match_patterns,
865                    exclude_patterns,
866                    cache,
867                ) {
868                    append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
869                }
870            }
871            ModelSpec::Pinned {
872                model, provider, ..
873            } => {
874                if let Some(matched) = cache
875                    .models
876                    .iter()
877                    .find(|cache_model| cache_model.id == *model)
878                {
879                    append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
880                } else {
881                    append_pinned_alias_match(
882                        &mut by_model_id,
883                        model,
884                        provider.as_deref(),
885                        alias.description.as_deref(),
886                        availability_ctx,
887                        alias_name,
888                    );
889                }
890            }
891            ModelSpec::PinnedWithMatch {
892                model,
893                provider,
894                match_patterns,
895                exclude_patterns,
896            } => {
897                if let Some(matched) = cache
898                    .models
899                    .iter()
900                    .find(|cache_model| cache_model.id == *model)
901                {
902                    append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
903                } else {
904                    append_pinned_alias_match(
905                        &mut by_model_id,
906                        model,
907                        provider.as_deref(),
908                        alias.description.as_deref(),
909                        availability_ctx,
910                        alias_name,
911                    );
912                }
913
914                let provider_for_discovery = provider
915                    .as_deref()
916                    .or_else(|| models::infer_provider_from_model_id(model));
917                for matched in models::auto_resolve_all(
918                    provider_for_discovery,
919                    match_patterns,
920                    exclude_patterns,
921                    cache,
922                ) {
923                    append_alias_match(&mut by_model_id, matched, availability_ctx, alias_name);
924                }
925            }
926        }
927    }
928
929    let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
930    sort_list_model_entries(&mut out);
931    out
932}
933
934fn collect_catalog_model_entries(
935    cache: &models::ModelsCache,
936    availability_ctx: AvailabilityContext<'_>,
937) -> Vec<ListModelEntry> {
938    let mut out: Vec<ListModelEntry> = cache
939        .models
940        .iter()
941        .map(|model| model_entry_for_cached(model, availability_ctx))
942        .collect();
943    sort_list_model_entries(&mut out);
944    out
945}
946
947fn collect_all_model_entries_static(
948    merged: &IndexMap<String, ModelAlias>,
949    cache: &models::ModelsCache,
950) -> Vec<ListModelEntry> {
951    let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
952
953    for (alias_name, alias) in merged {
954        match &alias.spec {
955            ModelSpec::AutoResolve {
956                provider,
957                match_patterns,
958                exclude_patterns,
959            } => {
960                for matched in models::auto_resolve_all(
961                    provider.as_deref(),
962                    match_patterns,
963                    exclude_patterns,
964                    cache,
965                ) {
966                    let entry = by_model_id
967                        .entry(matched.id.clone())
968                        .or_insert_with(|| model_entry_for_cached_static(matched));
969                    append_alias_name(entry, alias_name);
970                }
971            }
972            ModelSpec::Pinned {
973                model, provider, ..
974            } => {
975                let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
976                    cache
977                        .models
978                        .iter()
979                        .find(|cache_model| cache_model.id == *model)
980                        .map(model_entry_for_cached_static)
981                        .unwrap_or_else(|| {
982                            model_entry_for_pinned_static(
983                                model,
984                                provider.as_deref(),
985                                alias.description.as_deref(),
986                            )
987                        })
988                });
989                append_alias_name(entry, alias_name);
990            }
991            ModelSpec::PinnedWithMatch {
992                model,
993                provider,
994                match_patterns,
995                exclude_patterns,
996            } => {
997                let entry = by_model_id.entry(model.clone()).or_insert_with(|| {
998                    cache
999                        .models
1000                        .iter()
1001                        .find(|cache_model| cache_model.id == *model)
1002                        .map(model_entry_for_cached_static)
1003                        .unwrap_or_else(|| {
1004                            model_entry_for_pinned_static(
1005                                model,
1006                                provider.as_deref(),
1007                                alias.description.as_deref(),
1008                            )
1009                        })
1010                });
1011                append_alias_name(entry, alias_name);
1012
1013                let provider_for_discovery = provider
1014                    .as_deref()
1015                    .or_else(|| models::infer_provider_from_model_id(model));
1016                for matched in models::auto_resolve_all(
1017                    provider_for_discovery,
1018                    match_patterns,
1019                    exclude_patterns,
1020                    cache,
1021                ) {
1022                    let entry = by_model_id
1023                        .entry(matched.id.clone())
1024                        .or_insert_with(|| model_entry_for_cached_static(matched));
1025                    append_alias_name(entry, alias_name);
1026                }
1027            }
1028        }
1029    }
1030
1031    let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
1032    sort_list_model_entries(&mut out);
1033    out
1034}
1035
1036fn collect_catalog_model_entries_static(cache: &models::ModelsCache) -> Vec<ListModelEntry> {
1037    let mut out: Vec<ListModelEntry> = cache
1038        .models
1039        .iter()
1040        .map(model_entry_for_cached_static)
1041        .collect();
1042    sort_list_model_entries(&mut out);
1043    out
1044}
1045
1046fn append_alias_match(
1047    by_model_id: &mut IndexMap<String, ListModelEntry>,
1048    model: &models::CachedModel,
1049    availability_ctx: AvailabilityContext<'_>,
1050    alias_name: &str,
1051) {
1052    let entry = by_model_id
1053        .entry(model.id.clone())
1054        .or_insert_with(|| model_entry_for_cached(model, availability_ctx));
1055
1056    append_alias_name(entry, alias_name);
1057}
1058
1059fn append_pinned_alias_match(
1060    by_model_id: &mut IndexMap<String, ListModelEntry>,
1061    model_id: &str,
1062    provider: Option<&str>,
1063    description: Option<&str>,
1064    availability_ctx: AvailabilityContext<'_>,
1065    alias_name: &str,
1066) {
1067    let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
1068        model_entry_for_pinned(model_id, provider, description, availability_ctx)
1069    });
1070
1071    append_alias_name(entry, alias_name);
1072}
1073
1074fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
1075    if !entry
1076        .matched_aliases
1077        .iter()
1078        .any(|existing| existing == alias_name)
1079    {
1080        entry.matched_aliases.push(alias_name.to_string());
1081    }
1082}
1083
1084fn model_entry_for_cached(
1085    model: &models::CachedModel,
1086    availability_ctx: AvailabilityContext<'_>,
1087) -> ListModelEntry {
1088    model_entry_for_cached_with_auth(
1089        model,
1090        availability_ctx,
1091        models::harness::native_harness_authenticated,
1092    )
1093}
1094
1095fn model_entry_for_cached_with_auth<F>(
1096    model: &models::CachedModel,
1097    availability_ctx: AvailabilityContext<'_>,
1098    auth_check: F,
1099) -> ListModelEntry
1100where
1101    F: Fn(&str) -> bool,
1102{
1103    let (harness, harness_source) =
1104        resolve_harness_with_routing_auth(&model.provider, &model.id, availability_ctx, auth_check);
1105
1106    ListModelEntry {
1107        id: model.id.clone(),
1108        provider: model.provider.clone(),
1109        release_date: model.release_date.clone(),
1110        harness,
1111        harness_source,
1112        harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
1113        description: model.description.clone(),
1114        cost_input: model.cost_input,
1115        cost_output: model.cost_output,
1116        cost_cache_read: model.cost_cache_read,
1117        cost_cache_write: model.cost_cache_write,
1118        cost_reasoning: model.cost_reasoning,
1119        matched_aliases: Vec::new(),
1120        availability: Some(models::availability::classify_model(
1121            &model.id,
1122            &model.provider,
1123            availability_ctx.installed,
1124            availability_ctx.opencode_probe_result,
1125            availability_ctx.pi_probe_result,
1126            availability_ctx.cursor_probe_result,
1127            availability_ctx.is_offline,
1128        )),
1129    }
1130}
1131
1132fn model_entry_for_pinned(
1133    model_id: &str,
1134    provider: Option<&str>,
1135    description: Option<&str>,
1136    availability_ctx: AvailabilityContext<'_>,
1137) -> ListModelEntry {
1138    let provider = provider
1139        .map(str::to_string)
1140        .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1141        .unwrap_or_else(|| "unknown".to_string());
1142    let (harness, harness_source) =
1143        resolve_harness_with_routing(&provider, model_id, availability_ctx);
1144
1145    ListModelEntry {
1146        id: model_id.to_string(),
1147        provider: provider.clone(),
1148        release_date: None,
1149        harness,
1150        harness_source,
1151        harness_candidates: models::harness::harness_candidates_for_provider(&provider),
1152        description: description.map(str::to_string),
1153        cost_input: None,
1154        cost_output: None,
1155        cost_cache_read: None,
1156        cost_cache_write: None,
1157        cost_reasoning: None,
1158        matched_aliases: Vec::new(),
1159        availability: Some(models::availability::classify_model(
1160            model_id,
1161            &provider,
1162            availability_ctx.installed,
1163            availability_ctx.opencode_probe_result,
1164            availability_ctx.pi_probe_result,
1165            availability_ctx.cursor_probe_result,
1166            availability_ctx.is_offline,
1167        )),
1168    }
1169}
1170
1171fn model_entry_for_cached_static(model: &models::CachedModel) -> ListModelEntry {
1172    ListModelEntry {
1173        id: model.id.clone(),
1174        provider: model.provider.clone(),
1175        release_date: model.release_date.clone(),
1176        harness: None,
1177        harness_source: HarnessSource::Unavailable,
1178        harness_candidates: Vec::new(),
1179        description: model.description.clone(),
1180        cost_input: model.cost_input,
1181        cost_output: model.cost_output,
1182        cost_cache_read: model.cost_cache_read,
1183        cost_cache_write: model.cost_cache_write,
1184        cost_reasoning: model.cost_reasoning,
1185        matched_aliases: Vec::new(),
1186        availability: None,
1187    }
1188}
1189
1190fn model_entry_for_pinned_static(
1191    model_id: &str,
1192    provider: Option<&str>,
1193    description: Option<&str>,
1194) -> ListModelEntry {
1195    let provider = provider
1196        .map(str::to_string)
1197        .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
1198        .unwrap_or_else(|| "unknown".to_string());
1199    ListModelEntry {
1200        id: model_id.to_string(),
1201        provider,
1202        release_date: None,
1203        harness: None,
1204        harness_source: HarnessSource::Unavailable,
1205        harness_candidates: Vec::new(),
1206        description: description.map(str::to_string),
1207        cost_input: None,
1208        cost_output: None,
1209        cost_cache_read: None,
1210        cost_cache_write: None,
1211        cost_reasoning: None,
1212        matched_aliases: Vec::new(),
1213        availability: None,
1214    }
1215}
1216
1217fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
1218    entries.sort_by(|a, b| {
1219        a.provider
1220            .to_ascii_lowercase()
1221            .cmp(&b.provider.to_ascii_lowercase())
1222            .then_with(|| {
1223                b.release_date
1224                    .as_deref()
1225                    .unwrap_or("")
1226                    .cmp(a.release_date.as_deref().unwrap_or(""))
1227            })
1228            .then_with(|| a.id.cmp(&b.id))
1229    });
1230}
1231
1232fn routing_settings_evidence<'a>(
1233    input: &'a RouteTraceInput<'a>,
1234) -> crate::routing::RoutingSettingsEvidence<'a> {
1235    crate::routing::RoutingSettingsEvidence::new(
1236        input.model_id,
1237        Some(input.provider_for_order),
1238        input.provider_constraint,
1239        input.installed,
1240        input.opencode_probe_result,
1241        input.pi_probe_result,
1242        input.cursor_probe_result,
1243        input.catalog_model_slugs,
1244        input.routing_settings,
1245    )
1246}
1247
1248fn resolve_harness_with_routing(
1249    provider: &str,
1250    model_id: &str,
1251    availability_ctx: AvailabilityContext<'_>,
1252) -> (Option<String>, HarnessSource) {
1253    resolve_harness_with_routing_auth(
1254        provider,
1255        model_id,
1256        availability_ctx,
1257        models::harness::native_harness_authenticated,
1258    )
1259}
1260
1261fn resolve_harness_with_routing_auth<F>(
1262    provider: &str,
1263    model_id: &str,
1264    availability_ctx: AvailabilityContext<'_>,
1265    auth_check: F,
1266) -> (Option<String>, HarnessSource)
1267where
1268    F: Fn(&str) -> bool,
1269{
1270    let route_input = RouteTraceInput {
1271        model_id,
1272        provider_for_order: provider,
1273        provider_constraint: None,
1274        installed: availability_ctx.installed,
1275        opencode_probe_result: availability_ctx.opencode_probe_result,
1276        pi_probe_result: availability_ctx.pi_probe_result,
1277        cursor_probe_result: availability_ctx.cursor_probe_result,
1278        catalog_model_slugs: availability_ctx.catalog_model_slugs,
1279        routing_settings: availability_ctx.routing_settings,
1280    };
1281    let routing_evidence = routing_settings_evidence(&route_input);
1282    let trace = crate::routing::evaluate_candidates_with_auth(
1283        &routing_evidence.routing_input(),
1284        auth_check,
1285    );
1286
1287    match crate::routing::acceptance::accept_route(
1288        &trace,
1289        availability_ctx.installed,
1290        crate::routing::acceptance::MatchPolicy::InstalledOnly,
1291    ) {
1292        Ok(()) => (
1293            Some(trace.selected_harness().to_string()),
1294            HarnessSource::AutoDetected,
1295        ),
1296        Err(_) => (None, HarnessSource::Unavailable),
1297    }
1298}
1299
1300fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
1301    match &alias.spec {
1302        ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
1303            provider.clone()
1304        }
1305        ModelSpec::AutoResolve { provider, .. } => provider.clone(),
1306    }
1307    .map(|provider| provider.trim().to_ascii_lowercase())
1308}
1309
1310fn route_trace_for_resolved_model(input: &RouteTraceInput<'_>) -> crate::routing::RoutingTrace {
1311    let routing_evidence = routing_settings_evidence(input);
1312    crate::routing::evaluate_candidates(&routing_evidence.routing_input())
1313}
1314
1315fn route_trace_for_resolved_model_with_probes(
1316    input: &RouteTraceInput<'_>,
1317    probe_resolver: &mut dyn crate::routing::ProbeResolver,
1318) -> crate::routing::RoutingTrace {
1319    let routing_evidence = routing_settings_evidence(input);
1320    crate::routing::evaluate_candidates_with_auth_and_probes(
1321        &routing_evidence.routing_input(),
1322        probe_resolver,
1323        models::harness::native_harness_authenticated,
1324    )
1325}
1326
1327fn route_trace_for_fixed_harness_with_probes(
1328    input: &RouteTraceInput<'_>,
1329    fixed_harness: &str,
1330    source: crate::routing::RouteSource,
1331    probe_resolver: &mut dyn crate::routing::ProbeResolver,
1332) -> crate::routing::RoutingTrace {
1333    let provider_for_order = crate::routing::provider_for_order_for_fixed_harness(
1334        Some(input.provider_for_order),
1335        fixed_harness,
1336    );
1337    let routing_evidence = routing_settings_evidence(input);
1338    let mut fixed_input = routing_evidence.routing_input();
1339    fixed_input.provider_for_order = provider_for_order;
1340    let assessment = crate::routing::evaluate_fixed_harness_with_auth_and_probes(
1341        &fixed_input,
1342        fixed_harness,
1343        probe_resolver,
1344        models::harness::native_harness_authenticated,
1345    );
1346    crate::routing::trace_for_fixed_harness(source, fixed_harness, assessment, Vec::new())
1347}
1348
1349fn effective_visibility(
1350    project_config: Option<&crate::config::LoadedProjectConfig>,
1351    args: &ListArgs,
1352) -> crate::config::ModelVisibility {
1353    if args.include.is_some() || args.exclude.is_some() {
1354        return crate::config::ModelVisibility {
1355            include: args.include.clone(),
1356            exclude: args.exclude.clone(),
1357        };
1358    }
1359
1360    project_config
1361        .map(|loaded| loaded.effective.settings.model_visibility.clone())
1362        .unwrap_or_default()
1363}
1364
1365#[allow(clippy::too_many_arguments)]
1366fn apply_routing_settings_to_resolved_aliases(
1367    resolved: &mut IndexMap<String, models::ResolvedAlias>,
1368    aliases: &IndexMap<String, ModelAlias>,
1369    installed: &HashSet<String>,
1370    opencode_probe_result: Option<&OpenCodeProbeResult>,
1371    pi_probe_result: Option<&PiProbeResult>,
1372    cursor_probe_result: Option<&CursorProbeResult>,
1373    catalog_model_slugs: Option<&[String]>,
1374    routing_settings: &ResolvedRoutingSettings,
1375) {
1376    for alias in resolved.values_mut() {
1377        let has_explicit_harness = aliases
1378            .get(&alias.name)
1379            .is_some_and(|source_alias| source_alias.harness.is_some());
1380        if has_explicit_harness {
1381            continue;
1382        }
1383        apply_routing_settings_to_resolved_alias(
1384            alias,
1385            installed,
1386            opencode_probe_result,
1387            pi_probe_result,
1388            cursor_probe_result,
1389            catalog_model_slugs,
1390            routing_settings,
1391        );
1392    }
1393}
1394
1395fn apply_routing_settings_to_resolved_alias(
1396    alias: &mut models::ResolvedAlias,
1397    installed: &HashSet<String>,
1398    opencode_probe_result: Option<&OpenCodeProbeResult>,
1399    pi_probe_result: Option<&PiProbeResult>,
1400    cursor_probe_result: Option<&CursorProbeResult>,
1401    catalog_model_slugs: Option<&[String]>,
1402    routing_settings: &ResolvedRoutingSettings,
1403) {
1404    let provider_for_order =
1405        models::infer_provider_from_model_id(&alias.model_id).unwrap_or(alias.provider.as_str());
1406    let route_input = RouteTraceInput {
1407        model_id: &alias.model_id,
1408        provider_for_order,
1409        provider_constraint: None,
1410        installed,
1411        opencode_probe_result,
1412        pi_probe_result,
1413        cursor_probe_result,
1414        catalog_model_slugs,
1415        routing_settings,
1416    };
1417    let trace = route_trace_for_resolved_model(&route_input);
1418    alias.harness = Some(trace.harness.clone());
1419    alias.harness_source = match crate::routing::acceptance::accept_route(
1420        &trace,
1421        installed,
1422        crate::routing::acceptance::MatchPolicy::InstalledOnly,
1423    ) {
1424        Ok(()) => HarnessSource::AutoDetected,
1425        Err(_) => HarnessSource::Unavailable,
1426    };
1427}
1428
1429fn annotate_resolved_availability(
1430    resolved: &mut IndexMap<String, models::ResolvedAlias>,
1431    installed: &HashSet<String>,
1432    opencode_probe_result: Option<&OpenCodeProbeResult>,
1433    pi_probe_result: Option<&PiProbeResult>,
1434    cursor_probe_result: Option<&CursorProbeResult>,
1435    is_offline: bool,
1436) {
1437    for alias in resolved.values_mut() {
1438        alias.availability = Some(models::availability::classify_model(
1439            &alias.model_id,
1440            &alias.provider,
1441            installed,
1442            opencode_probe_result,
1443            pi_probe_result,
1444            cursor_probe_result,
1445            is_offline,
1446        ));
1447    }
1448}
1449
1450fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
1451    resolved.retain(|_, alias| {
1452        alias
1453            .availability
1454            .as_ref()
1455            .map(|availability| availability.status != AvailabilityStatus::Unavailable)
1456            .unwrap_or(true)
1457    });
1458}
1459
1460fn filter_model_entries_by_visibility(
1461    entries: Vec<ListModelEntry>,
1462    visibility: &crate::config::ModelVisibility,
1463) -> Vec<ListModelEntry> {
1464    if visibility.include.is_none() && visibility.exclude.is_none() {
1465        return entries;
1466    }
1467
1468    entries
1469        .into_iter()
1470        .filter(|entry| {
1471            let paths = entry
1472                .availability
1473                .as_ref()
1474                .map(|availability| availability.runnable_paths.as_slice())
1475                .unwrap_or(&[]);
1476            let included = visibility.include.as_ref().is_none_or(|includes| {
1477                includes.iter().any(|pattern| {
1478                    models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1479                })
1480            });
1481            let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
1482                excludes.iter().any(|pattern| {
1483                    models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
1484                })
1485            });
1486            included && !excluded
1487        })
1488        .collect()
1489}
1490
1491fn add_availability_json_fields(
1492    obj: &mut serde_json::Value,
1493    availability: Option<&ModelAvailability>,
1494) {
1495    if let Some(availability) = availability {
1496        obj["availability"] = serde_json::json!(availability.status);
1497        obj["availability_source"] = serde_json::json!(availability.source);
1498        obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
1499    }
1500}
1501
1502fn add_cost_json_fields(obj: &mut serde_json::Value, model: &models::CachedModel) {
1503    obj["cost_input"] = serde_json::json!(model.cost_input);
1504    obj["cost_output"] = serde_json::json!(model.cost_output);
1505    obj["cost_cache_read"] = serde_json::json!(model.cost_cache_read);
1506    obj["cost_cache_write"] = serde_json::json!(model.cost_cache_write);
1507    obj["cost_reasoning"] = serde_json::json!(model.cost_reasoning);
1508}
1509
1510fn add_probe_results_json(
1511    out: &mut serde_json::Value,
1512    probe_result: Option<&OpenCodeProbeResult>,
1513    pi_probe_result: Option<&PiProbeResult>,
1514    cursor_probe_result: Option<&CursorProbeResult>,
1515) {
1516    if let Some(probe) = probe_result {
1517        out["probe_results"] = serde_json::json!({
1518            "opencode": {
1519                "success": probe.model_probe_success,
1520                "models_found": probe.model_slugs.len(),
1521            }
1522        });
1523    }
1524    if let Some(probe) = pi_probe_result {
1525        if out.get("probe_results").is_none() {
1526            out["probe_results"] = serde_json::json!({});
1527        }
1528        out["probe_results"]["pi"] = serde_json::json!({
1529            "compatible": probe.compatible,
1530            "version": probe.version,
1531            "missing_surface_tokens": probe.help_surface_tokens_missing,
1532        });
1533    }
1534    if let Some(probe) = cursor_probe_result {
1535        if out.get("probe_results").is_none() {
1536            out["probe_results"] = serde_json::json!({});
1537        }
1538        out["probe_results"]["cursor"] = serde_json::json!({
1539            "success": probe.model_probe_success,
1540            "models_found": probe.slugs.len(),
1541        });
1542    }
1543}
1544
1545fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
1546    match availability.map(|value| value.status) {
1547        Some(AvailabilityStatus::Runnable) => "runnable",
1548        Some(AvailabilityStatus::Unavailable) => "unavailable",
1549        Some(AvailabilityStatus::Unknown) => "unknown",
1550        None => "unknown",
1551    }
1552}
1553
1554fn annotate_one_availability(
1555    resolved: &mut models::ResolvedAlias,
1556    args: &ResolveAliasArgs,
1557    installed: &HashSet<String>,
1558    opencode_probe_result: Option<&OpenCodeProbeResult>,
1559    pi_probe_result: Option<&PiProbeResult>,
1560    cursor_probe_result: Option<&CursorProbeResult>,
1561) {
1562    let is_offline = models::is_mars_offline() || args.no_refresh_models;
1563    resolved.availability = Some(models::availability::classify_model(
1564        &resolved.model_id,
1565        &resolved.provider,
1566        installed,
1567        opencode_probe_result,
1568        pi_probe_result,
1569        cursor_probe_result,
1570        is_offline,
1571    ));
1572}
1573
1574fn print_availability_text(availability: Option<&ModelAvailability>) {
1575    if let Some(availability) = availability {
1576        println!(
1577            "Availability: {} ({:?})",
1578            availability_status_label(Some(availability)),
1579            availability.source
1580        );
1581        for (idx, path) in availability.runnable_paths.iter().enumerate() {
1582            let label = if idx == 0 {
1583                "Runnable via:"
1584            } else {
1585                "             "
1586            };
1587            println!("{label} {} -> {}", path.harness, path.harness_model_id);
1588        }
1589    }
1590}
1591
1592fn add_route_json_fields(out: &mut serde_json::Value, trace: &crate::routing::RoutingTrace) {
1593    let report = trace.to_report();
1594    out["route"] = serde_json::json!(report.compact_summary());
1595    out["route_trace"] = serde_json::json!(report);
1596}
1597
1598fn print_route_text(trace: &crate::routing::RoutingTrace) {
1599    let report = trace.to_report();
1600    println!(
1601        "Route:    {} ({}, {}, {})",
1602        trace.selected_harness(),
1603        trace.source.label(),
1604        trace.selected_selection_kind().label(),
1605        trace.selected_match_evidence().label()
1606    );
1607    if !report.candidates_tried.is_empty() {
1608        println!("Tried:    {}", report.candidates_tried.join(", "));
1609    }
1610    for assessment in report.assessments {
1611        if let Some(skip_reason) = assessment.skip_reason {
1612            println!("Skip:     {} ({})", assessment.harness, skip_reason);
1613        }
1614    }
1615}
1616
1617fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1618    let project_config = load_project_config_layers_optional(&ctx.project_root)?;
1619    let merged = load_merged_aliases(&ctx.project_root, project_config.as_ref())?;
1620    let mars = mars_dir(ctx);
1621    let ttl = models_cache_ttl_hours(project_config.as_ref());
1622    let refresh =
1623        models::resolve_models_refresh_control(args.refresh_models, args.no_refresh_models)?;
1624    let mode = refresh.catalog_mode;
1625    let default_settings = crate::config::Settings::default();
1626    let settings = project_config
1627        .as_ref()
1628        .map(|loaded| &loaded.effective.settings)
1629        .unwrap_or(&default_settings);
1630    let routing_settings = ResolvedRoutingSettings::from_settings(settings);
1631    let routing_diagnostics = routing_settings.diagnostic_messages();
1632    if !json {
1633        emit_routing_settings_warnings(&routing_diagnostics);
1634    }
1635
1636    // Cache is enrichment, not a gate. If unavailable, skip to passthrough.
1637    let mut cache_error = None;
1638    let cache_result = match ensure_fresh_or_json_error(&mars, ttl, mode, json)? {
1639        FreshOrJsonError::Fresh(cache, outcome) => Some((cache, outcome)),
1640        FreshOrJsonError::JsonError(error_message) => {
1641            cache_error = Some(error_message);
1642            None
1643        }
1644    };
1645    let mut capability_session = CapabilitySession::collect(&CapabilityCollectionOptions {
1646        offline: models::is_mars_offline(),
1647        probe_refresh: refresh.probe_refresh,
1648    });
1649    let installed = capability_session.installed_harnesses();
1650
1651    // Step 1: exact alias lookup
1652    if let Some(alias) = merged.get(&args.name) {
1653        if cache_result.is_none() && matches!(alias.spec, ModelSpec::AutoResolve { .. }) {
1654            return run_auto_resolve_alias_cache_unavailable(
1655                AutoResolveAliasCacheUnavailableInput {
1656                    name: &args.name,
1657                    alias,
1658                    project_config: project_config.as_ref(),
1659                    cache_error: cache_error.as_deref(),
1660                    routing_diagnostics: &routing_diagnostics,
1661                    json,
1662                },
1663            );
1664        }
1665
1666        let fallback_cache = models::ModelsCache {
1667            models: Vec::new(),
1668            fetched_at: None,
1669        };
1670        let fallback_outcome = models::RefreshOutcome::Offline;
1671        let fallback_catalog_slugs = models::catalog_model_slugs(&fallback_cache);
1672        let cache_catalog_slugs = cache_result
1673            .as_ref()
1674            .map(|(cache, _)| models::catalog_model_slugs(cache));
1675        let (cache, outcome) = cache_result
1676            .as_ref()
1677            .map(|(cache, outcome)| (cache, outcome))
1678            .unwrap_or((&fallback_cache, &fallback_outcome));
1679        let catalog_model_slugs = cache_catalog_slugs
1680            .as_deref()
1681            .unwrap_or(fallback_catalog_slugs.as_slice());
1682
1683        let runtime = ResolveRuntime {
1684            cache,
1685            catalog_model_slugs,
1686            outcome,
1687            installed: &installed,
1688            routing_settings: &routing_settings,
1689            probe_refresh: refresh.probe_refresh,
1690        };
1691        return run_resolve_exact_alias(
1692            ResolveExactAliasInput {
1693                args,
1694                alias,
1695                merged: &merged,
1696                project_config: project_config.as_ref(),
1697                runtime,
1698                routing_diagnostics: &routing_diagnostics,
1699                json,
1700            },
1701            &mut capability_session,
1702        );
1703    }
1704
1705    // Step 2: alias-prefix resolution
1706    if let Some((cache, outcome)) = &cache_result
1707        && let Some(mut resolved) = models::resolve_with_alias_prefix_with_probe(
1708            &args.name, &merged, cache, None, None, None,
1709        )
1710    {
1711        let catalog_slugs = models::catalog_model_slugs(cache);
1712        let route_input = RouteTraceInput {
1713            model_id: &resolved.model_id,
1714            provider_for_order: models::infer_provider_from_model_id(&resolved.model_id)
1715                .unwrap_or(resolved.provider.as_str()),
1716            provider_constraint: None,
1717            installed: &installed,
1718            opencode_probe_result: None,
1719            pi_probe_result: None,
1720            cursor_probe_result: None,
1721            catalog_model_slugs: Some(catalog_slugs.as_slice()),
1722            routing_settings: &routing_settings,
1723        };
1724        let route_trace = {
1725            let mut probe_resolver = SessionProbeResolver {
1726                session: &mut capability_session,
1727            };
1728            route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1729        };
1730        resolved.harness = Some(route_trace.selected_harness().to_string());
1731        resolved.harness_source = match crate::routing::acceptance::accept_route(
1732            &route_trace,
1733            &installed,
1734            crate::routing::acceptance::MatchPolicy::InstalledOnly,
1735        ) {
1736            Ok(()) => HarnessSource::AutoDetected,
1737            Err(_) => HarnessSource::Unavailable,
1738        };
1739        annotate_one_availability(
1740            &mut resolved,
1741            args,
1742            &installed,
1743            capability_session.loaded_opencode_probe_result(),
1744            capability_session.loaded_pi_probe_result(),
1745            capability_session.loaded_cursor_probe_result(),
1746        );
1747        let cache_outcome = capability_session
1748            .loaded_opencode_outcome()
1749            .cloned()
1750            .unwrap_or(CachedProbeOutcome::Unavailable);
1751        return run_output_resolved(OutputResolvedInput {
1752            name: &args.name,
1753            resolved: &resolved,
1754            source: "alias_prefix",
1755            route_trace: &route_trace,
1756            outcome,
1757            cache_outcome: &cache_outcome,
1758            probe_refresh: refresh.probe_refresh,
1759            routing_diagnostics: &routing_diagnostics,
1760            json,
1761        });
1762    }
1763
1764    // Step 3: passthrough — no cache needed
1765    let outcome = cache_result
1766        .as_ref()
1767        .map(|(_, o)| o.clone())
1768        .unwrap_or(models::RefreshOutcome::Offline);
1769    let is_offline = models::is_mars_offline() || args.no_refresh_models;
1770    let passthrough_catalog_slugs = cache_result
1771        .as_ref()
1772        .map(|(cache, _)| models::catalog_model_slugs(cache));
1773    run_output_passthrough(OutputPassthroughInput {
1774        name: &args.name,
1775        outcome: &outcome,
1776        is_offline,
1777        installed: &installed,
1778        capability_session: &mut capability_session,
1779        catalog_model_slugs: passthrough_catalog_slugs.as_deref(),
1780        routing_settings: &routing_settings,
1781        cache_error: cache_error.as_deref(),
1782        routing_diagnostics: &routing_diagnostics,
1783        json,
1784    })
1785}
1786
1787fn run_refresh_probe(args: &RefreshProbeArgs) -> Result<i32, MarsError> {
1788    match args.target.as_str() {
1789        "opencode" => opencode_cache::run_refresh_probe_command(),
1790        "pi" => pi_cache::run_refresh_probe_command(),
1791        "cursor" => cursor_cache::run_refresh_probe_command(),
1792        _ => Ok(1),
1793    }
1794}
1795
1796fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
1797    let normalized_harness =
1798        models::harness::normalize_harness_name(&args.harness).ok_or_else(|| {
1799            MarsError::Config(ConfigError::Invalid {
1800                message: format!(
1801                    "invalid harness '{}'; valid harnesses: {}",
1802                    args.harness,
1803                    models::harness::VALID_HARNESSES.join(", ")
1804                ),
1805            })
1806        })?;
1807    let mut config = crate::config::load(&ctx.project_root)?;
1808    config.models.insert(
1809        args.name.clone(),
1810        ModelAlias {
1811            harness: Some(normalized_harness.clone()),
1812            description: args.description.clone(),
1813            default_effort: None,
1814            autocompact: None,
1815            autocompact_pct: None,
1816            spec: ModelSpec::Pinned {
1817                model: args.model_id.clone(),
1818                provider: None,
1819            },
1820        },
1821    );
1822    crate::config::save(&ctx.project_root, &config)?;
1823
1824    if json {
1825        println!(
1826            "{}",
1827            serde_json::to_string_pretty(&serde_json::json!({
1828                "status": "ok",
1829                "alias": args.name,
1830                "model": args.model_id,
1831                "harness": normalized_harness,
1832            }))
1833            .unwrap()
1834        );
1835    } else {
1836        println!(
1837            "Added alias `{}` → {} (harness: {})",
1838            args.name, args.model_id, normalized_harness
1839        );
1840    }
1841
1842    Ok(0)
1843}
1844
1845enum FreshOrJsonError {
1846    Fresh(models::ModelsCache, models::RefreshOutcome),
1847    JsonError(String),
1848}
1849
1850fn ensure_fresh_or_json_error(
1851    mars: &std::path::Path,
1852    ttl: u32,
1853    mode: models::RefreshMode,
1854    json: bool,
1855) -> Result<FreshOrJsonError, MarsError> {
1856    match models::ensure_fresh(mars, ttl, mode) {
1857        Ok((cache, outcome)) => Ok(FreshOrJsonError::Fresh(cache, outcome)),
1858        Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
1859            Ok(FreshOrJsonError::JsonError(format!("{err}")))
1860        }
1861        Err(err) => Err(err),
1862    }
1863}
1864
1865struct ResolveExactAliasInput<'a> {
1866    args: &'a ResolveAliasArgs,
1867    alias: &'a ModelAlias,
1868    merged: &'a IndexMap<String, ModelAlias>,
1869    project_config: Option<&'a crate::config::LoadedProjectConfig>,
1870    runtime: ResolveRuntime<'a>,
1871    routing_diagnostics: &'a [String],
1872    json: bool,
1873}
1874
1875fn run_resolve_exact_alias(
1876    input: ResolveExactAliasInput<'_>,
1877    capability_session: &mut CapabilitySession,
1878) -> Result<i32, MarsError> {
1879    let ResolveExactAliasInput {
1880        args,
1881        alias,
1882        merged,
1883        project_config,
1884        runtime,
1885        routing_diagnostics,
1886        json,
1887    } = input;
1888    let cache_warning = cache_warning(runtime.outcome);
1889    if let Some(warning) = cache_warning.as_deref()
1890        && !json
1891    {
1892        eprintln!("warning: {warning}");
1893    }
1894
1895    let name = &args.name;
1896    let source = determine_source(name, project_config);
1897    let mut diag = DiagnosticCollector::new();
1898    let mut resolved_entry =
1899        models::resolve_one_with_probe(name, merged, runtime.cache, &mut diag, None, None, None);
1900    let mut route_trace = None;
1901    let mut fixed_harness_route_rejection = None;
1902    if let Some(r) = resolved_entry.as_mut() {
1903        let provider_constraint = provider_constraint_for_alias(alias);
1904        let route_input = RouteTraceInput {
1905            model_id: &r.model_id,
1906            provider_for_order: &r.provider,
1907            provider_constraint: provider_constraint.as_deref(),
1908            installed: runtime.installed,
1909            opencode_probe_result: None,
1910            pi_probe_result: None,
1911            cursor_probe_result: None,
1912            catalog_model_slugs: Some(runtime.catalog_model_slugs),
1913            routing_settings: runtime.routing_settings,
1914        };
1915        route_trace = Some(if let Some(fixed_harness) = alias.harness.as_deref() {
1916            let mut probe_resolver = SessionProbeResolver {
1917                session: capability_session,
1918            };
1919            let fixed_trace = route_trace_for_fixed_harness_with_probes(
1920                &route_input,
1921                fixed_harness,
1922                crate::routing::RouteSource::Alias,
1923                &mut probe_resolver,
1924            );
1925            let assessed = fixed_trace
1926                .assessments
1927                .iter()
1928                .find(|assessment| assessment.harness == fixed_harness)
1929                .or_else(|| fixed_trace.assessments.first());
1930            fixed_harness_route_rejection = match assessed {
1931                Some(assessment) => crate::routing::acceptance::accept_assessment(assessment).err(),
1932                None => Some(
1933                    crate::routing::acceptance::RejectionReason::AssessmentFailed {
1934                        harness: fixed_harness.to_string(),
1935                        skip_reason: Some("missing_assessment".to_string()),
1936                    },
1937                ),
1938            };
1939            fixed_trace
1940        } else {
1941            let mut probe_resolver = SessionProbeResolver {
1942                session: capability_session,
1943            };
1944            route_trace_for_resolved_model_with_probes(&route_input, &mut probe_resolver)
1945        });
1946        if let Some(trace) = route_trace.as_ref() {
1947            r.harness = Some(trace.selected_harness().to_string());
1948            r.harness_source = match crate::routing::acceptance::accept_route(
1949                trace,
1950                runtime.installed,
1951                crate::routing::acceptance::MatchPolicy::InstalledOnly,
1952            ) {
1953                Ok(()) => HarnessSource::AutoDetected,
1954                Err(_) => HarnessSource::Unavailable,
1955            };
1956            if alias.harness.is_some() {
1957                r.harness_source = HarnessSource::Explicit;
1958            }
1959        }
1960        annotate_one_availability(
1961            r,
1962            args,
1963            runtime.installed,
1964            capability_session.loaded_opencode_probe_result(),
1965            capability_session.loaded_pi_probe_result(),
1966            capability_session.loaded_cursor_probe_result(),
1967        );
1968    }
1969    let diagnostics = diag.drain();
1970    let probe_outcome = capability_session
1971        .loaded_opencode_outcome()
1972        .cloned()
1973        .unwrap_or(CachedProbeOutcome::Unavailable);
1974
1975    if let Some(rejection_reason) = fixed_harness_route_rejection {
1976        let trace = route_trace
1977            .as_ref()
1978            .expect("fixed harness route trace exists");
1979        let Some(resolved) = resolved_entry.as_ref() else {
1980            return Ok(1);
1981        };
1982        return run_resolve_fixed_harness_failure(ResolveFixedHarnessFailureInput {
1983            name,
1984            source: source.as_str(),
1985            resolved,
1986            trace,
1987            cache_warning: cache_warning.as_deref(),
1988            diagnostics: &diagnostics,
1989            rejection_reason: &rejection_reason,
1990            routing_diagnostics,
1991            json,
1992        });
1993    }
1994
1995    if json {
1996        if let Some(r) = resolved_entry.as_ref() {
1997            let mut out = serde_json::json!({
1998                "name": r.name,
1999                "source": source,
2000                "provider": r.provider,
2001                "harness": r.harness,
2002                "harness_source": r.harness_source,
2003                "harness_candidates": r.harness_candidates,
2004                "model_id": r.model_id,
2005                "resolved_model": r.model_id,
2006                "spec": format_spec(&alias.spec),
2007                "description": r.description,
2008            });
2009            out["probe_cache"] = serde_json::json!(probe_outcome.cache_status());
2010            if let Some(error) = unavailable_harness_error(r) {
2011                out["error"] = serde_json::json!(error);
2012            }
2013            if let Some(default_effort) = &r.default_effort {
2014                out["default_effort"] = serde_json::json!(default_effort);
2015            }
2016            if let Some(autocompact) = r.autocompact {
2017                out["autocompact"] = serde_json::json!(autocompact);
2018            }
2019            if let Some(autocompact_pct) = r.autocompact_pct {
2020                out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2021            }
2022            add_availability_json_fields(&mut out, r.availability.as_ref());
2023            if let Some(warning) = cache_warning.as_deref() {
2024                out["cache_warning"] = serde_json::json!(warning);
2025            }
2026            if !diagnostics.is_empty() {
2027                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2028            }
2029            add_routing_diagnostics_json(&mut out, routing_diagnostics);
2030            if let Some(trace) = route_trace.as_ref() {
2031                add_route_json_fields(&mut out, trace);
2032            }
2033            println!("{}", serde_json::to_string_pretty(&out).unwrap());
2034        } else {
2035            let mut out = serde_json::json!({
2036                "error": format!("alias `{}` did not resolve to a model ID", name),
2037            });
2038            if let Some(warning) = cache_warning.as_deref() {
2039                out["cache_warning"] = serde_json::json!(warning);
2040            }
2041            if !diagnostics.is_empty() {
2042                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
2043            }
2044            add_routing_diagnostics_json(&mut out, routing_diagnostics);
2045            println!("{}", serde_json::to_string_pretty(&out).unwrap());
2046            return Ok(1);
2047        }
2048    } else {
2049        if runtime.probe_refresh == ProbeRefreshMode::Background
2050            && matches!(probe_outcome, CachedProbeOutcome::Stale(_))
2051        {
2052            eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2053        }
2054        let Some(r) = resolved_entry.as_ref() else {
2055            eprintln!("error: alias `{}` did not resolve to a model ID", name);
2056            return Ok(1);
2057        };
2058        let harness = r.harness.as_deref().unwrap_or("—");
2059        println!("Alias:    {}", name);
2060        println!("Source:   {}", source);
2061        println!(
2062            "Harness:  {} ({})",
2063            harness,
2064            harness_source_label(&r.harness_source)
2065        );
2066        println!("Provider: {}", r.provider);
2067        match &alias.spec {
2068            ModelSpec::Pinned { model, provider: _ } => {
2069                println!("Mode:     pinned");
2070                println!("Model:    {}", model);
2071            }
2072            ModelSpec::PinnedWithMatch {
2073                model,
2074                provider: _,
2075                match_patterns,
2076                exclude_patterns,
2077            } => {
2078                println!("Mode:     pinned");
2079                println!("Model:    {}", model);
2080                println!("Match:    {}", match_patterns.join(", "));
2081                if !exclude_patterns.is_empty() {
2082                    println!("Exclude:  {}", exclude_patterns.join(", "));
2083                }
2084                println!("Resolved: {}", r.model_id);
2085            }
2086            ModelSpec::AutoResolve {
2087                provider: _,
2088                match_patterns,
2089                exclude_patterns,
2090            } => {
2091                println!("Mode:     auto-resolve");
2092                println!("Match:    {}", match_patterns.join(", "));
2093                if !exclude_patterns.is_empty() {
2094                    println!("Exclude:  {}", exclude_patterns.join(", "));
2095                }
2096                println!("Resolved: {}", r.model_id);
2097            }
2098        }
2099        if let Some(error) = unavailable_harness_error(r) {
2100            println!("Error:    {}", error);
2101        }
2102        print_availability_text(r.availability.as_ref());
2103        if let Some(desc) = &r.description {
2104            println!("Desc:     {}", desc);
2105        }
2106        if let Some(trace) = route_trace.as_ref() {
2107            print_route_text(trace);
2108        }
2109        emit_drained_text_diagnostics(&diagnostics);
2110    }
2111
2112    Ok(0)
2113}
2114
2115struct ResolveFixedHarnessFailureInput<'a> {
2116    name: &'a str,
2117    source: &'a str,
2118    resolved: &'a models::ResolvedAlias,
2119    trace: &'a crate::routing::RoutingTrace,
2120    cache_warning: Option<&'a str>,
2121    diagnostics: &'a [Diagnostic],
2122    rejection_reason: &'a crate::routing::acceptance::RejectionReason,
2123    routing_diagnostics: &'a [String],
2124    json: bool,
2125}
2126
2127struct AutoResolveAliasCacheUnavailableInput<'a> {
2128    name: &'a str,
2129    alias: &'a ModelAlias,
2130    project_config: Option<&'a crate::config::LoadedProjectConfig>,
2131    cache_error: Option<&'a str>,
2132    routing_diagnostics: &'a [String],
2133    json: bool,
2134}
2135
2136fn run_auto_resolve_alias_cache_unavailable(
2137    input: AutoResolveAliasCacheUnavailableInput<'_>,
2138) -> Result<i32, MarsError> {
2139    let AutoResolveAliasCacheUnavailableInput {
2140        name,
2141        alias,
2142        project_config,
2143        cache_error,
2144        routing_diagnostics,
2145        json,
2146    } = input;
2147    let source = determine_source(name, project_config);
2148    let detail = cache_error.unwrap_or("models cache unavailable");
2149    let error = format!(
2150        "alias `{name}` requires models cache for auto-resolve, but cache is unavailable ({detail})"
2151    );
2152
2153    if json {
2154        let mut out = serde_json::json!({
2155            "name": name,
2156            "source": source,
2157            "spec": format_spec(&alias.spec),
2158            "error": error,
2159        });
2160        if let Some(cache_error) = cache_error {
2161            out["cache_error"] = serde_json::json!(cache_error);
2162        }
2163        add_routing_diagnostics_json(&mut out, routing_diagnostics);
2164        println!("{}", serde_json::to_string_pretty(&out).unwrap());
2165    } else {
2166        eprintln!("error: {error}");
2167    }
2168
2169    Ok(1)
2170}
2171
2172fn run_resolve_fixed_harness_failure(
2173    input: ResolveFixedHarnessFailureInput<'_>,
2174) -> Result<i32, MarsError> {
2175    let ResolveFixedHarnessFailureInput {
2176        name,
2177        source,
2178        resolved,
2179        trace,
2180        cache_warning,
2181        diagnostics,
2182        rejection_reason,
2183        routing_diagnostics,
2184        json,
2185    } = input;
2186    let error_message = fixed_alias_rejection_message(rejection_reason);
2187
2188    if json {
2189        let mut out = serde_json::json!({
2190            "name": name,
2191            "source": source,
2192            "provider": resolved.provider,
2193            "harness": trace.selected_harness(),
2194            "model_id": resolved.model_id,
2195            "resolved_model": resolved.model_id,
2196            "error": error_message,
2197            "route_rejection": route_rejection_json(rejection_reason),
2198            "harnesses_tried": trace.candidates_tried,
2199        });
2200        add_route_json_fields(&mut out, trace);
2201        if let Some(warning) = cache_warning {
2202            out["cache_warning"] = serde_json::json!(warning);
2203        }
2204        if !diagnostics.is_empty() {
2205            out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(diagnostics));
2206        }
2207        add_routing_diagnostics_json(&mut out, routing_diagnostics);
2208        println!("{}", serde_json::to_string_pretty(&out).unwrap());
2209    } else {
2210        eprintln!("error: {error_message}");
2211        println!("Alias:    {name}");
2212        println!("Source:   {source}");
2213        println!("Provider: {}", resolved.provider);
2214        println!("Resolved: {}", resolved.model_id);
2215        print_route_text(trace);
2216        emit_drained_text_diagnostics(diagnostics);
2217    }
2218
2219    Ok(1)
2220}
2221
2222fn run_output_resolved(input: OutputResolvedInput<'_>) -> Result<i32, MarsError> {
2223    let OutputResolvedInput {
2224        name,
2225        resolved,
2226        source,
2227        route_trace,
2228        outcome,
2229        cache_outcome,
2230        probe_refresh,
2231        routing_diagnostics,
2232        json,
2233    } = input;
2234    let cache_warning = cache_warning(outcome);
2235    if let Some(warning) = cache_warning.as_deref()
2236        && !json
2237    {
2238        eprintln!("warning: {warning}");
2239    }
2240
2241    if json {
2242        let mut out = serde_json::json!({
2243            "name": name,
2244            "source": source,
2245            "provider": resolved.provider,
2246            "harness": resolved.harness,
2247            "harness_source": resolved.harness_source,
2248            "harness_candidates": resolved.harness_candidates,
2249            "model_id": resolved.model_id,
2250            "resolved_model": resolved.model_id,
2251            "description": resolved.description,
2252        });
2253        if let Some(error) = unavailable_harness_error(resolved) {
2254            out["error"] = serde_json::json!(error);
2255        }
2256        if let Some(default_effort) = &resolved.default_effort {
2257            out["default_effort"] = serde_json::json!(default_effort);
2258        }
2259        if let Some(autocompact) = resolved.autocompact {
2260            out["autocompact"] = serde_json::json!(autocompact);
2261        }
2262        if let Some(autocompact_pct) = resolved.autocompact_pct {
2263            out["autocompact_pct"] = serde_json::json!(autocompact_pct);
2264        }
2265        out["probe_cache"] = serde_json::json!(cache_outcome.cache_status());
2266        add_availability_json_fields(&mut out, resolved.availability.as_ref());
2267        if let Some(warning) = cache_warning.as_deref() {
2268            out["cache_warning"] = serde_json::json!(warning);
2269        }
2270        add_routing_diagnostics_json(&mut out, routing_diagnostics);
2271        add_route_json_fields(&mut out, route_trace);
2272        println!("{}", serde_json::to_string_pretty(&out).unwrap());
2273    } else {
2274        if probe_refresh == ProbeRefreshMode::Background
2275            && matches!(cache_outcome, CachedProbeOutcome::Stale(_))
2276        {
2277            eprintln!("note: using cached opencode probe (stale, background refresh triggered)");
2278        }
2279        let harness = resolved.harness.as_deref().unwrap_or("—");
2280        println!("Alias:    {}", name);
2281        println!("Source:   {}", source);
2282        println!(
2283            "Harness:  {} ({})",
2284            harness,
2285            harness_source_label(&resolved.harness_source)
2286        );
2287        println!("Provider: {}", resolved.provider);
2288        println!("Resolved: {}", resolved.model_id);
2289        if let Some(error) = unavailable_harness_error(resolved) {
2290            println!("Error:    {}", error);
2291        }
2292        print_availability_text(resolved.availability.as_ref());
2293        if let Some(desc) = &resolved.description {
2294            println!("Desc:     {}", desc);
2295        }
2296        print_route_text(route_trace);
2297    }
2298
2299    Ok(0)
2300}
2301
2302fn run_output_passthrough(input: OutputPassthroughInput<'_>) -> Result<i32, MarsError> {
2303    let OutputPassthroughInput {
2304        name,
2305        outcome,
2306        is_offline,
2307        installed,
2308        capability_session,
2309        catalog_model_slugs,
2310        routing_settings,
2311        cache_error,
2312        routing_diagnostics,
2313        json,
2314    } = input;
2315    if name.trim().is_empty() {
2316        if json {
2317            let mut out = serde_json::json!({
2318                "error": "model name cannot be empty",
2319            });
2320            if let Some(cache_error) = cache_error {
2321                out["cache_error"] = serde_json::json!(cache_error);
2322            }
2323            add_routing_diagnostics_json(&mut out, routing_diagnostics);
2324            println!("{}", serde_json::to_string_pretty(&out).unwrap());
2325        } else {
2326            eprintln!("error: model name cannot be empty");
2327        }
2328        return Ok(1);
2329    }
2330
2331    let cache_warning = cache_warning(outcome);
2332    if let Some(warning) = cache_warning.as_deref()
2333        && !json
2334    {
2335        eprintln!("warning: {warning}");
2336    }
2337
2338    let (passthrough_model_id, provider_constraint) =
2339        models::split_provider_constrained_model_token(name);
2340    let guessed_provider =
2341        models::infer_provider_from_model_id(&passthrough_model_id).map(str::to_string);
2342    let provider_for_order = provider_constraint.as_deref().unwrap_or("unknown");
2343    let provider_for_classification = guessed_provider
2344        .as_deref()
2345        .or(provider_constraint.as_deref())
2346        .unwrap_or("unknown");
2347    let routing_evidence = crate::routing::RoutingSettingsEvidence::new(
2348        &passthrough_model_id,
2349        Some(provider_for_order),
2350        provider_constraint.as_deref(),
2351        installed,
2352        None,
2353        None,
2354        None,
2355        catalog_model_slugs,
2356        routing_settings,
2357    );
2358    let trace = {
2359        let mut probe_resolver = SessionProbeResolver {
2360            session: capability_session,
2361        };
2362        crate::routing::evaluate_candidates_with_auth_and_probes(
2363            &routing_evidence.routing_input(),
2364            &mut probe_resolver,
2365            models::harness::native_harness_authenticated,
2366        )
2367    };
2368    if let Err(rejection_reason) = crate::routing::acceptance::accept_route(
2369        &trace,
2370        installed,
2371        crate::routing::acceptance::MatchPolicy::RequireSlugEvidence,
2372    ) {
2373        let message = passthrough_rejection_message(name, &rejection_reason);
2374        if json {
2375            let mut out = serde_json::json!({
2376                "error": message,
2377                "source": "passthrough",
2378                "model_id": passthrough_model_id,
2379                "resolved_model": passthrough_model_id,
2380                "provider_constraint": provider_constraint,
2381                "harnesses_tried": trace.candidates_tried,
2382                "route_rejection": route_rejection_json(&rejection_reason),
2383            });
2384            add_route_json_fields(&mut out, &trace);
2385            if !trace.selected_diagnostics().is_empty() {
2386                out["diagnostics"] = serde_json::json!(trace.selected_diagnostics());
2387            }
2388            if let Some(warning) = cache_warning.as_deref() {
2389                out["cache_warning"] = serde_json::json!(warning);
2390            }
2391            if let Some(cache_error) = cache_error {
2392                out["cache_error"] = serde_json::json!(cache_error);
2393            }
2394            add_routing_diagnostics_json(&mut out, routing_diagnostics);
2395            println!("{}", serde_json::to_string_pretty(&out).unwrap());
2396        } else {
2397            eprintln!("error: {message}");
2398            print_route_text(&trace);
2399        }
2400        return Ok(1);
2401    }
2402
2403    let harness = installed
2404        .contains(trace.selected_harness())
2405        .then_some(trace.selected_harness().to_string());
2406    let harness_source = "pattern_guess";
2407    let harness_candidates = models::harness::harness_candidates_for_provider(provider_for_order);
2408    let availability = models::availability::classify_model(
2409        &passthrough_model_id,
2410        provider_for_classification,
2411        installed,
2412        capability_session.loaded_opencode_probe_result(),
2413        capability_session.loaded_pi_probe_result(),
2414        capability_session.loaded_cursor_probe_result(),
2415        is_offline,
2416    );
2417
2418    let warning = passthrough_catalog_warning(name, &trace);
2419
2420    if json {
2421        let mut out = serde_json::json!({
2422            "name": name,
2423            "source": "passthrough",
2424            "model_id": passthrough_model_id,
2425            "resolved_model": passthrough_model_id,
2426            "provider": guessed_provider,
2427            "harness": harness,
2428            "harness_source": harness_source,
2429            "harness_candidates": harness_candidates,
2430            "description": serde_json::Value::Null,
2431        });
2432        if let Some(warning) = warning.as_deref() {
2433            out["warning"] = serde_json::json!(warning);
2434        }
2435        add_availability_json_fields(&mut out, Some(&availability));
2436        add_route_json_fields(&mut out, &trace);
2437        if let Some(warning) = cache_warning.as_deref() {
2438            out["cache_warning"] = serde_json::json!(warning);
2439        }
2440        if let Some(cache_error) = cache_error {
2441            out["cache_error"] = serde_json::json!(cache_error);
2442        }
2443        add_routing_diagnostics_json(&mut out, routing_diagnostics);
2444        println!("{}", serde_json::to_string_pretty(&out).unwrap());
2445    } else {
2446        if let Some(warning) = warning.as_deref() {
2447            eprintln!("warning: {}", warning);
2448        }
2449        let h = harness.as_deref().unwrap_or("—");
2450        println!("Model:      {}", name);
2451        println!("Source:     passthrough");
2452        println!("Harness:    {} ({})", h, harness_source);
2453        if let Some(provider) = guessed_provider {
2454            println!("Provider:   {}", provider);
2455        }
2456        if !harness_candidates.is_empty() {
2457            println!("Candidates: {}", harness_candidates.join(", "));
2458        }
2459        print_route_text(&trace);
2460    }
2461
2462    Ok(0)
2463}
2464
2465// ---------------------------------------------------------------------------
2466// Helpers
2467// ---------------------------------------------------------------------------
2468
2469fn load_project_config_layers_optional(
2470    project_root: &std::path::Path,
2471) -> Result<Option<crate::config::LoadedProjectConfig>, MarsError> {
2472    match crate::config::load_project_config_layers(project_root) {
2473        Ok(loaded) => Ok(Some(loaded)),
2474        Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(None),
2475        Err(err) => Err(err),
2476    }
2477}
2478
2479fn models_cache_ttl_hours(project_config: Option<&crate::config::LoadedProjectConfig>) -> u32 {
2480    project_config
2481        .map(|loaded| loaded.effective.settings.models_cache_ttl_hours)
2482        .unwrap_or_else(|| crate::config::Settings::default().models_cache_ttl_hours)
2483}
2484
2485/// Load model aliases by combining lock-persisted dependency aliases with effective
2486/// project/local consumer aliases.
2487fn load_merged_aliases(
2488    project_root: &std::path::Path,
2489    project_config: Option<&crate::config::LoadedProjectConfig>,
2490) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
2491    let lock = crate::lock::load_for_runtime_aliases(project_root)?;
2492    Ok(models::merged_runtime_aliases(
2493        &lock.dependency_model_aliases,
2494        project_config.map(|loaded| &loaded.effective.models),
2495    ))
2496}
2497
2498/// Determine which layer provides an alias (consumer or dependency).
2499fn determine_source(
2500    name: &str,
2501    project_config: Option<&crate::config::LoadedProjectConfig>,
2502) -> String {
2503    let Some(project_config) = project_config else {
2504        return "unknown".to_string();
2505    };
2506
2507    if project_config.local.models.contains_key(name) {
2508        return "consumer local (mars.local.toml)".to_string();
2509    }
2510
2511    if project_config.config.models.contains_key(name) {
2512        return "consumer (mars.toml)".to_string();
2513    }
2514
2515    "dependency".to_string()
2516}
2517
2518fn format_spec(spec: &ModelSpec) -> serde_json::Value {
2519    match spec {
2520        ModelSpec::Pinned { model, provider } => {
2521            let mut out = serde_json::json!({ "mode": "pinned", "model": model });
2522            if let Some(provider) = provider {
2523                out["provider"] = serde_json::json!(provider);
2524            }
2525            out
2526        }
2527        ModelSpec::PinnedWithMatch {
2528            model,
2529            provider,
2530            match_patterns,
2531            exclude_patterns,
2532        } => {
2533            let mut out = serde_json::json!({
2534                "mode": "pinned",
2535                "model": model,
2536                "match": match_patterns,
2537                "exclude": exclude_patterns,
2538            });
2539            if let Some(provider) = provider {
2540                out["provider"] = serde_json::json!(provider);
2541            }
2542            out
2543        }
2544        ModelSpec::AutoResolve {
2545            provider,
2546            match_patterns,
2547            exclude_patterns,
2548        } => {
2549            let mut obj = serde_json::json!({
2550                "mode": "auto-resolve",
2551                "match": match_patterns,
2552                "exclude": exclude_patterns,
2553            });
2554            if let Some(provider) = provider {
2555                obj["provider"] = serde_json::json!(provider);
2556            }
2557            obj
2558        }
2559    }
2560}
2561
2562fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
2563    match spec {
2564        Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
2565        Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
2566        None => "unknown",
2567    }
2568}
2569
2570fn harness_source_label(source: &HarnessSource) -> &'static str {
2571    match source {
2572        HarnessSource::Explicit => "explicit",
2573        HarnessSource::AutoDetected => "auto-detected",
2574        HarnessSource::Unavailable => "unavailable",
2575    }
2576}
2577
2578fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
2579    if resolved.harness_source != HarnessSource::Unavailable {
2580        return None;
2581    }
2582    if let Some(h) = &resolved.harness {
2583        Some(format!("Harness '{}' is not installed", h))
2584    } else {
2585        Some(format!(
2586            "No installed harness for provider '{}'. Install one of: {}",
2587            resolved.provider,
2588            resolved.harness_candidates.join(", ")
2589        ))
2590    }
2591}
2592
2593fn fixed_alias_rejection_message(
2594    rejection: &crate::routing::acceptance::RejectionReason,
2595) -> String {
2596    match rejection {
2597        crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2598            "alias harness `{harness}` is not installed and cannot run resolved model under model-first routing"
2599        ),
2600        crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => format!(
2601            "alias harness `{harness}` did not provide required model slug evidence under model-first routing"
2602        ),
2603        crate::routing::acceptance::RejectionReason::AssessmentFailed {
2604            harness,
2605            skip_reason,
2606        } => format!(
2607            "alias harness `{harness}` cannot run resolved model under model-first routing ({})",
2608            skip_reason.as_deref().unwrap_or("unavailable")
2609        ),
2610    }
2611}
2612
2613fn passthrough_rejection_message(
2614    model_name: &str,
2615    rejection: &crate::routing::acceptance::RejectionReason,
2616) -> String {
2617    match rejection {
2618        crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => format!(
2619            "model '{model_name}' selected harness '{harness}', but that harness is not installed"
2620        ),
2621        crate::routing::acceptance::RejectionReason::NoSlugEvidence { .. } => format!(
2622            "model '{model_name}' did not match any harness-reported model slug under model-first routing"
2623        ),
2624        crate::routing::acceptance::RejectionReason::AssessmentFailed {
2625            harness,
2626            skip_reason,
2627        } => format!(
2628            "model '{model_name}' failed model-first routing assessment on harness '{harness}' ({})",
2629            skip_reason.as_deref().unwrap_or("unavailable")
2630        ),
2631    }
2632}
2633
2634fn passthrough_catalog_warning(name: &str, trace: &crate::routing::RoutingTrace) -> Option<String> {
2635    match trace.selected_match_evidence() {
2636        crate::routing::MatchEvidence::Passthrough => Some(format!(
2637            "model '{}' not found in catalog, passing through to harness",
2638            name
2639        )),
2640        crate::routing::MatchEvidence::Confirmed | crate::routing::MatchEvidence::Constrained => {
2641            None
2642        }
2643        crate::routing::MatchEvidence::None => None,
2644    }
2645}
2646
2647fn route_rejection_json(
2648    rejection: &crate::routing::acceptance::RejectionReason,
2649) -> serde_json::Value {
2650    match rejection {
2651        crate::routing::acceptance::RejectionReason::HarnessNotInstalled { harness } => {
2652            serde_json::json!({
2653                "reason": "harness_not_installed",
2654                "harness": harness,
2655            })
2656        }
2657        crate::routing::acceptance::RejectionReason::NoSlugEvidence { harness } => {
2658            serde_json::json!({
2659                "reason": "no_slug_evidence",
2660                "harness": harness,
2661            })
2662        }
2663        crate::routing::acceptance::RejectionReason::AssessmentFailed {
2664            harness,
2665            skip_reason,
2666        } => {
2667            serde_json::json!({
2668                "reason": "assessment_failed",
2669                "harness": harness,
2670                "skip_reason": skip_reason,
2671            })
2672        }
2673    }
2674}
2675
2676fn stale_warning(reason: &str) -> String {
2677    format!("models cache refresh failed: {reason}; using stale cache")
2678}
2679
2680fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
2681    match outcome {
2682        models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
2683        _ => None,
2684    }
2685}
2686
2687fn emit_routing_settings_warnings(routing_diagnostics: &[String]) {
2688    for message in routing_diagnostics {
2689        eprintln!("warning: {message}");
2690    }
2691}
2692
2693fn add_routing_diagnostics_json(out: &mut serde_json::Value, routing_diagnostics: &[String]) {
2694    if !routing_diagnostics.is_empty() {
2695        out["routing_diagnostics"] = serde_json::json!(routing_diagnostics);
2696    }
2697}
2698
2699fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
2700    diagnostics
2701        .iter()
2702        .map(|diagnostic| {
2703            serde_json::json!({
2704                "level": diagnostic_level_label(diagnostic.level),
2705                "code": diagnostic.code,
2706                "message": diagnostic.message,
2707                "context": diagnostic.context,
2708            })
2709        })
2710        .collect()
2711}
2712
2713fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
2714    let diagnostics = diag.drain();
2715    if diagnostics.is_empty() {
2716        None
2717    } else {
2718        Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
2719    }
2720}
2721
2722fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
2723    for diagnostic in diagnostics {
2724        let label = diagnostic_level_label(diagnostic.level);
2725        eprintln!("{label}: {}", diagnostic.message);
2726    }
2727}
2728
2729fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
2730    let diagnostics = diag.drain();
2731    emit_drained_text_diagnostics(&diagnostics);
2732}
2733
2734fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
2735    match level {
2736        DiagnosticLevel::Error => "error",
2737        DiagnosticLevel::Warning => "warning",
2738        DiagnosticLevel::Info => "info",
2739    }
2740}
2741
2742#[cfg(test)]
2743mod tests {
2744    use super::*;
2745    use clap::Parser;
2746    use indexmap::IndexMap;
2747    use tempfile::TempDir;
2748
2749    fn write_mars_toml(temp: &TempDir, contents: &str) {
2750        std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
2751    }
2752
2753    fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
2754        match result {
2755            Ok(code) => code,
2756            Err(err) => err.exit_code(),
2757        }
2758    }
2759
2760    #[test]
2761    fn list_args_parses_no_refresh_models() {
2762        let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
2763        assert!(args.no_refresh_models);
2764    }
2765
2766    #[test]
2767    fn list_args_parses_refresh_models() {
2768        let args = ListArgs::try_parse_from(["mars", "--refresh-models"]).unwrap();
2769        assert!(args.refresh_models);
2770    }
2771
2772    #[test]
2773    fn list_refresh_and_no_refresh_conflict() {
2774        assert!(
2775            ListArgs::try_parse_from(["mars", "--refresh-models", "--no-refresh-models"]).is_err()
2776        );
2777    }
2778
2779    #[test]
2780    fn list_args_parses_catalog() {
2781        let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
2782        assert!(args.catalog);
2783    }
2784
2785    #[test]
2786    fn list_all_and_catalog_conflict() {
2787        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
2788        assert!(parsed.is_err());
2789    }
2790
2791    #[test]
2792    fn list_all_and_include_can_combine() {
2793        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
2794        assert!(parsed.is_ok());
2795    }
2796
2797    #[test]
2798    fn list_catalog_and_include_can_combine() {
2799        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
2800        assert!(parsed.is_ok());
2801    }
2802
2803    #[test]
2804    fn resolve_alias_args_parses_no_refresh_models() {
2805        let args =
2806            ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
2807        assert!(args.no_refresh_models);
2808    }
2809
2810    #[test]
2811    fn list_no_refresh_without_cache_is_non_zero() {
2812        let temp = TempDir::new().unwrap();
2813        write_mars_toml(&temp, "[settings]\n");
2814        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2815        let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
2816
2817        let exit = normalized_exit_code(run(&args, &ctx, false));
2818        assert_ne!(exit, 0);
2819    }
2820
2821    #[test]
2822    fn resolve_no_refresh_without_cache_is_non_zero() {
2823        let temp = TempDir::new().unwrap();
2824        write_mars_toml(
2825            &temp,
2826            r#"[settings]
2827
2828[models.opus]
2829harness = "claude"
2830model = "claude-opus-4-6"
2831"#,
2832        );
2833        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2834        let args =
2835            ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
2836
2837        let exit = normalized_exit_code(run(&args, &ctx, false));
2838        assert_ne!(exit, 0);
2839    }
2840
2841    #[test]
2842    fn alias_updates_existing_model_entry() {
2843        let temp = TempDir::new().unwrap();
2844        write_mars_toml(
2845            &temp,
2846            r#"[settings]
2847
2848[models.fast]
2849harness = "claude"
2850model = "claude-3-5-sonnet"
2851description = "Old alias"
2852"#,
2853        );
2854        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2855
2856        let args = AddAliasArgs {
2857            name: "fast".to_string(),
2858            model_id: "gpt-5.3-codex".to_string(),
2859            harness: "codex".to_string(),
2860            description: Some("Updated alias".to_string()),
2861        };
2862
2863        let exit = run_alias(&args, &ctx, false).unwrap();
2864        assert_eq!(exit, 0);
2865
2866        let config = crate::config::load(temp.path()).unwrap();
2867        assert_eq!(config.models.len(), 1);
2868
2869        let alias = config.models.get("fast").unwrap();
2870        assert_eq!(alias.harness.as_deref(), Some("codex"));
2871        assert_eq!(alias.description.as_deref(), Some("Updated alias"));
2872        match &alias.spec {
2873            ModelSpec::Pinned { model, provider } => {
2874                assert_eq!(model, "gpt-5.3-codex");
2875                assert_eq!(provider, &None);
2876            }
2877            _ => panic!("expected pinned alias"),
2878        }
2879    }
2880
2881    #[test]
2882    fn alias_rejects_invalid_harness_at_write_boundary() {
2883        let temp = TempDir::new().unwrap();
2884        write_mars_toml(&temp, "[settings]\n");
2885        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2886
2887        let args = AddAliasArgs {
2888            name: "fast".to_string(),
2889            model_id: "gpt-5.3-codex".to_string(),
2890            harness: "gemini".to_string(),
2891            description: None,
2892        };
2893
2894        let err = run_alias(&args, &ctx, false).unwrap_err().to_string();
2895        assert!(err.contains("invalid harness 'gemini'"));
2896        assert!(err.contains("valid harnesses: claude, codex, pi, opencode, cursor"));
2897    }
2898
2899    #[test]
2900    fn alias_normalizes_mixed_case_harness_before_write() {
2901        let temp = TempDir::new().unwrap();
2902        write_mars_toml(&temp, "[settings]\n");
2903        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
2904
2905        let args = AddAliasArgs {
2906            name: "fast".to_string(),
2907            model_id: "gpt-5.3-codex".to_string(),
2908            harness: "OpenCode".to_string(),
2909            description: None,
2910        };
2911
2912        let exit = run_alias(&args, &ctx, false).unwrap();
2913        assert_eq!(exit, 0);
2914
2915        let config = crate::config::load(temp.path()).unwrap();
2916        let alias = config.models.get("fast").unwrap();
2917        assert_eq!(alias.harness.as_deref(), Some("opencode"));
2918    }
2919
2920    fn auto_alias(
2921        provider: &str,
2922        match_patterns: &[&str],
2923        exclude_patterns: &[&str],
2924    ) -> ModelAlias {
2925        ModelAlias {
2926            harness: None,
2927            description: None,
2928            default_effort: None,
2929            autocompact: None,
2930            autocompact_pct: None,
2931            spec: ModelSpec::AutoResolve {
2932                provider: Some(provider.to_string()),
2933                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2934                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2935            },
2936        }
2937    }
2938
2939    fn pinned_with_match_alias(
2940        model: &str,
2941        provider: &str,
2942        match_patterns: &[&str],
2943        exclude_patterns: &[&str],
2944    ) -> ModelAlias {
2945        ModelAlias {
2946            harness: None,
2947            description: None,
2948            default_effort: None,
2949            autocompact: None,
2950            autocompact_pct: None,
2951            spec: ModelSpec::PinnedWithMatch {
2952                model: model.to_string(),
2953                provider: Some(provider.to_string()),
2954                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
2955                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
2956            },
2957        }
2958    }
2959
2960    fn pinned_alias(model: &str) -> ModelAlias {
2961        ModelAlias {
2962            harness: None,
2963            description: None,
2964            default_effort: None,
2965            autocompact: None,
2966            autocompact_pct: None,
2967            spec: ModelSpec::Pinned {
2968                model: model.to_string(),
2969                provider: None,
2970            },
2971        }
2972    }
2973
2974    fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
2975        ModelAlias {
2976            harness: None,
2977            description: None,
2978            default_effort: None,
2979            autocompact: None,
2980            autocompact_pct: None,
2981            spec: ModelSpec::Pinned {
2982                model: model.to_string(),
2983                provider: Some(provider.to_string()),
2984            },
2985        }
2986    }
2987
2988    fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
2989        models::CachedModel {
2990            id: id.to_string(),
2991            provider: provider.to_string(),
2992            release_date: release_date.map(|value| value.to_string()),
2993            description: Some(format!("desc-{id}")),
2994            context_window: None,
2995            max_output: None,
2996            cost_input: None,
2997            cost_output: None,
2998            cost_cache_read: None,
2999            cost_cache_write: None,
3000            cost_reasoning: None,
3001        }
3002    }
3003
3004    fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
3005        models::ModelsCache {
3006            models,
3007            fetched_at: Some("123".to_string()),
3008        }
3009    }
3010
3011    fn installed(names: &[&str]) -> HashSet<String> {
3012        names.iter().map(|name| (*name).to_string()).collect()
3013    }
3014
3015    fn default_routing_settings() -> ResolvedRoutingSettings {
3016        crate::config::routing_settings::resolve(&crate::config::Settings::default())
3017    }
3018
3019    #[allow(clippy::too_many_arguments)]
3020    fn collect_all_model_entries(
3021        merged: &IndexMap<String, ModelAlias>,
3022        cache: &models::ModelsCache,
3023        installed: &HashSet<String>,
3024        opencode_probe_result: Option<&OpenCodeProbeResult>,
3025        pi_probe_result: Option<&PiProbeResult>,
3026        cursor_probe_result: Option<&CursorProbeResult>,
3027        is_offline: bool,
3028        routing_settings: &ResolvedRoutingSettings,
3029    ) -> Vec<ListModelEntry> {
3030        let catalog_slugs = models::catalog_model_slugs(cache);
3031        super::collect_all_model_entries(
3032            merged,
3033            cache,
3034            AvailabilityContext {
3035                installed,
3036                opencode_probe_result,
3037                pi_probe_result,
3038                cursor_probe_result,
3039                catalog_model_slugs: Some(catalog_slugs.as_slice()),
3040                is_offline,
3041                routing_settings,
3042            },
3043        )
3044    }
3045
3046    fn collect_catalog_model_entries(
3047        cache: &models::ModelsCache,
3048        installed: &HashSet<String>,
3049        opencode_probe_result: Option<&OpenCodeProbeResult>,
3050        pi_probe_result: Option<&PiProbeResult>,
3051        cursor_probe_result: Option<&CursorProbeResult>,
3052        is_offline: bool,
3053        routing_settings: &ResolvedRoutingSettings,
3054    ) -> Vec<ListModelEntry> {
3055        collect_catalog_model_entries_with_auth(
3056            cache,
3057            installed,
3058            opencode_probe_result,
3059            pi_probe_result,
3060            cursor_probe_result,
3061            is_offline,
3062            routing_settings,
3063            models::harness::native_harness_authenticated,
3064        )
3065    }
3066
3067    #[allow(clippy::too_many_arguments)]
3068    fn collect_catalog_model_entries_with_auth<F>(
3069        cache: &models::ModelsCache,
3070        installed: &HashSet<String>,
3071        opencode_probe_result: Option<&OpenCodeProbeResult>,
3072        pi_probe_result: Option<&PiProbeResult>,
3073        cursor_probe_result: Option<&CursorProbeResult>,
3074        is_offline: bool,
3075        routing_settings: &ResolvedRoutingSettings,
3076        auth_check: F,
3077    ) -> Vec<ListModelEntry>
3078    where
3079        F: Fn(&str) -> bool + Copy,
3080    {
3081        let catalog_slugs = models::catalog_model_slugs(cache);
3082        let availability_ctx = AvailabilityContext {
3083            installed,
3084            opencode_probe_result,
3085            pi_probe_result,
3086            cursor_probe_result,
3087            catalog_model_slugs: Some(catalog_slugs.as_slice()),
3088            is_offline,
3089            routing_settings,
3090        };
3091        let mut out: Vec<ListModelEntry> = cache
3092            .models
3093            .iter()
3094            .map(|model| {
3095                super::model_entry_for_cached_with_auth(model, availability_ctx, auth_check)
3096            })
3097            .collect();
3098        super::sort_list_model_entries(&mut out);
3099        out
3100    }
3101
3102    #[test]
3103    fn list_all_shows_multiple_per_alias() {
3104        let mut merged = IndexMap::new();
3105        merged.insert(
3106            "opus".to_string(),
3107            auto_alias("Anthropic", &["claude-opus-*"], &[]),
3108        );
3109
3110        let models_cache = cache(vec![
3111            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3112            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
3113        ]);
3114
3115        let installed = installed(&[]);
3116        let rows = collect_all_model_entries(
3117            &merged,
3118            &models_cache,
3119            &installed,
3120            None,
3121            None,
3122            None,
3123            false,
3124            &default_routing_settings(),
3125        );
3126        assert_eq!(rows.len(), 2);
3127        assert_eq!(rows[0].id, "claude-opus-4-7");
3128        assert_eq!(rows[1].id, "claude-opus-4-6");
3129    }
3130
3131    #[test]
3132    fn list_all_includes_matched_aliases_with_dedup() {
3133        let mut merged = IndexMap::new();
3134        merged.insert(
3135            "opus".to_string(),
3136            auto_alias("Anthropic", &["claude-opus-*"], &[]),
3137        );
3138        merged.insert(
3139            "legacy".to_string(),
3140            auto_alias("Anthropic", &["*4-6"], &[]),
3141        );
3142
3143        let models_cache = cache(vec![cached_model(
3144            "claude-opus-4-6",
3145            "Anthropic",
3146            Some("2026-02-05"),
3147        )]);
3148
3149        let installed = installed(&[]);
3150        let rows = collect_all_model_entries(
3151            &merged,
3152            &models_cache,
3153            &installed,
3154            None,
3155            None,
3156            None,
3157            false,
3158            &default_routing_settings(),
3159        );
3160        assert_eq!(rows.len(), 1);
3161        assert_eq!(rows[0].id, "claude-opus-4-6");
3162        assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
3163    }
3164
3165    #[test]
3166    fn list_all_includes_pinned_cache_entries() {
3167        let mut merged = IndexMap::new();
3168        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3169
3170        let models_cache = cache(vec![cached_model(
3171            "gpt-5.3-codex",
3172            "OpenAI",
3173            Some("2026-01-01"),
3174        )]);
3175        let installed = installed(&[]);
3176        let rows = collect_all_model_entries(
3177            &merged,
3178            &models_cache,
3179            &installed,
3180            None,
3181            None,
3182            None,
3183            false,
3184            &default_routing_settings(),
3185        );
3186        assert_eq!(rows.len(), 1);
3187        assert_eq!(rows[0].id, "gpt-5.3-codex");
3188        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3189    }
3190
3191    #[test]
3192    fn list_all_includes_pinned_cache_miss_entries() {
3193        let mut merged = IndexMap::new();
3194        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
3195
3196        let models_cache = cache(Vec::new());
3197        let installed = installed(&[]);
3198        let rows = collect_all_model_entries(
3199            &merged,
3200            &models_cache,
3201            &installed,
3202            None,
3203            None,
3204            None,
3205            false,
3206            &default_routing_settings(),
3207        );
3208        assert_eq!(rows.len(), 1);
3209        assert_eq!(rows[0].id, "gpt-5.3-codex");
3210        assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
3211        assert_eq!(rows[0].release_date, None);
3212        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
3213    }
3214
3215    #[test]
3216    fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
3217        let mut merged = IndexMap::new();
3218        merged.insert(
3219            "custom".to_string(),
3220            pinned_alias_with_provider("custom-model-id", "Anthropic"),
3221        );
3222
3223        let models_cache = cache(Vec::new());
3224        let installed = installed(&[]);
3225        let rows = collect_all_model_entries(
3226            &merged,
3227            &models_cache,
3228            &installed,
3229            None,
3230            None,
3231            None,
3232            false,
3233            &default_routing_settings(),
3234        );
3235        assert_eq!(rows.len(), 1);
3236        assert_eq!(rows[0].id, "custom-model-id");
3237        assert_eq!(rows[0].provider, "Anthropic");
3238        assert_eq!(rows[0].release_date, None);
3239        assert_eq!(rows[0].matched_aliases, vec!["custom"]);
3240    }
3241
3242    #[test]
3243    fn list_all_includes_unavailable_harness_entries_with_fallback_candidates() {
3244        let mut merged = IndexMap::new();
3245        merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
3246        let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
3247
3248        let installed = installed(&[]);
3249        let rows = collect_all_model_entries(
3250            &merged,
3251            &models_cache,
3252            &installed,
3253            None,
3254            None,
3255            None,
3256            false,
3257            &default_routing_settings(),
3258        );
3259        assert_eq!(rows.len(), 1);
3260        assert_eq!(rows[0].harness, None);
3261        assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
3262        assert_eq!(rows[0].harness_candidates, vec!["pi", "opencode", "cursor"]);
3263    }
3264
3265    #[test]
3266    fn list_catalog_shows_all_cache_sorted() {
3267        let models_cache = cache(vec![
3268            cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
3269            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3270            cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
3271        ]);
3272
3273        let installed = installed(&[]);
3274        let rows = collect_catalog_model_entries(
3275            &models_cache,
3276            &installed,
3277            None,
3278            None,
3279            None,
3280            false,
3281            &default_routing_settings(),
3282        );
3283        assert_eq!(rows.len(), 3);
3284        assert_eq!(rows[0].id, "claude-opus-4-6");
3285        assert_eq!(rows[1].id, "claude-sonnet-4-5");
3286        assert_eq!(rows[2].id, "gpt-5");
3287    }
3288
3289    #[test]
3290    fn list_catalog_uses_catalog_slugs_for_native_harness_matching() {
3291        let models_cache = cache(vec![cached_model(
3292            "claude-opus-4-6",
3293            "Anthropic",
3294            Some("2026-02-05"),
3295        )]);
3296
3297        let installed = installed(&["claude"]);
3298        let rows = collect_catalog_model_entries_with_auth(
3299            &models_cache,
3300            &installed,
3301            None,
3302            None,
3303            None,
3304            false,
3305            &default_routing_settings(),
3306            |_| true,
3307        );
3308
3309        assert_eq!(rows.len(), 1);
3310        assert_eq!(rows[0].harness.as_deref(), Some("claude"));
3311        assert_eq!(rows[0].harness_source, HarnessSource::AutoDetected);
3312    }
3313
3314    #[test]
3315    fn list_all_includes_pinned_with_match_discovery_candidates() {
3316        let mut merged = IndexMap::new();
3317        merged.insert(
3318            "opus".to_string(),
3319            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3320        );
3321        let models_cache = cache(vec![
3322            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3323            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3324        ]);
3325
3326        let installed = installed(&[]);
3327        let rows = collect_all_model_entries(
3328            &merged,
3329            &models_cache,
3330            &installed,
3331            None,
3332            None,
3333            None,
3334            false,
3335            &default_routing_settings(),
3336        );
3337        assert_eq!(rows.len(), 2);
3338        assert_eq!(rows[0].id, "claude-opus-4-7");
3339        assert_eq!(rows[1].id, "claude-opus-4-6");
3340        assert_eq!(rows[0].matched_aliases, vec!["opus"]);
3341        assert_eq!(rows[1].matched_aliases, vec!["opus"]);
3342    }
3343
3344    #[test]
3345    fn resolve_pinned_with_match_uses_model_field() {
3346        let mut merged = IndexMap::new();
3347        merged.insert(
3348            "opus".to_string(),
3349            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
3350        );
3351        let models_cache = cache(vec![
3352            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
3353            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
3354        ]);
3355        let mut diag = DiagnosticCollector::new();
3356        let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
3357        assert_eq!(resolved.model_id, "claude-opus-4-6");
3358        assert!(diag.drain().is_empty());
3359    }
3360
3361    fn passthrough_trace(
3362        match_evidence: crate::routing::MatchEvidence,
3363    ) -> crate::routing::RoutingTrace {
3364        crate::routing::RoutingTrace {
3365            source: crate::routing::RouteSource::Provider,
3366            selection_kind: crate::routing::SelectionKind::Auto,
3367            match_evidence,
3368            harness: "opencode".to_string(),
3369            harness_order_position: None,
3370            candidates_tried: vec!["opencode".to_string()],
3371            assessments: Vec::new(),
3372            diagnostics: Vec::new(),
3373        }
3374    }
3375
3376    #[test]
3377    fn passthrough_catalog_warning_omits_warning_for_confirmed_and_constrained_routes() {
3378        assert!(
3379            passthrough_catalog_warning(
3380                "openai/gpt-5.4-mini",
3381                &passthrough_trace(crate::routing::MatchEvidence::Confirmed)
3382            )
3383            .is_none()
3384        );
3385        assert!(
3386            passthrough_catalog_warning(
3387                "openai/gpt-5.4-mini",
3388                &passthrough_trace(crate::routing::MatchEvidence::Constrained)
3389            )
3390            .is_none()
3391        );
3392    }
3393
3394    #[test]
3395    fn passthrough_catalog_warning_keeps_warning_for_passthrough_routes() {
3396        let warning = passthrough_catalog_warning(
3397            "unknown-model",
3398            &passthrough_trace(crate::routing::MatchEvidence::Passthrough),
3399        )
3400        .expect("passthrough warning expected");
3401        assert!(warning.contains("not found in catalog"));
3402    }
3403}